Everwood: the HD/OctoWoW line of benilla, imported onto upstream

This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-15 15:01:14 -06:00
co-authored by Claude Opus 5
parent 706cd717a3
commit 81d96af35d
1019 changed files with 85223 additions and 130908 deletions
-3
View File
@@ -1,3 +0,0 @@
# These are supported funding model platforms
ko_fi: marcusaga
+2
View File
@@ -48,5 +48,7 @@ jobs:
run: scripts/doc-links.py
- name: pass-span-lint
run: scripts/pass-span-lint.py
- name: player-build
run: cargo build -p benilla --no-default-features
- name: player-tests
run: cargo test -p benilla-formats -p benilla-app --no-default-features --lib
+3
View File
@@ -45,3 +45,6 @@ __pycache__/
# A zipped snapshot of this checkout, taken by hand to move the tree somewhere. Binary, and
# redundant with the history it would land in.
/benilla-octo.zip
# run.sh's per-run client logs (logs/latest.log, logs/errors.log point at the newest).
logs/
Generated
+30 -39
View File
@@ -540,13 +540,13 @@ dependencies = [
"regex",
"rtrb",
"serde",
"serde_json",
"sha1",
"smithay-clipboard",
"symphonia",
"toml",
"wgpu",
"windows-sys 0.59.0",
"winit",
]
[[package]]
@@ -626,6 +626,7 @@ dependencies = [
"flate2",
"num-bigint",
"rand 0.8.6",
"serde",
"sha1",
"strum",
]
@@ -696,7 +697,6 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e"
dependencies = [
"bevy_dylib",
"bevy_internal",
"tracing",
]
@@ -1016,15 +1016,6 @@ dependencies = [
"sysinfo",
]
[[package]]
name = "bevy_dylib"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f142f72bbbc1a61338bf6da5dc7cf6dcf223683b9d53bcd635ebff4bfeb5308f"
dependencies = [
"bevy_internal",
]
[[package]]
name = "bevy_ecs"
version = "0.18.1"
@@ -3059,6 +3050,15 @@ dependencies = [
"syn",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "epaint"
version = "0.33.3"
@@ -4122,7 +4122,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kira"
version = "0.12.4"
version = "0.12.1"
dependencies = [
"atomic-arena",
"glam 0.33.2",
@@ -4381,8 +4381,6 @@ dependencies = [
[[package]]
name = "mlua-sys"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8"
dependencies = [
"cc",
"cfg-if",
@@ -5666,12 +5664,6 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.10"
@@ -6189,9 +6181,9 @@ dependencies = [
[[package]]
name = "symphonia"
version = "0.6.1"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-mp3",
@@ -6204,20 +6196,21 @@ dependencies = [
[[package]]
name = "symphonia-bundle-mp3"
version = "0.6.1"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.6.1"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "445932ecb0c59362fde9c082dd63a75380650c5077fdb8b80b8cac850442a74c"
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
dependencies = [
"log",
"symphonia-core",
@@ -6225,9 +6218,9 @@ dependencies = [
[[package]]
name = "symphonia-codec-pcm"
version = "0.6.1"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
dependencies = [
"log",
"symphonia-core",
@@ -6235,23 +6228,22 @@ dependencies = [
[[package]]
name = "symphonia-core"
version = "0.6.1"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"bitflags 2.11.1",
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
"num-complex",
"smallvec",
]
[[package]]
name = "symphonia-format-riff"
version = "0.6.1"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
dependencies = [
"extended",
"log",
@@ -6261,14 +6253,13 @@ dependencies = [
[[package]]
name = "symphonia-metadata"
version = "0.6.1"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"regex-lite",
"smallvec",
"symphonia-core",
]
+16 -23
View File
@@ -21,14 +21,14 @@ anyhow = "1.0.102"
# 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",
"2d_bevy_render",
"3d_bevy_render",
"ui_api",
"ui_bevy_render",
"scene",
"picking",
"default_app",
"default_platform",
"2d_bevy_render",
"3d_bevy_render",
"ui_api",
"ui_bevy_render",
"scene",
"picking",
] }
# For the `WOW_GPU_MS` meter's query set, which bevy 0.18 does not wrap; must match bevy's wgpu.
wgpu = "27"
@@ -39,9 +39,15 @@ sha1 = "0.10.6"
# 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.4, patched to compute spatial gains and the volume tween per output chunk and
# 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" }
# mlua-sys 0.10.0, patched so its Lua 5.1 traceback names a frame from `lua_getinfo`, as 5.1's own
# `debug.traceback` does, instead of searching the whole global table per stack level. mlua builds
# that traceback for every `Err` a Rust binding returns, which on this interface's 27,114 globals
# cost ~7 ms a raise — invisibly, for the raises an addon wraps in `pcall`: five a frame measured
# 44.7 ms of VM tick with an empty error log (`third_party/mlua-sys/BENILLA.md`).
mlua-sys = { path = "third_party/mlua-sys" }
# Dependencies at opt-level 3 in dev: at opt-level 0, avian/parry collision drops a run to <1 fps.
# Line tables only, and never packed into the binary, for the reason under `[profile.dev]` below.
@@ -49,13 +55,9 @@ kira = { path = "third_party/kira" }
opt-level = 3
debug = "line-tables-only"
split-debuginfo = "unpacked"
# One codegen unit each, since `lto = "off"` below drops the ThinLTO that inlines across a crate's
# units: at 16 units the dependencies cost +1.2 cpu_ms at the leg pin, at one nothing, and a cold
# build is no slower.
codegen-units = 1
# 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.
# Stormwind drops from a pinned 60 fps to ~45; the warm incremental rebuild cost is ~0.
[profile.dev]
opt-level = 1
# Line tables only, not `debug = 2`. DWARF is ~97% of what rustc writes for this tree: the largest
@@ -70,10 +72,6 @@ opt-level = 1
# with sccache and no disk pressure. Re-measure on macOS before assuming it holds there.
debug = "line-tables-only"
split-debuginfo = "unpacked"
# No ThinLTO: above opt-level 0 rustc runs it over a crate's own codegen units by default, and an
# incremental rebuild redoes it whole: 19 s of a one-line `benilla-app` edit's 25 s, and half its
# 5.3 GB. Off, our crates cost +0.7 cpu_ms (+6 %) at the leg pin.
lto = "off"
# 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.
@@ -92,8 +90,3 @@ incremental = true
# Not for speed: under `incremental`, thin-local LTO leaves promoted `anon.*.llvm.*` symbols
# unresolved and the link fails intermittently (rustc #86049).
lto = "off"
# One codegen unit each, as in dev: at 16 the dependencies cost +1.2 cpu_ms against `release` at
# the leg pin, at one the profile is +0.5, and a cold build is no slower.
[profile.play.package."*"]
codegen-units = 1
+25 -26
View File
@@ -15,7 +15,7 @@ behind each constant.
| Lane | What you see | Main switch |
|---|---|---|
| Sun shadows, characters | real silhouettes for units instead of the oval blob | `characterShadows` |
| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside, and so does the MCSH 0.5 sun dim on models standing on them (`wow_model.wgsl` / `static_gx.wgsl`, terrain's gate and dusk weight); the static caster cache also refreshes when streamed geometry arrives, so a standing camera picks up late trees (`world_shadow.rs::refresh_streamed_shadows`, independent of volumetric fog) | `worldShadows` |
| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside; the static caster cache also refreshes when streamed geometry arrives, so a standing camera picks up late trees (`world_shadow.rs::refresh_streamed_shadows`, independent of volumetric fog) | `worldShadows` |
| Dynamic interiors | WMO rooms are lit per fragment by their own fixtures with a soft falloff, gated per room so light does not leak through walls | `interiorLight`, `interiorRoomGate` |
| Synthesised lights | torches, braziers, lanterns, campfires and lampposts emit a light in the colour of their flame, with flicker | `fireLightGain`, `fireFlicker` |
| Torch shadows | cube-map shadows from point lights onto buildings, models and terrain, with a contact-hardening penumbra and a shadow floor | `interiorShadows`, `exteriorShadows`, `torchShadowStrength`, `interiorShadowSoft` |
@@ -61,19 +61,19 @@ every row the lighting ladder owns (`LIGHTING_PRESETS`: both sun lanes, shadow r
room and torch lanes, moon shadows, `waterQuality`, `volumetricFog`, `bloom`, `sunShafts`,
`colorGrading`, `foliageWind`, the fire/spell/lava gains, `nightGain`, `interiorGain`) follows it.
| row | Classic | Low | Medium | High | Ultra |
| ------------------------------- | ------- | --- | ------ | ---- | ----- |
| `lightingQuality` | Off | Low | Medium | High | Ultra |
| `farclip` (Render Distance, yd) | 350 | 350 | 477 | 777 | 1497 |
| `skyQuality` | 0 | 1 | 1 | 2 | 2 |
| `skyDither` | 0 | 1 | 1 | 1 | 1 |
| `fogModel` | 0 | 1 | 1 | 1 | 1 |
| `rainSurfaces` | 0 | 1 | 1 | 1 | 1 |
| `torchTerrainShadows` | 0 | 0 | 0 | 1 | 1 |
| `daylightWindowSplit` | 0 | 1 | 1 | 1 | 1 |
| `ambientOcclusion` | 0 | 0 | 1 | 2 | 2 |
| `zoneSkyboxes` | 0 | 0 | 1 | 1 | 1 |
| `lampFog` | 0 | 0 | 1 | 2 | 2 |
| row | Classic | Low | Medium | High | Ultra |
|---|---|---|---|---|---|
| `lightingQuality` | Off | Low | Medium | High | Ultra |
| `farclip` (Render Distance, yd) | 350 | 350 | 477 | 777 | 1497 |
| `skyQuality` | 0 | 1 | 1 | 2 | 2 |
| `skyDither` | 0 | 1 | 1 | 1 | 1 |
| `fogModel` | 0 | 1 | 1 | 1 | 1 |
| `rainSurfaces` | 0 | 1 | 1 | 1 | 1 |
| `torchTerrainShadows` | 0 | 0 | 0 | 1 | 1 |
| `daylightWindowSplit` | 0 | 1 | 1 | 1 | 1 |
| `ambientOcclusion` | 0 | 0 | 1 | 2 | 2 |
| `zoneSkyboxes` | 0 | 0 | 1 | 1 | 1 |
| `lampFog` | 0 | 0 | 1 | 2 | 2 |
What the lighting rungs set, beyond Off (everything off, gains 1.0) and High (the registered
defaults): Low = character shadows only at a 1024 map, no torch or moon shadows, bloom Low;
@@ -151,17 +151,17 @@ is its own module, documented in `WATER.md`.
it moves). Every `WowLight` mirror (terrain, wow_model, static_gx, liquid, enhanced_water,
wow_effect, wdl) declares it as `monkey: monkey_frame::MonkeyFrame` after `points`:
| row | x | y | z | w |
| ------------ | --------------------------------------------------- | ----------------- | -------------------------- | ---------------- |
| `fog_a` | height_fog_density | height_fog_height | height_fog_falloff | curve_blend |
| `fog_b` | sun_fog r | g | b | sun_fog_strength |
| `fog_c` | end_fog r | g | b | end_fog_distance |
| `fog_d` | fog_model (0 classic; Modern = scene fog end in yd) | sun_fog_angle | sun direction octahedral x | y |
| `wind_a` | dir_x | dir_y | base_heading (rad) | gust |
| `wind_b` | travel (yd, wrapped at 4096) | sway_strength | grass_strength | tree_strength |
| `wet_a` | rain_rate | wetness | ripple_time_s | snow |
| `misc` | bender_count | time_of_day 0..1 | night 0..1 | 0 |
| `benders[8]` | world x | world y | world z | radius |
| row | x | y | z | w |
|---|---|---|---|---|
| `fog_a` | height_fog_density | height_fog_height | height_fog_falloff | curve_blend |
| `fog_b` | sun_fog r | g | b | sun_fog_strength |
| `fog_c` | end_fog r | g | b | end_fog_distance |
| `fog_d` | fog_model (0 classic; Modern = scene fog end in yd) | sun_fog_angle | sun direction octahedral x | y |
| `wind_a` | dir_x | dir_y | base_heading (rad) | gust |
| `wind_b` | travel (yd, wrapped at 4096) | sway_strength | grass_strength | tree_strength |
| `wet_a` | rain_rate | wetness | ripple_time_s | snow |
| `misc` | bender_count | time_of_day 0..1 | night 0..1 | 0 |
| `benders[8]` | world x | world y | world z | radius |
MONKEY (rainshelter): the rain-shelter region follows the MonkeyFrame block (row 549: two
header rows, then 16384 packed `u32` cells, 65568 B; `weather/shelter.rs`), and the prop-probe
@@ -172,7 +172,6 @@ is its own module, documented in `WATER.md`.
direction is a unit vector in world XZ. Lanes write the `MonkeyFrame` resource
(`benilla-world/src/lighting/monkey_frame.rs`); `global_light::pack_monkey_frame` packs it after
the point table and fills `time_of_day` / `night` itself. All zero = no visual change.
- World lights use upstream's `WorldPointLight`, never Bevy's `PointLight`.
- Foliage wind is vertex-only. Tree and leaf shadow meshes stay static; the small mismatch is the
accepted first-stage cost/complexity tradeoff.
+4 -3
View File
@@ -174,7 +174,8 @@ runtime. The few files under `crates/benilla-app/assets/ui/` are our own, not co
adapters over stock files, and the settings windows and script error log benilla draws itself.
World of Warcraft is a trademark of Blizzard Entertainment, Inc. Our own code is licensed under
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. The two vendored components
under `third_party/`, the kira audio engine and a Lua 5.1 patched to the 1.12 client's dialect,
keep their own upstream licenses, alongside each. Code and techniques ported from other projects
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. The three vendored components
under `third_party/`, the kira audio engine, a Lua 5.1 patched to the 1.12 client's dialect and the
mlua FFI layer with its traceback's global-table search removed, keep their own upstream licenses,
alongside each. Code and techniques ported from other projects
(WarcraftXL, by iThorgrim) are credited file by file in [`THIRD-PARTY.md`](THIRD-PARTY.md).
+17 -17
View File
@@ -23,23 +23,23 @@ Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module <
and gets a row in the table below. Blocks inside a file that port a specific routine also say so
where they stand. Do not remove either.
| Benilla file | WarcraftXL module | Source files | What was taken |
| ---------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (DEPTH LOOK block) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` | Per-channel Beer-Lambert extinction over the view path through the water column, the body as a lerp from scattered colour to what is behind by the transmittance, normal-bent scene-copy refraction scaled by the water depth (ported) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (caustics) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `render/Noise.cpp` | Two caustic layers at incommensurate scales and rates, multiplied (ported; our layers are built from animated cell edges, not value noise) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (HIGH reflections) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl` | Screen-space reflection of the scene copy along an almost-planar normal, masked by the screen edge and by rays turning back toward the eye (principle; ours is a bisected march against the scene depth, not a single probe) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (beach surf, `on_bed`) | `wxl-experimental-water` | `sea/Shore.hpp`, `shaders/Shore.hlsli` | Shore surf limited by the terrain column, so objects standing in the water never foam (principle) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (Gerstner crests, High) + `crates/benilla-world/src/liquid/waves.rs` | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `sea/Spectrum.*`, `sea/Ocean.*` | Gerstner displacement and fold-driven crest whitecaps; the CPU swimmer sample inverts the same displacement (ported) |
| `crates/benilla-world/src/water_fx/bob.rs` | `wxl-experimental-water` | `world/Ride.cpp` | Visual-only wave riding for swimmers; the authoritative position never moves (principle, implemented independently) |
| `crates/benilla-app/src/post/grading.rs` + `crates/benilla-app/src/post/grading.wgsl` | `wxl-retail-grading` | `Grading.cpp`, `shaders/Grading.ps.hlsl` | 32³ LUT convention, strength control and strip lookup mathematics, with the strip uploaded as a native 3D texture (ported; used with the author's permission, attribution required) |
| `crates/benilla-world/src/shaders/sky_fx.wgsl` | `wxl-retail-clouds` | `Clouds.cpp`, `Clouds.hpp` | Domain-warped billow fbm (quintic value noise, 0.6 cotton blend, 0.55 octave gain) for the cloud detail |
| `crates/benilla-world/src/sky_fx.rs` | `wxl-retail-clouds` | `Clouds.cpp`, `Clouds.hpp` | The High cloud-detail tier and the sun-march inputs it feeds the sky and cloud materials (ported) |
| `crates/benilla-world/src/shaders/cloud.wgsl` | `wxl-retail-clouds` | `Clouds.cpp` | The 3-tap march toward the sun (self-shadow, ambient floor) and the one-tap silver lining |
| `crates/benilla-world/src/clouds/layer.rs` (`update_cloud_fx`) | `wxl-retail-clouds` | `Clouds.cpp` | March direction = the sun projected on the sheet, strength folded by its flatness (noon clouds evenly lit) |
| `crates/benilla-world/src/wind/mod.rs` | `wxl-experimental-wind` | `field/Wind.hpp`, `field/Wind.cpp` | Stateless three-sine gust and veer field, default profile, and weather gain (ported) |
| `crates/benilla-assets/src/shaders/wind_hook.wgsl` | `wxl-experimental-wind` | `grass/GrassWind.hpp`, `grass/GrassWind.cpp` | Two-wave grass sway, gust response, lean, blade phase/variance, distance fade and radial parting (ported; extended to eight benders) |
| `crates/benilla-assets/src/shaders/wow_model.wgsl` (MONKEY wind hook) | `wxl-experimental-wind` | `grass/GrassWind.cpp` | Grass vertex displacement call seam (technique) |
| `crates/benilla-world/src/clutter.rs` (MONKEY wind attributes) | `wxl-experimental-wind` | `grass/GrassWind.cpp` | Per-blade bend weight and per-tuft phase inputs (technique; height replaces WXL's unverified texture-V weight) |
| Benilla file | WarcraftXL module | Source files | What was taken |
|---|---|---|---|
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (DEPTH LOOK block) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` | Per-channel Beer-Lambert extinction over the view path through the water column, the body as a lerp from scattered colour to what is behind by the transmittance, normal-bent scene-copy refraction scaled by the water depth (ported) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (caustics) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `render/Noise.cpp` | Two caustic layers at incommensurate scales and rates, multiplied (ported; our layers are built from animated cell edges, not value noise) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (HIGH reflections) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl` | Screen-space reflection of the scene copy along an almost-planar normal, masked by the screen edge and by rays turning back toward the eye (principle; ours is a bisected march against the scene depth, not a single probe) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (beach surf, `on_bed`) | `wxl-experimental-water` | `sea/Shore.hpp`, `shaders/Shore.hlsli` | Shore surf limited by the terrain column, so objects standing in the water never foam (principle) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (Gerstner crests, High) + `crates/benilla-world/src/liquid/waves.rs` | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `sea/Spectrum.*`, `sea/Ocean.*` | Gerstner displacement and fold-driven crest whitecaps; the CPU swimmer sample inverts the same displacement (ported) |
| `crates/benilla-world/src/water_fx/bob.rs` | `wxl-experimental-water` | `world/Ride.cpp` | Visual-only wave riding for swimmers; the authoritative position never moves (principle, implemented independently) |
| `crates/benilla-app/src/post/grading.rs` + `crates/benilla-app/src/post/grading.wgsl` | `wxl-retail-grading` | `Grading.cpp`, `shaders/Grading.ps.hlsl` | 32³ LUT convention, strength control and strip lookup mathematics, with the strip uploaded as a native 3D texture (ported; used with the author's permission, attribution required) |
| `crates/benilla-world/src/shaders/sky_fx.wgsl` | `wxl-retail-clouds` | `Clouds.cpp`, `Clouds.hpp` | Domain-warped billow fbm (quintic value noise, 0.6 cotton blend, 0.55 octave gain) for the cloud detail |
| `crates/benilla-world/src/sky_fx.rs` | `wxl-retail-clouds` | `Clouds.cpp`, `Clouds.hpp` | The High cloud-detail tier and the sun-march inputs it feeds the sky and cloud materials (ported) |
| `crates/benilla-world/src/shaders/cloud.wgsl` | `wxl-retail-clouds` | `Clouds.cpp` | The 3-tap march toward the sun (self-shadow, ambient floor) and the one-tap silver lining |
| `crates/benilla-world/src/clouds/layer.rs` (`update_cloud_fx`) | `wxl-retail-clouds` | `Clouds.cpp` | March direction = the sun projected on the sheet, strength folded by its flatness (noon clouds evenly lit) |
| `crates/benilla-world/src/wind/mod.rs` | `wxl-experimental-wind` | `field/Wind.hpp`, `field/Wind.cpp` | Stateless three-sine gust and veer field, default profile, and weather gain (ported) |
| `crates/benilla-assets/src/shaders/wind_hook.wgsl` | `wxl-experimental-wind` | `grass/GrassWind.hpp`, `grass/GrassWind.cpp` | Two-wave grass sway, gust response, lean, blade phase/variance, distance fade and radial parting (ported; extended to eight benders) |
| `crates/benilla-assets/src/shaders/wow_model.wgsl` (MONKEY wind hook) | `wxl-experimental-wind` | `grass/GrassWind.cpp` | Grass vertex displacement call seam (technique) |
| `crates/benilla-world/src/clutter.rs` (MONKEY wind attributes) | `wxl-experimental-wind` | `grass/GrassWind.cpp` | Per-blade bend weight and per-tuft phase inputs (technique; height replaces WXL's unverified texture-V weight) |
Planned (not yet in the tree): breaker index from `sea/Shore.hpp`, `shaders/Wave.hlsli`.
The lane that ports one adds its row here.
+4 -12
View File
@@ -33,7 +33,7 @@ fork. Do not remove any of them.
| Shore surf limited by the TERRAIN column, so objects in the water never foam | shipped (authored depth gate, `on_bed`) | principle from `sea/Shore.hpp` |
| Per-channel extinction + scene-copy refraction | shipped (`enhanced_water.wgsl` DEPTH LOOK; bent samples landing on anything in front of the water are refused via the scene depth) | ported from `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` |
| Caustics, two layers at incommensurate scales/rates | shipped (floor web; built from animated cell edges, not value noise) | ported from `shaders/Surface.ps.hlsl`, `render/Noise.cpp` |
| Screen-space reflection of the scenery | the planar mirror's fallback for water at another height than the mirror plane (a bisected march against the scene depth, not WXL's single probe; a bisected hit still deep behind the depth is a ray passing behind an occluder and marches on) | principle from `shaders/Surface.ps.hlsl` |
| Screen-space reflection of the scenery | shipped on `High` (a bisected march against the scene depth, not WXL's single probe) | principle from `shaders/Surface.ps.hlsl` |
| Gerstner trains + breaker index, crest-fold foam | planned | `sea/Spectrum.*`, `sea/Shore.hpp`, `shaders/Wave.hlsli` |
| Specular lobe widened by the normal's screen derivative, distance glints | shipped / planned | `shaders/Surface.ps.hlsl` |
@@ -42,7 +42,6 @@ fork. Do not remove any of them.
| Lane | What you see | Main switch |
|---|---|---|
| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
| Planar reflections | High only: the scene drawn a second time from the eye mirrored in the water plane the view looks at (half resolution, clipped at the plane by an oblique near plane, no water, no sun shadow cascades), so docks reflect their undersides and anything above the frame reflects; water at another height falls back to the screen-space march. About +1 ms CPU per frame, GPU about even (the march is skipped where the mirror covers). It REPLACES screen-space reflections on High; there is no player option | tier; dev env `WOW_WATER_REFLECT=0\|1` (0 = screen-space only, for A/B) |
| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear. The fold mask adds the next two wave bands, a slow domain-warped gust field moves the break threshold and calms patches, and ~6 yd segments break each crest into short runs, so the caps do not form a lattice (`whitecap_fold`, `enhanced_water.wgsl`) | tier |
| Near mesh refinement | High builds a transient 4x liquid lattice within 64 yd, with an 80 yd release ring | tier |
@@ -63,17 +62,15 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
- **Shader**: `crates/benilla-assets/src/shaders/enhanced_water.wgsl` - the whole module on the GPU
side, an importable library (`benilla::enhanced_water`) with its own bindings: 103 scene depth,
104 `WaterParams` (`benilla_assets::WaterUniform`), 105 the shared light buffer (a second
read-only view of upstream's binding 90), 106 the scene colour (for refraction and reflection), 107 the planar mirror (`liquid/mirror.rs`). Upstream's `liquid.wgsl` carries three hooks only: the
read-only view of upstream's binding 90), 106 the scene colour (for refraction and reflection). Upstream's `liquid.wgsl` carries three hooks only: the
`#import`, `water_swell` in its vertex stage, and the `water_active()` branch at the top of its
fragment stage, which returns the module's result as-is (the module fogs its own surface terms
only: the scene it refracts and reflects is already fogged). Keep it that way: new
water code goes in the module, not in `liquid.wgsl`.
- **Assets** (`crates/benilla-assets/src`): `water_depth.rs` (`WaterUniform`, the scene-depth image),
`materials.rs` (`LiquidExt` fields 103-107, shader registration), `lib.rs` (`WaterQuality`,
`WaterReflections`).
`materials.rs` (`LiquidExt` fields 103-106, shader registration), `lib.rs` (`WaterQuality`).
- **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (copies the opaque depth AND colour after
the main opaque pass), `liquid/mirror.rs` (the planar mirror camera, its plane vote and binding 107),
`liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs`
the main opaque pass), `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs`
(per-kind material parameters and the opt-in `WOW_WATER_PROBE` WMO classification/depth log),
`liquid/lod.rs` (High-only 4x near-grid swap),
`water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`.
@@ -93,8 +90,3 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
- The temporary water wind is `WATER_WIND_DIR`; all wind-aligned shader terms derive from it until
the weather lane supplies shared wind.
- Run one GPU capture at a time, and run `cargo test` in a shell WITHOUT `WOW_DATA` exported.
- The planar mirror is read at the water pixel's own screen position, flipped vertically: the mirror
camera is the reflected eye rolled half a turn (a proper rotation, so culling and winding stay as they
are). It must keep the world camera's view key (HDR, MSAA, perspective, shadow filter) or every
pipeline compiles a second time; it never draws water (`drop_water_from_mirror`).
- `WOW_WATER_MIRROR_LOG=1` prints the mirror's verdict once a second (plane, votes, active).
+20 -54
View File
@@ -12,18 +12,10 @@ workspace = true
# `dev` compiles in the instruments; the player build (`scripts/gates.sh`) has no defaults.
default = ["dev"]
# The asset, format and world crates default `dev` on, so they come in without it, forwarded here.
dev = [
"benilla-assets/dev",
"benilla-formats/dev",
"benilla-world/dev",
"dep:bevy_egui",
]
dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:bevy_egui"]
tracy = ["bevy/trace_tracy"]
# Writes every system's per-run span to `trace-<ts>.json` in the cwd.
trace_chrome = ["bevy/trace_chrome"]
# Links Bevy as a shared library so an incremental rebuild relinks faster. The binary then starts
# only through `cargo run`, which puts the toolchain's libstd on the loader path.
bevy_dynamic_linking = ["bevy/dynamic_linking"]
[dependencies]
anyhow.workspace = true
@@ -40,8 +32,7 @@ half = "2"
include_dir = "0.7"
# The 1.12 chat filters (`text_filter`), with one `RegexSet` for the any-match pass per line.
regex = "1"
# `getrusage` for perf CPU time; `localtime_r` for the log stamp; the audio clock and render
# thread's realtime policy on macOS.
# `getrusage` for perf CPU time; the audio clock and render thread's realtime policy on macOS.
libc = "0.2"
# PNG encoding only: the macOS cursor's NSImage data and screenshots.
image = { version = "0.25", default-features = false, features = ["png"] }
@@ -53,27 +44,21 @@ benilla-protocol = { path = "../benilla-protocol" }
benilla-world = { path = "../benilla-world", default-features = false }
# The CVar diff persisted in `benilla-config/config.toml`.
serde = { version = "1", features = ["derive"] }
# The item cache's on-disk form (`item_persist.rs`); already in the tree through bevy.
serde_json = "1"
toml = "0.8"
# Collision for our kinematic character controller; 0.6 targets Bevy 0.18, 0.7 moves to 0.19.
avian3d = "0.6"
# Shapes and rasterizes `FontString` text standalone (`ui_text`), at bevy_text's own 0.16.
cosmic-text = "0.16"
# The layout's unshifted character per key (`KeyEventExtModifierSupplement`), which bevy's
# `KeyboardInput` drops; bevy_winit's own 0.30, whose features it sets.
winit = { version = "0.30", default-features = false }
# `cosmic-text`'s font database, named by `ui_text`; must stay the version it depends on.
fontdb = "0.23"
# The mixer. Its `cpal` backend stays off on every target: `sound::output` is our own backend.
kira = { version = "0.12", default-features = false, features = [
"mp3",
"wav",
"pcm",
"adpcm",
] }
kira = { version = "0.12", default-features = false, features = ["mp3", "wav", "pcm", "adpcm"] }
# Aborts a debug build on an allocation in the audio IO callback or render pass; off in release.
assert_no_alloc = "1.1"
# `MediaSource`: our source raises kira's decode thread's QoS; must stay kira's version.
symphonia = { version = "0.6", default-features = false }
symphonia = { version = "0.5", default-features = false }
# The audio threads' lock-free SPSC rings (output, mix tap); kira's version, so the graph has one.
rtrb = "0.3"
mint = "0.5"
@@ -89,49 +74,30 @@ smithay-clipboard = "0.7"
# A native NSCursor: winit's cursor rects revert to the arrow under a continuous render loop.
[target.'cfg(target_os = "macos")'.dependencies]
# The audio device layer (`sound::output::coreaudio`), 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",
] }
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"] }
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = [
"NSApplication",
"NSResponder",
"NSRunningApplication",
"NSWindow",
"NSView",
"NSImage",
"NSCursor",
"NSEvent",
] }
objc2-foundation = { version = "0.2", features = [
"NSArray",
"NSData",
"NSGeometry",
"NSApplication",
"NSResponder",
"NSRunningApplication",
"NSWindow",
"NSView",
"NSImage",
"NSCursor",
"NSEvent",
] }
objc2-foundation = { version = "0.2", features = ["NSArray", "NSData", "NSGeometry"] }
# The audio device layer off macOS (`sound::output::cpal`); on macOS cpal cannot rebuild on a
# lost default device and drops the IO-cycle timestamp.
[target.'cfg(not(target_os = "macos"))'.dependencies]
cpal = "0.17"
# Windows CPU clocks for `perf::clock`; `GetLocalTime` for the log stamp (`ui_chat::logging`); the
# layout's virtual keys for `bindings::layout`.
# Windows CPU clocks for `perf::clock`.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_SystemInformation",
"Win32_System_Threading",
"Win32_UI_Input_KeyboardAndMouse",
] }
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
# The login cycle's fake realmd runs the server half of SRP6 (`net::io::cycle_tests`).
[dev-dependencies]
@@ -0,0 +1,27 @@
-- benilla's own: tolerate the server's AIO blocks this client has no feature for.
--
-- AIO dispatches a server block by name through `AIO_BLOCKHANDLES`, and an unregistered name is
-- an `error()` (`AIO.lua:547-549`). That is the right shape — except **pfUI replaces the global
-- `error` with one that prints to chat and returns** (`pfUI.lua:453-460`, which it does to feed
-- `seterrorhandler`). A non-raising `error` turns AIO's clean abort into a fall-through: the
-- next line calls the handler it just failed to find, so the player sees two errors, the real
-- one being `attempt to call local 'handledata' (a nil value)` at `AIO.lua:558`.
--
-- That is pfUI's bug and it would bite the reference client the same way; what changed here is
-- only that benilla now loads AIO at all (for the calendar). So the blocks this client does not
-- implement are registered as no-ops, which is what "no handler" was always supposed to mean.
--
-- `OctoRadio`: the realm's radio is driven entirely from the client — a station click sends
-- `.radio <n>` as a SAY command and the server answers with `SMSG_PLAY_MUSIC`
-- (`OctoRadioPlaceholder.lua`'s own header says so, and that file contains no AIO call at all).
-- Whatever this block carries is therefore supplementary to a feature that already works, so
-- dropping it costs nothing visible. Logged once so a block that turns out to matter is still
-- findable.
if AIO and AIO.RegisterEvent and not AIO_SERVER then
local ignored = { "OctoRadio" }
for _, name in ipairs(ignored) do
AIO.RegisterEvent(name, function()
-- Nothing: the block is for a feature this client does not implement.
end)
end
end
@@ -1,127 +0,0 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!-- benilla's realmlist control on the stock login screen: a button over the Cinematics one,
the address the next login dials beside it, and the stock GlueDialog with its edit box to
change it. The address is the realmList CVar, written through ConsoleExec; the host keeps
BENILLA_REALMLIST and BENILLA_REALMLIST_PINNED (set while $WOW_HOST fixes the address),
and reopens the dialog with the typed text when it is not an address. -->
<Script>
GlueDialogTypes["BENILLA_REALMLIST"] = {
-- The realmList CVar's registered help text.
text = "Address of realm list server",
button1 = TEXT(OKAY),
button2 = TEXT(CANCEL),
hasEditBox = 1,
maxLetters = 64,
OnShow = function()
BenillaRealmlist_TakeEditBox();
GlueDialogEditBox:SetText(GlueDialog.data or BENILLA_REALMLIST or "");
GlueDialogEditBox:HighlightText();
GlueDialogEditBox:SetFocus();
end,
OnAccept = function()
local typed = GlueDialogEditBox:GetText();
BenillaRealmlist_ReleaseEditBox();
if ( typed and typed ~= "" ) then
ConsoleExec("realmList "..typed);
end
end,
OnCancel = function()
BenillaRealmlist_ReleaseEditBox();
end,
};
GlueDialogTypes["BENILLA_REALMLIST_PINNED"] = {
text = "",
button1 = TEXT(OKAY),
button2 = nil,
};
-- The stock edit box's Enter and Escape call the in-game StaticPopup functions, which the
-- glue does not have: while this dialog owns it, they press its buttons, and it is wide
-- enough for a host name. The stock border is two 75-pixel caps that meet at its own 130
-- pixels, so the widened box also takes the chat box's stretched middle piece
-- (ChatFrame.xml:41-55) between them.
function BenillaRealmlist_TakeEditBox()
if ( not BenillaRealmlist_Saved ) then
BenillaRealmlist_Saved = {
width = GlueDialogEditBox:GetWidth(),
enter = GlueDialogEditBox:GetScript("OnEnterPressed"),
escape = GlueDialogEditBox:GetScript("OnEscapePressed"),
};
end
GlueDialogEditBox:SetWidth(300);
if ( not BenillaRealmlist_Middle ) then
BenillaRealmlist_Middle = GlueDialogEditBox:CreateTexture(nil, "BACKGROUND");
BenillaRealmlist_Middle:SetTexture("Interface\\ChatFrame\\UI-ChatInputBorder-Right");
BenillaRealmlist_Middle:SetTexCoord(0, 0.9375, 0, 1.0);
BenillaRealmlist_Middle:SetHeight(32);
BenillaRealmlist_Middle:SetPoint("LEFT", GlueDialogEditBox, "LEFT", 65, 0);
BenillaRealmlist_Middle:SetPoint("RIGHT", GlueDialogEditBox, "RIGHT", -65, 0);
end
BenillaRealmlist_Middle:Show();
GlueDialogEditBox:SetScript("OnEnterPressed", function() GlueDialogButton1:Click(); end);
GlueDialogEditBox:SetScript("OnEscapePressed", function() GlueDialogButton2:Click(); end);
end
function BenillaRealmlist_ReleaseEditBox()
local saved = BenillaRealmlist_Saved;
if ( saved ) then
BenillaRealmlist_Middle:Hide();
GlueDialogEditBox:SetWidth(saved.width);
GlueDialogEditBox:SetScript("OnEnterPressed", saved.enter);
GlueDialogEditBox:SetScript("OnEscapePressed", saved.escape);
BenillaRealmlist_Saved = nil;
end
GlueDialogEditBox:ClearFocus();
end
function BenillaRealmlist_OnClick()
if ( GlueDialog:IsVisible() ) then
return;
end
PlaySound("gsLoginNewAccount");
if ( BENILLA_REALMLIST_PINNED ) then
GlueDialog_Show("BENILLA_REALMLIST_PINNED", format(
"$WOW_HOST is set for this session, so the realmlist is fixed at %s.",
BENILLA_REALMLIST or ""));
else
GlueDialog_Show("BENILLA_REALMLIST", nil, BENILLA_REALMLIST);
end
end
</Script>
<Button name="BenillaRealmlistButton" inherits="GlueButtonSmallTemplate" parent="AccountLoginUI" text="Realmlist">
<Anchors>
<Anchor point="BOTTOM" relativeTo="AccountLoginCinematicsButton" relativePoint="TOP">
<Offset>
<AbsDimension x="0" y="-2"/>
</Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString name="BenillaRealmlistText" inherits="GlueFontDisableSmall" justifyH="RIGHT">
<Size>
<AbsDimension x="256" y="0"/>
</Size>
<Anchors>
<Anchor point="RIGHT" relativePoint="LEFT">
<Offset>
<AbsDimension x="-4" y="3"/>
</Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnClick>
BenillaRealmlist_OnClick();
</OnClick>
<OnUpdate>
if ( BenillaRealmlistText:GetText() ~= BENILLA_REALMLIST ) then
BenillaRealmlistText:SetText(BENILLA_REALMLIST);
end
</OnUpdate>
</Scripts>
</Button>
</Ui>
@@ -13,40 +13,4 @@
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Frame name="ArenaFrame" setAllPoints="true" parent="CharacterFrame" hidden="true" id="6" />
<!--
TurtleWoW's `Blizzard_InspectUI\InspectArenaFrame.xml` has its three team buttons inherit
`ArenaTeamTemplate`, but no file on the chain defines it (walking every `.xml` in the
install finds none), so the buttons materialize bare and each `OnLoad` raises on the
missing `Text`/`DetailsDisbandButton`/`DetailsName`. Turtle's arena is TW_ARENA addon
messages, not a 1.12 feature, so only the names the stock scripts index matter: the empty
`Text` and `Details` fields and the disband button, which draw nothing. `$parent` is the
immediate parent frame, so the fields land under `…TeamN` + `Details` as the Lua reads them.
-->
<Button name="ArenaTeamTemplate" virtual="true">
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentText" inherits="GameFontNormal" />
</Layer>
</Layers>
<Frames>
<Frame name="$parentDetails" hidden="true">
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentName" inherits="GameFontNormal" />
<FontString name="$parentRating" inherits="GameFontNormal" />
<FontString name="$parentWeekRank" inherits="GameFontNormal" />
<FontString name="$parentWeekGames" inherits="GameFontNormal" />
<FontString name="$parentWeekWins" inherits="GameFontNormal" />
<FontString name="$parentWeekLoss" inherits="GameFontNormal" />
<FontString name="$parentSeasonRank" inherits="GameFontNormal" />
<FontString name="$parentSeasonGames" inherits="GameFontNormal" />
<FontString name="$parentSeasonWins" inherits="GameFontNormal" />
<FontString name="$parentSeasonLoss" inherits="GameFontNormal" />
</Layer>
</Layers>
</Frame>
<Button name="$parentDetailsDisbandButton" />
</Frames>
</Button>
</Ui>
@@ -1,18 +1,54 @@
<!--
Adapters over the stock ContainerFrame.xml and .lua; loaded after them, so these win.
Deviation: our keyring is a container (id -2), not inventory slots as in the reference, so its
tooltip comes from `SetBagItem`; the stock fork (ContainerFrame.lua:615-620) reads the slots.
A full keyring prints an empty error line as the reference does: `PutKeyInKeyRing` reads
`NO_EMPTY_KEYRING_SLOTS`, which 1.12 does not ship.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- ══ The two bag verbs benilla answers differently ═════════════════════════════════════
-- Deviation: `OpenBackpack` opens every equipped bag, not only the backpack, so the vendor
-- and mailbox windows that call it (MerchantFrame.lua:35, MailFrame.lua:39) bring up every
-- bag to sell or mail from. The was-open memory is keyed by bag id: `ContainerFrame1..12`
-- are recycled across bags. The stock `CloseAllBags` (ContainerFrame.lua:702-707) runs
-- unchanged: it calls this `CloseBackpack`, then closes bags 1 and up.
-- The stock body, captured before it is shadowed; nil when no client data was sourced.
local refItemButtonOnEnter = ContainerFrameItemButton_OnEnter
function ContainerFrameItemButton_OnEnter(button)
if not button then
button = this
end
if not button then return end
-- The stock bag-slot path, not its inventory-slot keyring detour.
if button:GetParent():GetID() == KEYRING_CONTAINER then
if (button:GetRight() or 0) >= (GetScreenWidth() / 2) then
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
end
GameTooltip:SetBagItem(KEYRING_CONTAINER, button:GetID())
ResetCursor()
return
end
if not refItemButtonOnEnter then
message("benilla: Interface\\FrameXML\\ContainerFrame.lua was not sourced from the "
.. "game data, so bag-slot tooltips cannot work. Check the client data path.")
return
end
refItemButtonOnEnter(button)
end
-- ══ The three bag verbs benilla answers differently ═══════════════════════════════════
-- Deviation: `OpenBackpack` opens every equipped bag, not only the backpack, so the vendor,
-- bank, mail and trade windows that call it bring up every bag to sell or deposit from.
-- The was-open memory is keyed by bag id: `ContainerFrame1..12` are recycled across bags.
BENILLA_BAG_WAS_OPEN = {}
-- Ignores the was-open memory and covers the bank bags, as ContainerFrame.lua:702-707 does.
function BenillaCloseAllBags()
for bagId = 0, (NUM_CONTAINER_FRAMES or -1) do
CloseBag(bagId)
end
end
-- The reference's `backpackWasOpen`, one entry per bag: CloseBackpack leaves those open.
function BenillaOpenBackpack()
for bagId = 0, (NUM_BAG_FRAMES or -1) do
@@ -36,9 +72,9 @@
end
end
-- The stock names, redefined after the stock ContainerFrame.lua has loaded, so its callers
-- (the vendor and mailbox windows, the vendor's close and `CloseAllBags`) reach these.
-- After the stock ContainerFrame.lua has loaded, so its callers reach these.
OpenBackpack = BenillaOpenBackpack
CloseBackpack = BenillaCloseBackpack
CloseAllBags = BenillaCloseAllBags
]]></Script>
</Ui>
@@ -1,51 +0,0 @@
<Ui>
<!--
benilla's fixes to the stock FrameXML Lua, loaded after every stock file.
-->
<Script><![CDATA[
-- Deviation: the stock FCF_ChatTabFadeFinished (FloatingChatFrame.lua:989-992) nils
-- oldAlpha even under a live hover, so leaving and re-entering within
-- CHAT_FRAME_FADE_TIME (a flick to the scroll buttons, outside the hover box, does it)
-- stops the plate fading in (l.873) for the rest of the session; this keeps oldAlpha while
-- hover is set.
if FCF_ChatTabFadeFinished then
function FCF_ChatTabFadeFinished(chatTab, chatFrame)
chatTab:Hide()
if not chatFrame.hover then
chatFrame.oldAlpha = nil
end
end
end
-- Deviation: re-runs UIParent_ManageFramePositions after DurabilityFrame's own OnEvent. The
-- pass seats the frame 20 further in while a side glyph shows (UIParent.lua:1759-1768),
-- but the stock frame runs it only from OnShow/OnHide, so a glyph appearing while the frame
-- is shown leaves the shield's overhang off the screen edge. The stock handler runs first,
-- unchanged, on the this/event/arg1 already set for the dispatch.
if DurabilityFrame then
local refOnEvent = DurabilityFrame:GetScript("OnEvent")
DurabilityFrame:SetScript("OnEvent", function()
if refOnEvent then refOnEvent() end
UIParent_ManageFramePositions()
end)
end
-- Deviation: friendly plates the player turned on come back at every world entry. The
-- stock UpdateNameplates (UIOptionsFrame.lua:768-780) shows them and hides them again at
-- once (l.775-776), and FRIENDNAMEPLATES_ON is never saved, since l.182 registers
-- NAMEPLATES_ON a second time.
function UpdateNameplates()
if ( NAMEPLATES_ON ) then
ShowNameplates()
else
HideNameplates()
end
if ( FRIENDNAMEPLATES_ON ) then
ShowFriendNameplates()
else
HideFriendNameplates()
end
end
RegisterForSave("FRIENDNAMEPLATES_ON")
]]></Script>
</Ui>
@@ -1,132 +0,0 @@
<Ui>
<!--
The ESC menu: the stock GameMenuFrame.xml, reshaped in place as a 1.12 addon reshapes it.
Deviation: the rungs are the 1.15 era client's ESC menu, because its settings screens
replace 1.12's, which are much worse to use; the era's AddOns rung is dropped, since the
character-select AddOns screen is the only addon UI.
-->
<!-- The era's header font, which 1.12's Fonts.xml lacks. -->
<Font name="BenillaGameFontNormalMed1" inherits="GameFontNormal" virtual="true">
<FontHeight><AbsValue val="13"/></FontHeight>
</Font>
<Script><![CDATA[
-- ══ The frame: the era layout, 28+144+28 wide, 32 top, 28 bottom, 7 rungs of 21 and
-- three gaps of 20 ═══════════════════════════════════════════════════════════════════════
GameMenuFrame:SetWidth(200)
GameMenuFrame:SetHeight(267)
-- The stock title (`GameMenuFrame.xml:36`) has no name: found as addons find it.
for _, region in ipairs({ GameMenuFrame:GetRegions() }) do
if region:GetObjectType() == "FontString" and region:GetText() == MAIN_MENU then
region:SetFontObject(BenillaGameFontNormalMed1)
end
end
-- BENILLA_ERA_WINDOW_SCALE (OptionsFrame.xml) stands in for the era's UIParent scale;
-- read at every show, then the stock OnShow.
local Ref_GameMenuFrame_OnShow = GameMenuFrame:GetScript("OnShow")
GameMenuFrame:SetScript("OnShow", function()
this:SetScale(BENILLA_ERA_WINDOW_SCALE or 1)
Ref_GameMenuFrame_OnShow()
end)
-- ══ The rungs ═══════════════════════════════════════════════════════════════════════════
-- Every stock rung stays, anchored TOP to a BOTTOM as in the stock file, so an addon's
-- `SetPoint("TOP", …)` replaces its one anchor. The three the era menu lacks are hidden
-- in place, with Sound and UI Options each laid over the rung above it, so they take no
-- space: a width or height of 0 is no size in 1.12's layout, not a flat rect.
-- Literal: 1.12 has no GAMEMENU_OPTIONS; its OPTIONS_MENU is "Options Menu".
GameMenuButtonOptions:SetText("Options")
GameMenuButtonOptions:ClearAllPoints()
GameMenuButtonOptions:SetPoint("TOP", GameMenuFrame, "TOP", 0, -32)
GameMenuButtonOptions:SetScript("OnClick", function()
PlaySound("igMainMenuOption")
HideUIPanel(GameMenuFrame)
ShowUIPanel(BenillaOptionsFrame)
end)
for _, fold in ipairs({
{ GameMenuButtonSoundOptions, GameMenuButtonOptions },
{ GameMenuButtonUIOptions, GameMenuButtonSoundOptions },
}) do
local button, above = fold[1], fold[2]
button:Hide()
button:ClearAllPoints()
button:SetPoint("TOP", above, "BOTTOM", 0, button:GetHeight())
end
-- Key Bindings keeps its stock anchor, 1 under UI Options: the era rungs hang from its
-- top, so an addon that inserts a button above it and re-anchors it under that button
-- (ShaguTweaks) moves them down as it moves the stock rungs.
GameMenuButtonKeybindings:Hide()
-- The era's Edit Mode and Support, with nothing behind them yet: disabled.
local editMode = CreateFrame("Button", "BenillaGameMenuButtonEditMode", GameMenuFrame,
"GameMenuButtonTemplate")
editMode:SetPoint("TOP", GameMenuButtonKeybindings, "TOP", 0, -19)
editMode:SetText("Edit Mode")
editMode:Disable()
local support = CreateFrame("Button", "BenillaGameMenuButtonSupport", GameMenuFrame,
"GameMenuButtonTemplate")
support:SetPoint("TOP", editMode, "BOTTOM", 0, 0)
support:SetText("Support")
support:Disable()
GameMenuButtonMacros:SetPoint("TOP", support, "BOTTOM", 0, 0)
GameMenuButtonLogout:SetPoint("TOP", GameMenuButtonMacros, "BOTTOM", 0, -20)
GameMenuButtonQuit:SetPoint("TOP", GameMenuButtonLogout, "BOTTOM", 0, 0)
GameMenuButtonContinue:SetPoint("TOP", GameMenuButtonQuit, "BOTTOM", 0, -20)
-- ══ Our options window in the stock "is an options window open?" checks ═══════════════
-- Each wraps the stock function to add `BenillaOptionsFrame`.
local Ref_IsOptionFrameOpen = IsOptionFrameOpen
local Ref_ToggleGameMenu = ToggleGameMenu
local Ref_UpdateMicroButtons = UpdateMicroButtons
-- 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`: a yes keeps the ESC sweep off the centre panel, which our window is.
function IsOptionFrameOpen()
if BenillaOptions_IsOpen() then
return 1
end
return Ref_IsOptionFrameOpen()
end
-- `UIParent.lua:1465-1497`: static popup, then an open options window, then the stock
-- ladder. Our Close click is the discard-and-hide of the reference's `OptionsFrameCancel`.
function ToggleGameMenu(clicked)
if clicked then
-- As the reference's `clicked` arm: close an options window, then toggle.
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
end
return Ref_ToggleGameMenu(clicked)
end
if StaticPopup_EscapePressed() then
return
end
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
return
end
return Ref_ToggleGameMenu(clicked)
end
-- `MainMenuBarMicroButtons.lua:47-58`: only ever pushes the button, so the stock decision
-- stands and ours adds our window. Guarded for a test kit that loads no micro buttons.
function UpdateMicroButtons()
if Ref_UpdateMicroButtons then
Ref_UpdateMicroButtons()
end
if MainMenuMicroButton and BenillaOptions_IsOpen() then
MainMenuMicroButton:SetButtonState("PUSHED", 1)
end
end
]]></Script>
</Ui>
@@ -0,0 +1,223 @@
<Ui>
<!--
The game menu ESC opens, in the chrome of the reference's GameMenuFrame.xml.
Deviation: the rungs are the 1.15 era client's ESC menu, because its settings screens
replace 1.12's, which are much worse to use; the era's AddOns rung is dropped, since the
character-select AddOns screen is the only addon UI. Addons address `GameMenuFrame` by name.
-->
<Script><![CDATA[
-- Label strings as `X = X or "..."`, so a stock GlobalStrings value wins where one exists.
MAIN_MENU = MAIN_MENU or "Main Menu"
HUD_EDIT_MODE_MENU = HUD_EDIT_MODE_MENU or "Edit Mode"
GAMEMENU_SUPPORT = GAMEMENU_SUPPORT or "Support"
MACROS = MACROS or "Macros"
LOGOUT = LOGOUT or "Logout"
EXIT_GAME = EXIT_GAME or "Exit Game"
RETURN_TO_GAME = RETURN_TO_GAME or "Return to Game"
CANCEL = CANCEL or "Cancel"
-- A rung with nothing behind it yet (Edit Mode, Support), disabled.
function GameMenuButton_Pending(button, text)
button:SetText(text)
button:Disable()
end
-- ══ Our options window in the stock "is an options window open?" checks ══
-- Each wraps the stock body to add `BenillaOptionsFrame`; here because this file loads
-- last, after MainMenuBarMicroButtons.xml.
local Ref_IsOptionFrameOpen = IsOptionFrameOpen
local Ref_ToggleGameMenu = ToggleGameMenu
local Ref_UpdateMicroButtons = UpdateMicroButtons
-- 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`: a yes keeps the ESC sweep off the centre panel, which our window is.
function IsOptionFrameOpen()
if BenillaOptions_IsOpen() then
return 1
end
return Ref_IsOptionFrameOpen()
end
-- `UIParent.lua:1465-1497`: static popup, then an open options window, then the stock
-- ladder. Our Close click is the discard-and-hide of the reference's `OptionsFrameCancel`.
function ToggleGameMenu(clicked)
if clicked then
-- As the reference's `clicked` arm: close an options window, then toggle.
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
end
return Ref_ToggleGameMenu(clicked)
end
if StaticPopup_EscapePressed() then
return
end
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
return
end
return Ref_ToggleGameMenu(clicked)
end
-- `MainMenuBarMicroButtons.lua:47-58`: only ever pushes the button, so the stock decision
-- stands and ours adds our window.
function UpdateMicroButtons()
if Ref_UpdateMicroButtons then
Ref_UpdateMicroButtons()
end
if MainMenuMicroButton and BenillaOptions_IsOpen() then
MainMenuMicroButton:SetButtonState("PUSHED", 1)
end
end
]]></Script>
<!-- The era's header font, which 1.12's Fonts.xml lacks. -->
<Font name="GameFontNormalMed1" inherits="GameFontNormal" virtual="true">
<FontHeight><AbsValue val="13"/></FontHeight>
</Font>
<!-- GameMenuFrame.xml:4's attributes but `movable`, inert there: it has no drag scripts. -->
<Frame name="GameMenuFrame" parent="UIParent" frameStrata="DIALOG" toplevel="true" enableMouse="true" hidden="true">
<!-- The era layout: 28+144+28 wide, 32 top, 28 bottom, 7 rungs of 21, 3 gaps of 20. -->
<Size><AbsDimension x="200" y="267"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
<TileSize><AbsValue val="32"/></TileSize>
<EdgeSize><AbsValue val="32"/></EdgeSize>
</Backdrop>
<Layers>
<Layer level="ARTWORK">
<Texture name="GameMenuFrameHeader" file="Interface\DialogFrame\UI-DialogBox-Header">
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="12"/></Offset></Anchor></Anchors>
</Texture>
<!-- The era's 13px font on the 1.12 seat, within half a unit of the era's. -->
<FontString name="GameMenuFrameTitle" inherits="GameFontNormalMed1" text="MAIN_MENU">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuFrameHeader"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- Literal: 1.12 has no GAMEMENU_OPTIONS; its OPTIONS_MENU is "Options Menu". -->
<Button name="GameMenuButtonOptions" inherits="GameMenuButtonTemplate" text="Options">
<Anchors>
<!-- Centred, not TOPLEFT+28. The 28 was (200-144)/2, the centring offset baked in as an
absolute left edge, and it is right only while the frame is 200 wide. Skins resize this
frame and keep the anchor: pfUI's Game Menu skin takes 30 off the width and re-applies
the button's own point verbatim (skins/blizzard/game_menu.lua), which leaves a TOPLEFT
offset pointing 15 too far right — the column then hangs 2px past the frame's right
edge. A TOP anchor re-centres on any width, which is what the reference's own chain
root does (CENTER to the frame's TOP) and what every skin assumes. -->
<Anchor point="TOP"><Offset><AbsDimension x="0" y="-32"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(GameMenuFrame)
ShowUIPanel(BenillaOptionsFrame)
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonEditMode" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, HUD_EDIT_MODE_MENU)</OnLoad></Scripts>
</Button>
<Button name="GameMenuButtonSupport" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonEditMode" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, GAMEMENU_SUPPORT)</OnLoad></Scripts>
</Button>
<Button name="GameMenuButtonMacros" inherits="GameMenuButtonTemplate" text="MACROS">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonSupport" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<!-- GameMenuFrame.xml:118-122. -->
<Scripts>
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(getglobal("GameMenuFrame"))
ShowMacroFrame()
</OnClick>
</Scripts>
</Button>
<!-- As the reference: disabled while a CAMP or QUIT countdown is up. -->
<Button name="GameMenuButtonLogout" inherits="GameMenuButtonTemplate" text="LOGOUT">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonMacros" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnShow>
if not StaticPopup_Visible("CAMP") and not StaticPopup_Visible("QUIT") then
self:Enable()
else
self:Disable()
end
</OnShow>
<OnClick>
PlaySound("igMainMenuLogout")
Logout()
HideUIPanel(GameMenuFrame)
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonQuit" inherits="GameMenuButtonTemplate" text="EXIT_GAME">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonLogout" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnShow>
if not StaticPopup_Visible("CAMP") and not StaticPopup_Visible("QUIT") then
self:Enable()
else
self:Disable()
end
</OnShow>
<OnClick>
PlaySound("igMainMenuQuit")
Quit()
HideUIPanel(GameMenuFrame)
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonContinue" inherits="GameMenuButtonTemplate" text="RETURN_TO_GAME">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonQuit" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
PlaySound("igMainMenuContinue")
HideUIPanel(GameMenuFrame)
</OnClick>
</Scripts>
</Button>
</Frames>
<!-- The reference's OnShow/OnHide, guarded for a test that loads no action bar. -->
<Scripts>
<OnShow>
-- ERA_WINDOW_SCALE (OptionsFrame.xml) stands in for the era's UIParent scale. A
-- second OnShow on this frame would replace this one.
this:SetScale(ERA_WINDOW_SCALE or 1)
if UpdateMicroButtons then UpdateMicroButtons() end
if Disable_BagButtons then Disable_BagButtons() end
</OnShow>
<OnHide>
if UpdateMicroButtons then UpdateMicroButtons() end
if Enable_BagButtons then Enable_BagButtons() end
</OnHide>
</Scripts>
</Frame>
</Ui>
+135 -197
View File
@@ -4,16 +4,14 @@
OptionsFrame.xml, which loads after. Only commands the engine dispatches appear.
Deviation: the layout is the era Settings panel's Keybindings page (collapsible category
sections), because 1.12's settings screens are much worse to use. Capture follows 1.12's
Blizzard_BindingUI: while a capsule is selected the window is keyboard-enabled, so the
next key binds through its OnKeyDown, and a mouse 3/4/5 click or a wheel notch binds;
left and right clicks only select, and ESC while armed binds Escape (its OnKeyDown has no
ESCAPE filter).
Blizzard_BindingUI: the next key or mouse 3/4/5 press binds, left and right clicks only
select, and ESC while armed binds Escape (its OnKeyDown has no ESCAPE filter).
Deviation: every bind, unbind and reset saves at once, as the era panel commits, where
1.12 had Okay and Cancel; only Reset To Default and leaving the character set confirm.
-->
<!-- 1.12's KeyBindingFrameBindingButtonTemplate; all five buttons reach the handler. -->
<Button name="BenillaKeyBindingButtonTemplate" virtual="true">
<!-- 1.12's KeyBindingFrameBindingButtonTemplate; all five buttons reach the capture. -->
<Button name="KeyBindingButtonTemplate" virtual="true">
<Size><AbsDimension x="180" y="22"/></Size>
<ButtonText name="$parentText"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
@@ -24,13 +22,13 @@
<DisabledFont inherits="GameFontDisableSmall"/>
<Scripts>
<OnLoad>this:RegisterForClicks("LeftButtonUp", "RightButtonUp", "MiddleButtonUp", "Button4Up", "Button5Up")</OnLoad>
<OnClick>BenillaKeyBindingButton_OnClick(arg1)</OnClick>
<OnClick>KeyBindingButton_OnClick(arg1)</OnClick>
</Scripts>
</Button>
<!-- A binding row or a section header in 1.12's QuestLogTitleButton dress, since the era's
expand arrows are not in 1.12; 25 tall, the era's row height. -->
<Frame name="BenillaKeyBindingRowTemplate" virtual="true">
<Frame name="KeyBindingRowTemplate" virtual="true">
<Size><AbsDimension x="560" y="25"/></Size>
<Layers>
<Layer level="BACKGROUND">
@@ -63,13 +61,13 @@
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<Scripts>
<OnClick>BenillaKeyBindings_ToggleSection(this.sectionIndex)</OnClick>
<OnClick>KeyBindings_ToggleSection(this.sectionIndex)</OnClick>
</Scripts>
</Button>
<Button name="$parentKey1Button" inherits="BenillaKeyBindingButtonTemplate" id="1">
<Button name="$parentKey1Button" inherits="KeyBindingButtonTemplate" id="1">
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="175" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentKey2Button" inherits="BenillaKeyBindingButtonTemplate" id="2">
<Button name="$parentKey2Button" inherits="KeyBindingButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="$parentKey1Button" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
</Frames>
@@ -77,7 +75,7 @@
<Script><![CDATA[
-- ── Strings ── a 1.12 GlobalStrings global, or the fallback for a harness without them.
function BenillaKeyBindings_String(name, fallback)
function KeyBindings_String(name, fallback)
local s = getglobal(name)
if s then return s end
return fallback
@@ -85,9 +83,9 @@
-- ── Key display ── 1.12's GetBindingText in full form: modifier prefixes stay tokens, the
-- base key reads its KEY_* global. "CTRL--" is CTRL plus the minus key.
function BenillaKeyBindings_KeyText(key)
function KeyBindings_KeyText(key)
if not key then
return NORMAL_FONT_COLOR_CODE .. BenillaKeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
return NORMAL_FONT_COLOR_CODE .. KeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
end
local prefix = ""
local rest = key
@@ -106,55 +104,43 @@
break
end
end
return prefix .. BenillaKeyBindings_String("KEY_" .. rest, rest)
return prefix .. KeyBindings_String("KEY_" .. rest, rest)
end
-- ── Page state ── `searchPainted` maps OptionsFrame.xml's search rows to binding indices.
BenillaKeyBindingsPage = { sections = {}, items = {} }
KeyBindingsPage = { sections = {}, items = {} }
BENILLA_KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
BENILLA_KEYBINDINGS_DISPLAYED = 19
BENILLA_KEYBINDINGS_ROW_HEIGHT = 25
BENILLA_KEYBINDINGS_SEARCH_ROWS = 8
KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
KEYBINDINGS_DISPLAYED = 19
KEYBINDINGS_ROW_HEIGHT = 25
KEYBINDINGS_SEARCH_ROWS = 8
-- ── Section model ── one section per `HEADER_*` row of the binding list, in its
-- order (Blizzard_BindingUI.lua:84-92), collapsed at first as in the era; a rebuild keeps
-- each state. `token` is the header's GlobalStrings key, `fallback` its bare name.
-- Deviation: the spacer headers BLANK, BLANK2 and BLANK3 over multibars 2-4
-- (Bindings.xml:883/967/1051), which 1.12 draws blank (BINDING_HEADER_BLANK is " ", the
-- other two are not defined), continue the section above, because this page's sections
-- are named and collapsible.
function BenillaKeyBindings_IsHeader(command)
return strsub(command, 1, 6) == "HEADER"
end
function BenillaKeyBindings_BuildSections()
local page = BenillaKeyBindingsPage
-- ── Section model ── one section per category in Bindings.xml order, collapsed at first
-- as in the era; a rebuild keeps each state.
function KeyBindings_BuildSections()
local page = KeyBindingsPage
local was = {}
for _, s in ipairs(page.sections) do
was[s.token] = s.expanded
end
page.sections = {}
local sec = nil
local byToken = {}
for i = 1, GetNumBindings() do
local command = GetBinding(i)
if BenillaKeyBindings_IsHeader(command) then
local token = "BINDING_" .. command
if not (sec and strfind(command, "^HEADER_BLANK%d*$")) then
sec = { token = token, fallback = strsub(command, 8), indices = {},
expanded = was[token] }
table.insert(page.sections, sec)
end
elseif sec then
table.insert(sec.indices, i)
local _, category = GetBinding(i)
local sec = byToken[category]
if not sec then
sec = { token = category, indices = {}, expanded = was[category] }
byToken[category] = sec
table.insert(page.sections, sec)
end
table.insert(sec.indices, i)
end
BenillaKeyBindings_Flatten()
KeyBindings_Flatten()
end
-- The visible list the faux scroll runs over.
function BenillaKeyBindings_Flatten()
local page = BenillaKeyBindingsPage
function KeyBindings_Flatten()
local page = KeyBindingsPage
page.items = {}
for n, sec in ipairs(page.sections) do
table.insert(page.items, { header = n })
@@ -167,38 +153,38 @@
end
-- A header click, voiced with the checkbox sound.
function BenillaKeyBindings_ToggleSection(n)
local sec = BenillaKeyBindingsPage.sections[n]
function KeyBindings_ToggleSection(n)
local sec = KeyBindingsPage.sections[n]
if not sec then return end
PlaySound("igMainMenuOptionCheckBoxOn")
sec.expanded = not sec.expanded
BenillaKeyBindings_Flatten()
BenillaKeyBindingsPage_Update()
KeyBindings_Flatten()
KeyBindingsPage_Update()
end
-- Silent, for the capture fixture.
function BenillaKeyBindings_ExpandSection(n, on)
local sec = BenillaKeyBindingsPage.sections[n]
function KeyBindings_ExpandSection(n, on)
local sec = KeyBindingsPage.sections[n]
if not sec then return end
sec.expanded = on and true or false
BenillaKeyBindings_Flatten()
KeyBindings_Flatten()
end
-- ── Painters ── shared by the list and the search rows, which capture in place.
function BenillaKeyBindings_PaintBindRow(name, index)
local command, key1, key2 = GetBinding(index)
function KeyBindings_PaintBindRow(name, index)
local command, _, key1, key2 = GetBinding(index)
local b1 = getglobal(name .. "Key1Button")
local b2 = getglobal(name .. "Key2Button")
b1.commandName = command
b2.commandName = command
b1:SetText(BenillaKeyBindings_KeyText(key1))
b2:SetText(BenillaKeyBindings_KeyText(key2))
b1:SetText(KeyBindings_KeyText(key1))
b2:SetText(KeyBindings_KeyText(key2))
if key1 then b1:SetAlpha(1) else b1:SetAlpha(0.8) end
if key2 then b2:SetAlpha(1) else b2:SetAlpha(0.8) end
local desc = getglobal(name .. "Description")
desc:SetText(BenillaKeyBindings_String("BINDING_NAME_" .. command, command))
desc:SetText(KeyBindings_String("BINDING_NAME_" .. command, command))
desc:Show()
local page = BenillaKeyBindingsPage
local page = KeyBindingsPage
if page.selected == command and page.keyID == 1 then
b1:LockHighlight()
else
@@ -214,11 +200,11 @@
getglobal(name .. "Header"):Hide()
end
function BenillaKeyBindings_PaintHeaderRow(name, n)
local sec = BenillaKeyBindingsPage.sections[n]
function KeyBindings_PaintHeaderRow(name, n)
local sec = KeyBindingsPage.sections[n]
local header = getglobal(name .. "Header")
header.sectionIndex = n
header:SetText(BenillaKeyBindings_String(sec.token, sec.fallback))
header:SetText(KeyBindings_String(sec.token, sec.token))
local icon = getglobal(name .. "HeaderIcon")
if sec.expanded then
icon:SetTexture("Interface\\Buttons\\UI-MinusButton-Up")
@@ -232,34 +218,34 @@
end
-- ── The list repaint (1.12 KeyBindingFrame_Update) ──
function BenillaKeyBindingsPage_Update()
local page = BenillaKeyBindingsPage
function KeyBindingsPage_Update()
local page = KeyBindingsPage
local scroll = getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrame")
local offset = FauxScrollFrame_GetOffset(scroll)
for i = 1, BENILLA_KEYBINDINGS_DISPLAYED do
local name = BENILLA_KEYBINDINGS_ROW_BASE .. i
for i = 1, KEYBINDINGS_DISPLAYED do
local name = KEYBINDINGS_ROW_BASE .. i
local row = getglobal(name)
local item = page.items[offset + i]
if item and item.header then
BenillaKeyBindings_PaintHeaderRow(name, item.header)
KeyBindings_PaintHeaderRow(name, item.header)
row:Show()
elseif item then
BenillaKeyBindings_PaintBindRow(name, item.bind)
KeyBindings_PaintBindRow(name, item.bind)
row:Show()
else
row:Hide()
end
end
FauxScrollFrame_Update(scroll, table.getn(page.items),
BENILLA_KEYBINDINGS_DISPLAYED, BENILLA_KEYBINDINGS_ROW_HEIGHT)
KEYBINDINGS_DISPLAYED, KEYBINDINGS_ROW_HEIGHT)
-- Our trough follows the bar `FauxScrollFrame_Update` has just shown or hidden.
BenillaScrollTrough_Follow(scroll:GetName())
BenillaKeyBindings_UpdateUnbindKey()
KeyBindings_UpdateUnbindKey()
end
-- The page entry: a rebind or a set switch may have changed the table.
function BenillaKeyBindingsPage_Refresh()
BenillaKeyBindings_BuildSections()
function KeyBindingsPage_Refresh()
KeyBindings_BuildSections()
local box = getglobal("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck")
if GetCurrentBindingSet() == 2 then
box:SetChecked(1)
@@ -267,28 +253,23 @@
box:SetChecked(nil)
end
getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrameScrollBar"):SetValue(0)
BenillaKeyBindingsPage_Update()
KeyBindingsPage_Update()
end
function BenillaKeyBindings_Output(text)
function KeyBindings_Output(text)
getglobal("BenillaOptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
end
-- ── Selection ── while a capsule is selected the window takes every key and the
-- wheel, as 1.12's keyboard-enabled KeyBindingFrame does (Blizzard_BindingUI.xml:73,
-- :541-543); unarmed it takes neither, so keys and the wheel reach the game.
function BenillaKeyBindings_SetSelected(command, keyID)
BenillaKeyBindingsPage.selected = command
BenillaKeyBindingsPage.keyID = keyID
if BenillaOptionsFrame then
BenillaOptionsFrame:EnableKeyboard(command ~= nil)
BenillaOptionsFrame:EnableMouseWheel(command ~= nil)
end
-- ── Selection ── arms the host's capture, which hands its chord to KeyBindings_OnHostKey.
function KeyBindings_SetSelected(command, keyID)
KeyBindingsPage.selected = command
KeyBindingsPage.keyID = keyID
BenillaBindCapture(command ~= nil)
end
function BenillaKeyBindings_UpdateUnbindKey()
function KeyBindings_UpdateUnbindKey()
local unbind = getglobal("BenillaOptionsFrameContainerUnbind")
if BenillaKeyBindingsPage.selected then
if KeyBindingsPage.selected then
unbind:Enable()
else
unbind:Disable()
@@ -296,134 +277,94 @@
end
-- Every search row: a bind from one can unbind a victim shown on another.
function BenillaKeyBindings_RepaintVisible()
if BenillaKeyBindingsPage.searchPainted then
for name, index in pairs(BenillaKeyBindingsPage.searchPainted) do
BenillaKeyBindings_PaintBindRow(name, index)
function KeyBindings_RepaintVisible()
if KeyBindingsPage.searchPainted then
for name, index in pairs(KeyBindingsPage.searchPainted) do
KeyBindings_PaintBindRow(name, index)
end
BenillaKeyBindings_UpdateUnbindKey()
KeyBindings_UpdateUnbindKey()
else
BenillaKeyBindingsPage_Update()
KeyBindingsPage_Update()
end
end
-- 1.12's KeyBindingButton_OnClick (Blizzard_BindingUI.lua:228-258): while armed, a middle,
-- 4 or 5 click binds that button.
function BenillaKeyBindingButton_OnClick(button)
local page = BenillaKeyBindingsPage
function KeyBindingButton_OnClick(button)
-- As 1.12: while armed, a middle, 4 or 5 press is a bind the capture takes after this.
local page = KeyBindingsPage
if page.selected then
if button ~= "LeftButton" and button ~= "RightButton" then
BenillaKeyBindings_OnKeyDown(button)
return
end
if page.selected == this.commandName and page.keyID == this:GetID() then
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
else
BenillaKeyBindings_SetSelected(this.commandName, this:GetID())
BenillaKeyBindings_Output(string.format(
BenillaKeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
BenillaKeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindings_Output(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
else
BenillaKeyBindings_SetSelected(this.commandName, this:GetID())
BenillaKeyBindings_Output(string.format(
BenillaKeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
BenillaKeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindings_Output(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
BenillaKeyBindings_RepaintVisible()
KeyBindings_RepaintVisible()
end
function BenillaKeyBindings_Commit()
function KeyBindings_Commit()
SaveBindings(GetCurrentBindingSet())
end
-- 1.12's KeyBindingFrame_OnKeyDown armed branch (Blizzard_BindingUI.lua:140-212), then
-- the live commit: `button` is a mouse button's or the wheel's name, else the key is
-- `arg1`, the window's OnKeyDown argument. Unarmed it does nothing: the window takes keys
-- only while armed, so 1.12's unarmed ESCAPE arm never runs here.
function BenillaKeyBindings_OnKeyDown(button)
local page = BenillaKeyBindingsPage
-- `chord` is ALT-CTRL-SHIFT-TOKEN; 1.12's OnKeyDown armed branch, then the live commit.
function KeyBindings_OnHostKey(chord)
local page = KeyBindingsPage
if not page.selected then
return
end
if button == "LeftButton" then
button = "BUTTON1"
elseif button == "RightButton" then
button = "BUTTON2"
elseif button == "MiddleButton" then
button = "BUTTON3"
elseif button == "Button4" then
button = "BUTTON4"
elseif button == "Button5" then
button = "BUTTON5"
end
local chord
if button then
if button == "BUTTON1" or button == "BUTTON2" then
return
end
chord = button
else
chord = arg1
end
if chord == "UNKNOWN" or chord == "SHIFT" or chord == "CTRL" or chord == "ALT" then
return
end
if IsShiftKeyDown() then chord = "SHIFT-" .. chord end
if IsControlKeyDown() then chord = "CTRL-" .. chord end
if IsAltKeyDown() then chord = "ALT-" .. chord end
local selected = page.selected
local oldAction = GetBindingAction(chord)
if oldAction ~= "" and oldAction ~= selected then
local k1, k2 = GetBindingKey(oldAction)
if (not k1 or k1 == chord) and (not k2 or k2 == chord) then
BenillaKeyBindings_Output(string.format(
BenillaKeyBindings_String("KEY_UNBOUND_ERROR", "|cffff0000%s Function is Now Unbound!|r"),
BenillaKeyBindings_String("BINDING_NAME_" .. oldAction, oldAction)))
KeyBindings_Output(string.format(
KeyBindings_String("KEY_UNBOUND_ERROR", "|cffff0000%s Function is Now Unbound!|r"),
KeyBindings_String("BINDING_NAME_" .. oldAction, oldAction)))
else
BenillaKeyBindings_Output(BenillaKeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
KeyBindings_Output(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
else
BenillaKeyBindings_Output(BenillaKeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
KeyBindings_Output(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
local key1, key2 = GetBindingKey(selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
if page.keyID == 1 then
BenillaKeyBindings_SetOrRestore(chord, selected, key1)
KeyBindings_SetOrRestore(chord, selected, key1)
if key2 then SetBinding(key2, selected) end
else
if key1 then SetBinding(key1, selected) end
BenillaKeyBindings_SetOrRestore(chord, selected, key2)
KeyBindings_SetOrRestore(chord, selected, key2)
end
BenillaKeyBindings_Commit()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_RepaintVisible()
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_RepaintVisible()
end
-- 1.12's KeyBindingFrame_SetBinding, message and all; SetBinding refuses only a non-key.
function BenillaKeyBindings_SetOrRestore(key, command, oldKey)
function KeyBindings_SetOrRestore(key, command, oldKey)
if not SetBinding(key, command) then
if oldKey then SetBinding(oldKey, command) end
BenillaKeyBindings_Output(BenillaKeyBindings_String(
KeyBindings_Output(KeyBindings_String(
"KEYBINDINGFRAME_MOUSEWHEEL_ERROR",
"Can't bind mousewheel to actions with up and down states"))
end
end
-- The list's OnMouseWheel while armed (Blizzard_BindingUI.xml:377-387).
function BenillaKeyBindings_OnMouseWheel(delta)
if delta > 0 then
BenillaKeyBindings_OnKeyDown("MOUSEWHEELUP")
else
BenillaKeyBindings_OnKeyDown("MOUSEWHEELDOWN")
end
end
-- 1.12's Unbind Key body, then the live commit.
function BenillaKeyBindings_Unbind()
local page = BenillaKeyBindingsPage
function KeyBindings_Unbind()
local page = KeyBindingsPage
local key1, key2 = GetBindingKey(page.selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
@@ -432,70 +373,67 @@
else
if key1 then SetBinding(key1, page.selected) end
end
BenillaKeyBindings_Commit()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindings_RepaintVisible()
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindings_RepaintVisible()
end
-- ── Character-specific set ── checking saves set 2 at once (LoadBindings(2) falls back
-- to the account set); unchecking confirms first, as the era does. The accept loads set 1
-- before saving it, or the character binds would be written into the account set.
function BenillaKeyBindings_CharacterToggle(box)
function KeyBindings_CharacterToggle(box)
PlaySound("igMainMenuOptionCheckBoxOn")
if box:GetChecked() then
LoadBindings(2)
SaveBindings(2)
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindingsPage_Refresh()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
else
box:SetChecked(1)
StaticPopup_Show("BENILLA_CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS")
StaticPopup_Show("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS")
end
end
-- Our key: Blizzard_BindingUI.lua:10 defines the stock one for its own window.
StaticPopupDialogs["BENILLA_CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS"] = {
text = BenillaKeyBindings_String("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS",
StaticPopupDialogs["CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS"] = {
text = KeyBindings_String("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS",
"Really switch to general key bindings? All key bindings specific to this character will be permanantly deleted."),
button1 = BenillaKeyBindings_String("OKAY", "Okay"),
button2 = BenillaKeyBindings_String("CANCEL", "Cancel"),
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
LoadBindings(1)
SaveBindings(1)
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindingsPage_Refresh()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
end,
}
-- Reset To Default confirms, as the era's does; our string, since 1.12 has none.
StaticPopupDialogs["BENILLA_CONFIRM_RESET_KEYBINDINGS"] = {
StaticPopupDialogs["CONFIRM_RESET_KEYBINDINGS"] = {
text = "Are you sure you want to reset all keybindings to their defaults?",
button1 = BenillaKeyBindings_String("OKAY", "Okay"),
button2 = BenillaKeyBindings_String("CANCEL", "Cancel"),
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
PlaySound("igMainMenuOptionCheckBoxOn")
LoadBindings(0)
BenillaKeyBindings_Commit()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindingsPage_Refresh()
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
end,
}
-- ── Search ── a binding's tag is its display name uppercased, as the era's.
-- A static pool of BENILLA_KEYBINDINGS_SEARCH_ROWS rows, where the era's results scroll.
function BenillaKeyBindings_SearchMatches(words)
-- 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
local command = GetBinding(i)
if not BenillaKeyBindings_IsHeader(command) then
local tag = string.upper(BenillaKeyBindings_String("BINDING_NAME_" .. command, command))
local s = BenillaOptionsSearch_Score(tag, words)
if s then
table.insert(out, { score = s, index = i })
end
local tag = string.upper(KeyBindings_String("BINDING_NAME_" .. command, command))
local s = OptionsSearch_Score(tag, words)
if s then
table.insert(out, { score = s, index = i })
end
end
return out
File diff suppressed because it is too large Load Diff
+96 -159
View File
@@ -1,20 +1,16 @@
<Ui>
<!--
The script error log, which 1.12 does not have, so every name carries the `Benilla` prefix;
the stock `ScriptErrors` dialog shows one message, this keeps the log. Laid out on the
ClassTrainer panel art as the class trainer lays itself out (Blizzard_TrainerUI.xml:214-356
and :481-513, Blizzard_TrainerUI.lua:469-474): its rows, its list, its detail under the
list and its two buttons in the art's sockets. It collects as a 1.12 error-log addon does:
it wraps the handler `geterrorhandler` holds, the stock `_ERRORMESSAGE`
(BasicControls.xml:16), which still runs after it. An addon that later calls
`seterrorhandler` replaces the chain, as in 1.12.
The script error log, which 1.12 does not have, so every name carries the `Benilla` prefix
(`tests::reference_surface`); the stock `ScriptErrors` dialog shows one message, this keeps
the log. Shaped like the reference's CraftFrame. It polls the engine:
`BenillaGetNumScriptErrors()` is (retained, total-ever), `BenillaGetScriptErrorInfo(i)` is
`seq, kind, message, count` (1-based, oldest first, nil past the end).
-->
<Script><![CDATA[
-- A pushable above every stock panel's, so a game window opening pushes the log aside.
UIPanelWindows["BenillaScriptLogFrame"] = { area = "left", pushable = 9, whileDead = 1 }
-- The class trainer's rows (`CLASS_TRAINER_SKILLS_DISPLAYED`, Blizzard_TrainerUI.lua:470).
BENILLA_SCRIPTLOG_ROWS = 11
BENILLA_SCRIPTLOG_ROWS = 13
BENILLA_SCRIPTLOG_ROW_HEIGHT = 16
-- A wrapped row would break the faux kit's fixed rows.
BENILLA_SCRIPTLOG_ROW_CHARS = 52
@@ -22,39 +18,6 @@
-- A `seq`, not an index: indices shift when the cap evicts.
BenillaScriptLogSelected = nil
-- Distinct messages kept before the oldest is evicted.
BENILLA_SCRIPTLOG_CAP = 256
-- Rows oldest first, each { seq, message, count }; `total` is the last `seq` handed out,
-- kept apart from the row count so an eviction or a clear never renumbers.
BenillaScriptLog = { rows = {}, total = 0 }
-- A repeat bumps its row's count and keeps its place.
function BenillaScriptLog_Record(message)
if type(message) ~= "string" then message = tostring(message) end
local log = BenillaScriptLog
local found = nil
for i = 1, table.getn(log.rows) do
if log.rows[i].message == message then found = log.rows[i] end
end
if found then
found.count = found.count + 1
else
log.total = log.total + 1
if table.getn(log.rows) >= BENILLA_SCRIPTLOG_CAP then table.remove(log.rows, 1) end
table.insert(log.rows, { seq = log.total, message = message, count = 1 })
end
if BenillaScriptLogFrame and BenillaScriptLogFrame:IsVisible() then
BenillaScriptLog_Update()
end
end
-- The chain: the log first, guarded so a fault in it cannot stop the stock handler.
local benillaStockErrorHandler = geterrorhandler()
seterrorhandler(function(message)
pcall(BenillaScriptLog_Record, message)
return benillaStockErrorHandler(message)
end)
-- `strsub` counts bytes, so the cut backs up over UTF-8 continuation bytes (0x80..0xBF).
function BenillaScriptLog_RowText(message)
local cut = strfind(message, "\n", 1, 1)
@@ -72,10 +35,18 @@
return head
end
function BenillaScriptLog_Update()
local shown = table.getn(BenillaScriptLog.rows)
local total = BenillaScriptLog.total
-- `load` red (the addon is not running), `error` amber (code raised), `warn` grey (a call
-- was accepted and quietly did nothing).
function BenillaScriptLog_RowColor(kind)
if kind == "load" then return 1.0, 0.3, 0.3 end
if kind == "warn" then return 0.65, 0.65, 0.65 end
return 1.0, 0.82, 0.0
end
function BenillaScriptLog_Update()
local shown, total = BenillaGetNumScriptErrors()
-- "Problems", because two of the three kinds are not errors.
local summary
if total == 0 then
summary = "No problems this session."
@@ -97,17 +68,18 @@
for i = 1, BENILLA_SCRIPTLOG_ROWS do
local button = getglobal("BenillaScriptLogRow" .. i)
local index = offset + i
local row = BenillaScriptLog.rows[index]
if row then
button.seq = row.seq
button.message = row.message
local seq, kind, message, count = BenillaGetScriptErrorInfo(index)
if seq then
button.seq = seq
button.message = message
-- `#12 x1113`: one deduplicated failure that happened 1,113 times.
local label = "#" .. row.seq
if row.count > 1 then label = label .. " x" .. row.count end
local label = "#" .. seq
if count > 1 then label = label .. " x" .. count end
button.index:SetText(label)
button.label:SetText(BenillaScriptLog_RowText(row.message))
button.label:SetText(BenillaScriptLog_RowText(message))
button.label:SetTextColor(BenillaScriptLog_RowColor(kind))
button:Show()
if row.seq == BenillaScriptLogSelected then selectedRow = button end
if seq == BenillaScriptLogSelected then selectedRow = button end
else
button.seq = nil
button.message = nil
@@ -127,16 +99,15 @@
BenillaScriptLog_UpdateDetail()
end
-- The selected message in full, traceback and all, in a box a player can select and copy
-- from. A text that has not changed is left alone: `SetText` collapses the selection, so a
-- new error arriving must not undo one about to be copied. A different text takes the
-- keyboard from the box, as a click on another row does.
-- The selected message in full, traceback and all.
function BenillaScriptLog_UpdateDetail()
local text = ""
if BenillaScriptLogSelected then
for _, row in ipairs(BenillaScriptLog.rows) do
if row.seq == BenillaScriptLogSelected then
text = row.message
local shown = BenillaGetNumScriptErrors()
for i = 1, shown do
local seq, _, message = BenillaGetScriptErrorInfo(i)
if seq == BenillaScriptLogSelected then
text = message
break
end
end
@@ -144,24 +115,12 @@
if text == "" then
text = "Select a row above to read it in full."
end
local box = getglobal("BenillaScriptLogDetailText")
if text ~= box.shown then
box.shown = text
box:ClearFocus()
box:SetText(text)
end
getglobal("BenillaScriptLogDetailText"):SetText(text)
end
-- The box is read-only: a player's edit is undone. The restore's own `SetText` raises
-- another change, which finds the text equal and stops; `SetText` of an equal text does
-- nothing. The selection an edit replaced is put back, so Ctrl+C still copies it all.
function BenillaScriptLogDetail_OnTextChanged()
local shown = this.shown or ""
if this:GetText() ~= shown then
this:SetText(shown)
this:HighlightText()
end
ScrollingEdit_OnTextChanged(getglobal("BenillaScriptLogDetailScroll"))
-- Text rects resolve after Lua runs, so the detail child is re-measured every frame.
function BenillaScriptLogFrame_OnUpdate()
BenillaScroll_ResizeChild("BenillaScriptLogDetailScroll", { "BenillaScriptLogDetailText" })
end
function BenillaScriptLogRow_OnClick(button)
@@ -174,7 +133,7 @@
BenillaScriptLog_Update()
end
function BenillaScriptLogFrame_OnLoad()
function BenillaScriptLogFrame_OnLoad(self)
-- Masks the square icon to the portrait ring.
SetPortraitToTexture("BenillaScriptLogFramePortrait",
"Interface\\Icons\\INV_Misc_Note_01")
@@ -198,7 +157,7 @@
-- `/errors clear`: keeps the numbering, so a number never names two failures.
function BenillaScriptLog_Clear()
BenillaScriptLog.rows = {}
BenillaClearScriptErrors()
BenillaScriptLogSelected = nil
if getglobal("BenillaScriptLogFrame"):IsVisible() then
BenillaScriptLog_Update()
@@ -225,16 +184,15 @@
<HighlightTexture file="Interface\Buttons\UI-Listbox-Highlight2" alphaMode="ADD"/>
<Scripts>
<OnLoad>
this.index = getglobal(this:GetName() .. "Index")
this.label = getglobal(this:GetName() .. "Label")
self.index = getglobal(self:GetName() .. "Index")
self.label = getglobal(self:GetName() .. "Label")
</OnLoad>
<OnClick>BenillaScriptLogRow_OnClick(this)</OnClick>
<OnClick>BenillaScriptLogRow_OnClick(self)</OnClick>
</Scripts>
</Button>
<Frame name="BenillaScriptLogFrame" parent="UIParent" hidden="true">
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. Under UIParent,
as the stock panels are, so it takes the UI scale with them. -->
<Frame name="BenillaScriptLogFrame" hidden="true">
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<Layer level="BACKGROUND">
@@ -274,9 +232,8 @@
</Layer></Layers>
</Frame>
<!-- The class trainer's first row (ClassTrainerSkill1, Blizzard_TrainerUI.xml:214-222). -->
<Button name="BenillaScriptLogRow1" inherits="BenillaScriptLogRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-100"/></Offset></Anchor></Anchors>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
</Button>
<Button name="BenillaScriptLogRow2" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow1" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow3" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow2" relativePoint="BOTTOMLEFT"/></Anchors></Button>
@@ -288,29 +245,28 @@
<Button name="BenillaScriptLogRow9" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow8" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow10" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow9" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow11" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow10" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow12" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow11" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow13" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow12" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<!-- The class trainer's list frame (Blizzard_TrainerUI.xml:313-322, 184 tall as its .lua:471
sets it). Only the faux kit's right-hand Slider renders. -->
<!-- Only the faux kit's right-hand Slider renders. -->
<ScrollFrame name="BenillaScriptLogListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="296" y="184"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-67" y="-96"/></Offset></Anchor></Anchors>
<Scripts>
<Size><AbsDimension x="293" y="208"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
<Scripts>
<!-- The stock kit has no `updateFunc`, so the repaint is wired here. -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(BENILLA_SCRIPTLOG_ROW_HEIGHT, BenillaScriptLog_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
</ScrollFrame>
<!-- The class trainer's detail pane, 8 under its list and 119 tall (Blizzard_TrainerUI.xml:
346-356, .lua:472), so it ends above the buttons' sockets. The real-scroll kit: a
message with its traceback has variable height. -->
<!-- The real-scroll kit: a message with its traceback has variable height. -->
<ScrollFrame name="BenillaScriptLogDetailScroll">
<Size><AbsDimension x="296" y="119"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogListScrollFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor></Anchors>
<Size><AbsDimension x="293" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-314"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>this:SetScrollChild(getglobal(this:GetName() .. "Child"))</OnLoad>
<OnMouseWheel>BenillaScroll_OnMouseWheel(this, arg1)</OnMouseWheel>
<OnVerticalScroll>BenillaScroll_SyncBar(this)</OnVerticalScroll>
<OnScrollRangeChanged>BenillaScroll_SyncBar(this)</OnScrollRangeChanged>
<OnLoad>self:SetScrollChild(getglobal(self:GetName() .. "Child"))</OnLoad>
<OnMouseWheel>BenillaScroll_OnMouseWheel(self, arg1)</OnMouseWheel>
<OnVerticalScroll>BenillaScroll_SyncBar(self)</OnVerticalScroll>
<OnScrollRangeChanged>BenillaScroll_SyncBar(self)</OnScrollRangeChanged>
</Scripts>
<Frames>
<Slider name="$parentBar" inherits="BenillaScrollBarTemplate" hidden="true">
@@ -318,83 +274,64 @@
<Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="16"/></Offset></Anchor>
</Anchors>
<Scripts><OnValueChanged>BenillaScrollBar_OnValueChanged(this, arg1)</OnValueChanged></Scripts>
<Scripts><OnValueChanged>BenillaScrollBar_OnValueChanged(self, arg1)</OnValueChanged></Scripts>
</Slider>
<!-- Mouse-enabled and the pane's full height, as SendMailScrollChildFrame is
(MailFrame.xml:604-659): a click below the text focuses the box. -->
<Frame name="$parentChild" enableMouse="true">
<Size><AbsDimension x="296" y="119"/></Size>
<Frame name="$parentChild">
<Size><AbsDimension x="293" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Frames>
<!-- A multi-line box in a scroll frame as MailFrame.xml:616-649 hosts the mail
body: `autoFocus="false"`, so the keyboard stays with the game until a
click, and Escape clears the focus. A multi-line box is as tall as its
insets and its wrapped text, so the scroll frame ranges over it as
ScrollingEdit_OnTextChanged (UIPanelTemplates.lua:307) recomputes. The
insets seat the text 6 in and 6 down, 270 wide, and leave 20 below
the last line. No `letters`, so the whole error goes in. Read-only
through OnTextChanged; a click selects it all, so Ctrl+C copies the
error. Not wired: the stock scroll-to-caret (UIPanelTemplates.lua:
314-342), since selecting all leaves the caret at the end and a
click would scroll a long traceback to its bottom. -->
<EditBox name="BenillaScriptLogDetailText" multiLine="true" autoFocus="false">
<Size><AbsDimension x="296" y="119"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<TextInsets><AbsInset left="6" right="20" top="6" bottom="20"/></TextInsets>
<Scripts>
<OnTextChanged>BenillaScriptLogDetail_OnTextChanged()</OnTextChanged>
<OnEditFocusGained>this:HighlightText()</OnEditFocusGained>
<OnEscapePressed>this:ClearFocus()</OnEscapePressed>
</Scripts>
<FontString inherits="GameFontHighlightSmall"/>
</EditBox>
</Frames>
<Scripts>
<OnMouseUp>BenillaScriptLogDetailText:SetFocus()</OnMouseUp>
</Scripts>
<Layers>
<Layer level="ARTWORK">
<!-- 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>
</FontString>
</Layer>
</Layers>
</Frame>
</Frames>
</ScrollFrame>
<!-- The art's two button sockets, where the class trainer's Train and Exit sit
(Blizzard_TrainerUI.xml:481-513). -->
<Button name="BenillaScriptLogClearButton" inherits="UIPanelButtonTemplate" text="Clear">
<Size><AbsDimension x="80" y="22"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="224" y="-420"/></Offset></Anchor></Anchors>
<Size><AbsDimension x="96" y="22"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-455"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaScriptLog_Clear()</OnClick></Scripts>
</Button>
<Button name="BenillaScriptLogCloseBottomButton" inherits="UIPanelButtonTemplate" text="Close">
<Size><AbsDimension x="80" y="22"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="305" y="-420"/></Offset></Anchor></Anchors>
<Size><AbsDimension x="96" y="22"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="219" y="-455"/></Offset></Anchor></Anchors>
<Scripts><OnClick>HideUIPanel(getglobal("BenillaScriptLogFrame"))</OnClick></Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>BenillaScriptLogFrame_OnLoad()</OnLoad>
<OnLoad>BenillaScriptLogFrame_OnLoad(self)</OnLoad>
<!-- A spin bubbles up the parent chain; the detail pane's own handler wins there. -->
<OnMouseWheel>BenillaScriptLogFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>BenillaScriptLog_Update()</OnShow>
<OnUpdate>BenillaScriptLogFrame_OnUpdate()</OnUpdate>
</Scripts>
</Frame>
<!-- The player's commands, as an addon registers them; after the stock ChatFrame.lua, which
declares SlashCmdList. -->
<!-- Benilla's slash commands, handed to SubmitChatInput (ui_chat::input); loads after the
stock ChatFrame.xml, which declares SlashCmdList. -->
<Script><![CDATA[
-- Deviation: 1.12 has no `/reload` (later clients added it; 1.12 reloads through
-- `/console reloadui`); kept so a player can reload the interface after toggling addons.
SLASH_BENILLA_RELOAD1 = "/reload"
SlashCmdList["BENILLA_RELOAD"] = function()
ReloadUI()
end
-- `/errors` toggles the log and `/errors clear` empties it; `/err` is the short form.
SLASH_BENILLA_ERRORS1 = "/errors"
SLASH_BENILLA_ERRORS2 = "/err"
SlashCmdList["BENILLA_ERRORS"] = function(msg)
local arg = string.lower((string.gsub(msg or "", "^%s*(.-)%s*$", "%1")))
if arg == "clear" then
BenillaScriptLog_Clear()
else
BenillaScriptLog_Toggle()
local BENILLA_COMMANDS = {
{ "CHATTEST", "/chattest" },
{ "SHOT", "/shot" },
{ "LIQUID", "/liquid" },
{ "CASTVIS", "/castvis" },
{ "PARTYTEST", "/partytest" },
{ "REACTION", "/reaction", "/react" },
{ "RELOAD", "/reload" },
{ "ERRORS", "/errors", "/err" },
{ "CONVERTRAID", "/convertraid" },
}
for _, row in ipairs(BENILLA_COMMANDS) do
local key = "BENILLA_" .. row[1]
for i = 2, table.getn(row) do
setglobal("SLASH_" .. key .. (i - 1), row[i])
end
SlashCmdList[key] = function(msg)
SubmitChatInput(row[2] .. " " .. (msg or ""))
end
end
]]></Script>
@@ -96,18 +96,11 @@
BenillaScrollBar_UpdateArrows(bar)
end
-- The range the bar follows: the engine's, the reference's union of the child's whole
-- subtree, unless the frame measures its own content (`sf.contentRange`).
function BenillaScroll_Range(sf)
if sf.contentRange then return sf.contentRange(sf) end
return sf:GetVerticalScrollRange()
end
-- Hides the bar when the content fits (reference: ScrollFrame_OnScrollRangeChanged).
function BenillaScroll_SyncBar(sf)
local bar = getglobal(sf:GetName() .. "Bar")
if not bar then return end
local range = BenillaScroll_Range(sf)
local range = sf:GetVerticalScrollRange()
bar:SetValueStep(BENILLA_SCROLL_STEP)
bar:SetMinMaxValues(0, range)
bar:SetValue(sf:GetVerticalScroll())
@@ -118,7 +111,7 @@
-- Clamped here: SetVerticalScroll stores any value, as the reference's does.
function BenillaScroll_Step(sf, delta)
local target = sf:GetVerticalScroll() + delta
local range = BenillaScroll_Range(sf)
local range = sf:GetVerticalScrollRange()
if target > range then target = range end
if target < 0 then target = 0 end
sf:SetVerticalScroll(target)
@@ -190,7 +183,7 @@
</Button>
</Frames>
<Scripts>
<OnValueChanged>BenillaFauxScrollBar_OnValueChanged(this, arg1)</OnValueChanged>
<OnValueChanged>BenillaFauxScrollBar_OnValueChanged(self, arg1)</OnValueChanged>
</Scripts>
<ThumbTexture name="$parentThumbTexture" file="Interface\Buttons\UI-ScrollBar-Knob">
<Size><AbsDimension x="16" y="16"/></Size>
+232
View File
@@ -0,0 +1,232 @@
# The built-in interface, in load order: a bare filename is ours, from `assets/ui`; a path is
# the reference's own file, off the player's patch chain. Entry 0, Fonts.xml, loads alone into
# every boot VM, from Startup on; the rest loads on entering the world, then third-party addons.
# The font objects thus exist before the login screen; the reference loads Fonts.xml with the
# rest of FrameXML.
# `inherits=`, `parent=` and a named `relativeTo=` resolve only against names declared above; a
# missing one only warns (the template is skipped, the parent falls back to the enclosing frame).
# A later Lua global or template replaces an earlier one; a named frame publishes its global
# only while the name is free.
# A stock `.xml` that does not `<Script file=>` its own `.lua` needs both rows, `.lua` first.
# LoadOnDemand addons have no row, as in the reference; they load off the chain on first use.
## Interface: 11200
## Title: benilla
Interface\FrameXML\Fonts.xml
# Second, not first as in the reference, because entry 0 must be Fonts.xml.
Interface\FrameXML\GlobalStrings.lua
# `Localize()` runs at load and overwrites GlobalStrings keys, so this follows them.
Interface\FrameXML\Localization.xml
# `GetText` is a Lua global, not an engine binding: the client's `0x703bf0` calls it.
Interface\FrameXML\LocaleProperties.lua
Interface\FrameXML\BasicControls.xml
# Before UIParent.xml, whose `LowHealthFrame` has `parent="WorldFrame"`.
Interface\FrameXML\WorldFrame.xml
# Above every file that names `parent="UIParent"` or adds a `UIPanelWindows` row at load.
Interface\FrameXML\UIParent.xml
Interface\FrameXML\ItemButtonTemplate.xml
# Before GameTooltip.xml and StaticPopup.xml, whose money rows inherit `SmallMoneyFrameTemplate`.
Interface\FrameXML\MoneyFrame.lua
Interface\FrameXML\MoneyFrame.xml
Interface\FrameXML\MoneyInputFrame.lua
Interface\FrameXML\MoneyInputFrame.xml
Interface\FrameXML\GameTooltip.xml
Interface\FrameXML\UIDropDownMenu.xml
Interface\FrameXML\UIMenu.xml
# Before the unit frames, whose dropdowns' OnLoad reaches `UnitPopup_ShowMenu`.
Interface\FrameXML\UnitPopup.xml
Interface\FrameXML\ItemRef.xml
# The `TextStatusBar` template and verbs, which the unit bars and `MainMenuExpBar` inherit.
Interface\FrameXML\TextStatusBar.lua
Interface\FrameXML\TextStatusBar.xml
# Before PartyFrame.xml, whose rows reach `RefreshBuffs` from their OnLoad.
Interface\FrameXML\BuffFrame.xml
# Function-only kits the unit windows below call from their OnLoad.
Interface\FrameXML\UnitFrame.xml
Interface\FrameXML\CombatFeedback.xml
Interface\FrameXML\PlayerFrame.xml
Interface\FrameXML\PartyFrame.xml
Interface\FrameXML\TargetFrame.xml
Interface\FrameXML\PetFrame.xml
# Anchors `relativeTo="TargetFrame"`.
Interface\FrameXML\ComboFrame.xml
# Ours: the scroll parts of our options window and script log.
ScrollTemplates.xml
Interface\FrameXML\UIPanelTemplates.lua
Interface\FrameXML\UIPanelTemplates.xml
# The reference's three options windows, loaded hidden so addons find their frames by name.
# Deviation: the player opens our own options window, `OptionsFrame.xml` below, because 1.12's
# settings screens are much worse to use; the stock one holds `OptionsFrame`.
Interface\FrameXML\OptionsFrame.xml
Interface\FrameXML\SoundOptionsFrame.xml
# Inherits `OptionsCheckButtonTemplate`, and declares `UIOptionsFrameCheckButtons`, which
# MultiActionBars.lua writes at load.
Interface\FrameXML\UIOptionsFrame.xml
# StaticPopup.lua starts `StaticPopupDialogs` empty: a file that adds a dialog at load follows it.
Interface\FrameXML\StaticPopup.xml
Interface\FrameXML\CoinPickupFrame.xml
# Ours: the Keybindings page's templates and script, which OptionsFrame.xml uses at load.
KeyBindingsPage.xml
# Ours: `BenillaOptionsFrame`; its rows read the option globals the stock windows above set.
OptionsFrame.xml
# `MainMenuBar`, `MainMenuBarArtFrame` and `MainMenuExpBar`, which later files read at load.
Interface\FrameXML\MainMenuBar.xml
Interface\FrameXML\Cooldown.xml
# `ActionButtonTemplate`, which ActionBarFrame.xml's `ActionBarButtonTemplate` inherits.
Interface\FrameXML\ActionButtonTemplate.xml
Interface\FrameXML\ActionBarFrame.xml
# The four optional bars, action slots 25-72.
Interface\FrameXML\MultiActionBars.xml
Interface\FrameXML\PetActionBarFrame.xml
# `UpdateMicroButtons` reads CharacterFrame, SpellBookFrame, QuestLogFrame, GameMenuFrame, the
# three stock options windows, FriendsFrame, WorldMapFrame, HelpFrame and KeyRingButton
# unguarded, so each must be on this list.
Interface\FrameXML\MainMenuBarMicroButtons.xml
# Before Minimap.xml, whose queue dropdown's OnLoad calls `MiniMapBattlefieldDropDown_OnLoad`.
Interface\FrameXML\BattlefieldFrame.xml
# GameTime.xml, DurabilityFrame.xml and QuestTimerFrame.xml name `MinimapCluster` at load.
Interface\FrameXML\Minimap.xml
Interface\FrameXML\GameTime.xml
# Before ZoneText.xml, whose frames call `FadingFrame_OnLoad` in their OnLoad.
Interface\FrameXML\FadingFrame.xml
Interface\FrameXML\ZoneText.xml
Interface\FrameXML\ColorPickerFrame.xml
Interface\FrameXML\WorldMapFrame.xml
Interface\FrameXML\CastingBarFrame.xml
Interface\FrameXML\MirrorTimer.xml
Interface\FrameXML\DurabilityFrame.xml
Interface\FrameXML\UIErrorsFrame.xml
# Anchors `relativeTo="UIErrorsFrame"`.
Interface\FrameXML\RaidWarning.xml
Interface\FrameXML\ContainerFrame.xml
# `CharacterFrameTabButtonTemplate`, which `CharacterFrame.xml`'s own tabs (and TurtleWoW's LFT
# group finder) inherit; the reference's toc lists it apart, so bring it in ahead of its users.
Interface\FrameXML\CharacterFrameTemplates.xml
# `CharacterFrame` parents every tab page below, and its OnLoad reads `PlayerFrameHealthBar`,
# `PlayerFrameManaBar` and `MainMenuExpBar`; PaperDollFrame.xml declares templates the pet page
# and the bag bar inherit.
Interface\FrameXML\CharacterFrame.xml
Interface\FrameXML\PaperDollFrame.xml
Interface\FrameXML\PetPaperDollFrame.xml
Interface\FrameXML\MainMenuBarBagButtons.xml
Interface\FrameXML\StackSplitFrame.xml
# Ours: wraps the stock bag verbs; the keyring is a container (id -2).
ContainerFrameAdapters.xml
Interface\FrameXML\ReputationFrame.xml
Interface\FrameXML\SkillFrame.xml
# Brings HonorFrameTemplates.xml, whose row templates `Blizzard_InspectUI` inherits.
Interface\FrameXML\HonorFrame.xml
# Ours: the sixth tab page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped.
CharacterFrameAdapters.xml
Interface\FrameXML\DressUpFrame.xml
Interface\FrameXML\SpellBookFrame.xml
# Ours: the pet page's autocast widgets in the pet action button's proportions.
SpellBookAdapters.xml
# RaidFrame.xml names `parent="FriendsFrame"` and MailFrame.xml inherits `FriendsFrameTabTemplate`.
Interface\FrameXML\FriendsFrame.xml
Interface\FrameXML\RaidFrame.xml
Interface\FrameXML\GossipFrame.xml
Interface\FrameXML\MerchantFrame.xml
Interface\FrameXML\MailFrame.xml
Interface\FrameXML\TradeFrame.xml
Interface\FrameXML\ItemTextFrame.xml
Interface\FrameXML\BankFrame.xml
Interface\FrameXML\TaxiFrame.xml
Interface\FrameXML\PetStable.xml
Interface\FrameXML\LootFrame.xml
# Brings QuestFrameTemplates.xml, whose templates the guild registrar and quest log inherit; the
# quest log's OnLoad also calls `QuestMoneyFrame_OnLoad` from QuestFrame.lua.
Interface\FrameXML\QuestFrame.xml
Interface\FrameXML\GuildRegistrarFrame.xml
Interface\FrameXML\PetitionFrame.xml
Interface\FrameXML\QuestLogFrame.xml
Interface\FrameXML\QuestTimerFrame.xml
# ChatFrame.lua starts `SlashCmdList` empty: a file that registers slash commands at load follows.
Interface\FrameXML\ChatFrame.xml
Interface\FrameXML\FloatingChatFrame.xml
# `ShapeshiftBar_OnLoad` can reach `UIParent_ManageFramePositions`, which reads the frames above
# unguarded.
Interface\FrameXML\BonusActionBarFrame.xml
# `WorldStateAlwaysUpFrame`'s OnLoad runs the same pass, which also reads
# `ShapeshiftBarLeft/Middle/Right`, so this follows BonusActionBarFrame.xml.
Interface\FrameXML\WorldStateFrame.xml
# `ClassTrainerListScrollFrameTemplate`, which on-demand addons inherit.
Interface\FrameXML\ClassTrainerFrameTemplates.xml
# TurtleWoW's Looking-For-Turtle group finder (`/lft`, and the minimap eye): the reference's own
# toc lists LFT_Data.lua, LFT.lua, LFTTemplates.xml and LFT.xml in that order, and OctoWoW's
# blink-pause overlay rides last. `json.lua` is the codec the group payloads decode through.
Interface\FrameXML\json.lua
Interface\FrameXML\LFT\LFT_Data.lua
Interface\FrameXML\LFT\LFT.lua
Interface\FrameXML\LFT\LFTTemplates.xml
Interface\FrameXML\LFT\LFT.xml
Interface\FrameXML\OctoLFTBlinkPause\OctoLFTBlinkPause.lua
# TurtleWoW's own FrameXML additions, in the reference `FrameXML.toc` order (patch-3.mpq). These
# are the client half of Turtle's custom systems: `Turtle_TransmogUI` hooks `GOSSIP_SHOW`, hides
# the stock gossip frame for its NPCs and drives the transmog window over `TW_TRANSMOG` addon
# messages; `Turtle_ShopUI` (prefix `TW_SHOP`) is the custom merchant/shop. Without these rows the
# server's trigger gossip text (`TRANSMOG_TRIGGER`, `CUSTOM_MERCHANT_TRIGGER`) is what the stock
# gossip window shows, and neither custom window exists. Turtle's Inspect-Talents and Guild-Bank
# UIs are the same pattern but need `ToggleInspect`/`SetTalentButtonLocation` and the GuildBank
# globals built first, so they are not loaded here yet.
Interface\FrameXML\Turtle_TransmogUI\Turtle_TransmogSets.lua
# The slot buttons in Turtle_TransmogUI.xml inherit TransmogPlayerSlotTemplate, and the look grid
# and new-transmog alert inherit TransmogFrameLookTemplate/TransmogWonItemTemplate; without this
# file they materialize bare and the UI's `_G[slot.."BorderSelected"]`-style reads raise on nil.
Interface\FrameXML\Turtle_TransmogUI\Turtle_TransmogUITemplates.xml
Interface\FrameXML\Turtle_TransmogUI\Turtle_TransmogUI.xml
Interface\FrameXML\Turtle_ShopUI\Turtle_ShopUITemplates.xml
Interface\FrameXML\Turtle_ShopUI\Turtle_ShopUI.xml
# Turtle's honour/arena merchant (Stormwind armoury PvP gear, the Custom_Merchant NPCs): it hooks
# `GOSSIP_SHOW`, hides the gossip frame for an NPC whose greeting is `CUSTOM_MERCHANT_TRIGGER` and
# fills over `TW_Merchant` addon messages. Without it that trigger text is what the stock gossip
# window shows. The `.xml` scripts its own `.lua`, and it inherits SmallAlterateCurrencyFrameTemplate
# (MoneyFrame.xml) and uses `json` (json.lua above), both already on the chain.
Interface\FrameXML\Custom_Merchant\CustomMerchantFrame.xml
# The realm's in-game radio, two baked files in the chain's own order (its FrameXML.toc 129 then
# 146). `EverlookBroadcastingCo` builds the `EBC_Minimap` boombox button; `OctoRadioPlaceholder`
# hangs the station menu off it and bails at its first line without it
# (`if not EBC_Minimap then return end`), so the order is load-bearing, not cosmetic.
#
# A station click sends `.radio <n>` as a SAY chat command; the server answers with
# `SMSG_PLAY_MUSIC` naming a `SoundEntries.dbc` row whose file is a live HTTP stream URL.
Interface\FrameXML\EverlookBroadcastingCo\EverlookBroadcastingCo.xml
Interface\FrameXML\OctoRadioPlaceholder\OctoRadioPlaceholder.lua
# AIO, the realm's client<->server transport (its FrameXML.toc bakes it so no AddOn is needed).
# Before OctoCalendar, which feeds from it: the calendar registers an AIO handler at load and
# queues its guild-permission request until AIO is up (`OctoCalendar.lua:919-920`).
Interface\FrameXML\AIO\Dep_LibWindow-1.1\LibStub.lua
Interface\FrameXML\AIO\Dep_LibWindow-1.1\LibWindow-1.1\LibWindow-1.1.lua
Interface\FrameXML\AIO\Dep_Smallfolk\smallfolk.lua
Interface\FrameXML\AIO\lualzw-zeros\lualzw.lua
Interface\FrameXML\AIO\queue.lua
Interface\FrameXML\AIO\AIO.lua
# The realm's event calendar: `/cal`, `/calendar`, `/octocal` (`OctoCalendar.lua:925-928`).
Interface\FrameXML\OctoCalendar\OctoCalendar_Holidays.lua
Interface\FrameXML\OctoCalendar\OctoCalendar.lua
Interface\FrameXML\OctoCalendar\OctoCalendar.xml
Interface\FrameXML\OctoCalendar\OctoCalendar_Create.lua
Interface\FrameXML\OctoCalendar\OctoCalendar_Create.xml
Interface\FrameXML\OctoCalendar\OctoCalendar_Mail.lua
Interface\FrameXML\OctoCalendar\OctoCalendar_GuildPerms.lua
# Ours: a no-op for each AIO block this client has no feature for. After AIO, which defines the
# registration verb, and after the Octo files, so a real handler always wins the name.
AIOHandlers.lua
# Ours: the `/errors` script error log and our slash commands.
ScriptLogFrame.xml
Interface\FrameXML\HelpFrame.xml
Interface\FrameXML\CinematicFrame.xml
Interface\FrameXML\TabardFrame.xml
Interface\FrameXML\TutorialFrame.xml
# Ours: the ESC game menu. Last: it wraps `ToggleGameMenu`, `IsOptionFrameOpen` and
# `UpdateMicroButtons` to count `BenillaOptionsFrame`.
GameMenuFrame.xml
-25
View File
@@ -1,25 +0,0 @@
# benilla's own interface, one layer on the stock one (docs/METHOD.md): every file is ours, from
# `assets/ui`, and loads in this order after every `FrameXML.toc` row and before the third-party
# addons. It is listed in no addon list, fires no `ADDON_LOADED` and cannot be switched off; a dev
# build boots without it under `WOW_STOCK_UI=1`. A test names its files, so it does not grow.
## Interface: 11200
# The scroll parts of our options window and script log.
ScrollTemplates.xml
# The Keybindings page's templates and script, which OptionsFrame.xml uses at load.
KeyBindingsPage.xml
# `BenillaOptionsFrame`; its rows read the option globals the stock options windows set.
OptionsFrame.xml
# The ESC menu's era look on the stock `GameMenuFrame`, whose Options rung opens ours.
GameMenuAdapters.xml
# `OpenBackpack` and `CloseBackpack` over every equipped bag, for the vendor and the mailbox.
ContainerFrameAdapters.xml
# The pet page's autocast widgets in the pet action button's proportions.
SpellBookAdapters.xml
# Ours: the sixth tab page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped.
CharacterFrameAdapters.xml
# The `/errors` script error log and our slash commands.
ScriptLogFrame.xml
# Our fixes to the stock Lua.
FrameXMLFixes.xml
@@ -1,87 +0,0 @@
//! `extended_launcher`: the client with a plugin of its own on top, started through
//! [`benilla_app::run_with`] as a crate built on benilla starts it. A feature 1.12.1 does not have
//! lives in a crate like this one, never in benilla.
//!
//! ```text
//! cargo run -p benilla-app --example extended_launcher
//! ```
//!
//! The banner reads `benilla build … · extended`, and the plugin logs one line at `Startup`: the
//! `AddOns/` folder in [`benilla_app::config_dir`], where an addon the crate ships is written.
//!
//! A crate of its own copies the `benilla` launcher (`crates/benilla`): a build script that stamps
//! its commit, a `main` like this one that reads the stamp back as `crates/benilla/src/main.rs`
//! does, and a manifest that takes benilla as the workspace does:
//!
//! ```toml
//! [features]
//! default = ["dev"]
//! # The instruments; the player build is `--no-default-features`.
//! dev = ["benilla-app/dev"]
//!
//! [dependencies]
//! # `default-features = false`, or `--no-default-features` still builds benilla-app with `dev`.
//! benilla-app = { git = "https://github.com/samwhosung/benilla", default-features = false }
//! # benilla's version, adding no features: bevy's defaults bring its audio stack back.
//! bevy = { version = "0.18.1", default-features = false }
//!
//! [build-dependencies]
//! benilla-buildstamp = { git = "https://github.com/samwhosung/benilla" }
//!
//! # Cargo reads `[patch]` and `[profile]` from the root manifest only, so benilla's are repeated:
//! # without the patches the addon Lua and the mixer are not benilla's.
//! [patch.crates-io]
//! lua-src = { git = "https://github.com/samwhosung/benilla" }
//! kira = { git = "https://github.com/samwhosung/benilla" }
//!
//! [profile.dev]
//! opt-level = 1
//!
//! [profile.dev.package."*"]
//! opt-level = 3
//! ```
//!
//! ```text
//! // build.rs
//! fn main() {
//! benilla_buildstamp::emit();
//! }
//! ```
//!
//! Its `Cargo.lock` starts as a copy of benilla's: cargo reads only the root's lockfile too, and
//! without it resolves every crate to its newest compatible version, not the one benilla is built
//! with. A dev build keeps its `WoW` link, `benilla-config/` and `.probe-identity` in its own
//! folder, beside that `Cargo.lock`, as benilla's does.
use benilla_app::{AppExit, BuildId};
use bevy::prelude::*;
/// The crate's own plugin: one line at `Startup`, to show it runs inside the client and where its
/// addons go.
struct HelloPlugin;
impl Plugin for HelloPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, || {
let addons = benilla_app::config_dir().map(|dir| dir.join("AddOns"));
info!("a plugin on top of benilla is running; its addons go in {addons:?}");
});
}
}
fn main() -> AppExit {
// A launcher of its own passes the stamp its build script made; an example has no build
// script, so this names the version and profile alone.
let build = BuildId {
version: env!("CARGO_PKG_VERSION"),
profile: if cfg!(debug_assertions) {
"debug"
} else {
"release"
},
..Default::default()
};
benilla_app::run_with(build, |app| {
app.add_plugins(HelloPlugin);
})
}
+247 -242
View File
@@ -31,6 +31,13 @@ mod probe_tests;
pub mod render;
#[cfg(test)]
mod render_tests;
/// A frame's cost on the player's whole interface, the shared VM loaded as the client loads it.
///
/// Named `_tests` like its `render_tests` sibling so `tests/ui_script_wall.rs` classifies it as the
/// test code it already is (`#[cfg(test)]`): that wall gates how many *gameplay* files may name
/// `UiScript`, and a `#[ignore]`d measurement harness is not one.
#[cfg(test)]
mod tick_bench_tests;
/// The control for the one-VM-per-addon bound: the whole folder in one VM, as the 1.12 client runs
/// it.
pub mod together;
@@ -162,7 +169,7 @@ fn corpus(
let toc = Toc::parse(&benilla_ui::source::decode(
&std::fs::read(manifest_path(root, n)?).unwrap_or_default(),
));
Some(crate::ui_script::addons::info_from_toc(n, &toc, false))
Some(crate::ui_script::addons::info_from_toc(n, &toc))
})
.collect();
@@ -264,10 +271,15 @@ fn survey_one(
// The addon's dependencies, first and recursively, as `AddOn_Load` (`0x51f240`) does in its
// first two steps. Loaded before `globals_of` below, so a name a dependency provides is not
// missing.
// Marked first, as `AddOn_Load` marks an addon before its dependency loops (`0x51f313`): a
// dependency that requires the surveyed addon finds it loaded and does not load it here.
script.mark_addon_loaded(name);
let dep_loads = load_dependencies(&script, root, registry, &toc);
let mut dep_loads: Vec<LoadedDep> = Vec::new();
load_dependencies(
&mut script,
root,
&toc,
installed,
&mut BTreeSet::new(),
&mut dep_loads,
);
let dep_order: Vec<String> = dep_loads.into_iter().map(|d| d.name).collect();
let known = globals_of(&script);
// The methods the dependency chain defines, read from its source (a method has no VM read).
@@ -284,11 +296,15 @@ fn survey_one(
let warn_mark = script.diagnostics().last().map_or(0, |d| d.seq);
let files = load_addon_files(&script, root, name, &toc);
// The live walk stamps each addon as loaded just before its `ADDON_LOADED` (`0x51f5ad`);
// without it `IsAddOnLoaded` answers nil and a LoadOnDemand dependent gets
// `DEP_NOT_DEMAND_LOADED`. The dependencies stamped themselves in `load_dependencies`.
script.mark_addon_loaded(name);
let wants = missing_calls(root, name, &toc, &known, &dep_methods);
// After the addon's files, so a template its own XML declares is registered.
let missing_templates = missing_templates(&script, root, name, &toc);
let missing_inherits = missing_inherits(&script, root, name, &toc);
let session_errors = drive_session_start(&mut script, name, installed, root, registry);
let session_errors = drive_session_start(&mut script, name, installed);
let probe_errors = drive_ui_probe(&mut script);
// Warnings only; errors have their own columns.
let warnings: Vec<String> = script
@@ -567,8 +583,6 @@ fn drive_session_start(
script: &mut UiScript,
name: &str,
installed: &BTreeSet<String>,
root: &Path,
registry: &[benilla_ui::script::AddOnInfo],
) -> Vec<String> {
// Errors are attributed by the raising chunk, not the event window: AceAddon runs every queued
// consumer's `OnInitialize` on any `ADDON_LOADED` it sees (`AceAddon-2.0.lua:104-105`, `:230`),
@@ -580,15 +594,6 @@ fn drive_session_start(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(name.to_string())],
);
// `AddOn_Load` goes on to the addons whose `## LoadWith` names this one (`0x51f5b2`-
// `0x51f5e1`), their files and events inside this window; a raise in their own files is
// exempted below by its chunk name, like a dependency's.
if let Some(row) = script.addon_row(name) {
let mut host = HarnessLoad::new(script, root, registry);
for dependent in script.addon_dependents(row) {
script.load_addon_row(&mut host, dependent, false, false);
}
}
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
script.fire_event(event, Vec::new());
}
@@ -728,75 +733,54 @@ fn drive_ui_probe(script: &mut UiScript) -> Vec<String> {
out
}
/// Load an addon's declared dependencies into the VM through the shared `AddOn_Load` driver
/// (`0x51f240`): optional ones first, failures ignored, then required ones, each through the gate,
/// its own dependencies first, its files, then `ADDON_LOADED` with its folder name, then the
/// addons whose `## LoadWith` names it. The surveyed addon's own gate and abort are not
/// reproduced, so a survey still measures it when a required dependency is missing; that is
/// reported ([`AddonReport::missing_deps`]). Errors inside a dependency are its own row, never the
/// dependent's. The caller marks the surveyed addon loaded first (`0x51f313`).
/// Load an addon's declared dependencies into the VM, depth-first, each at most once.
///
/// The 1.12 client's order (`AddOn_Load`, `0x51f240`): optional dependencies first, failures
/// ignored; required ones next, a failure aborting the dependent; then its own files. The abort is
/// not reproduced, only the state the dependent's file scope meets; a missing required dependency
/// is reported ([`AddonReport::missing_deps`]), an absent optional one is silent. Errors inside a
/// dependency are its own row, never the dependent's.
fn load_dependencies(
script: &UiScript,
script: &mut UiScript,
root: &Path,
registry: &[benilla_ui::script::AddOnInfo],
toc: &Toc,
) -> Vec<LoadedDep> {
installed: &BTreeSet<String>,
seen: &mut BTreeSet<String>,
loaded: &mut Vec<LoadedDep>,
) {
// Optional first, then required: `FuBar_BagFu` lists `FuBarPlugin-2.0.lua` before
// `AceLibrary.lua`, and FuBarPlugin raises unless the optional `Ace2` loaded first.
let mut host = HarnessLoad::new(script, root, registry);
for dep in toc
let deps: Vec<&str> = toc
.optional_dependencies()
.into_iter()
.chain(toc.dependencies())
{
if let Some(row) = script.addon_row(dep) {
// Every addon loads: the harness forces past the version check, as the AddOns screen's
// Load out of date AddOns does.
script.load_addon_row(&mut host, row, false, false);
.collect();
for dep in deps {
let key = dep.to_ascii_lowercase();
if !installed.contains(&key) || !seen.insert(key) {
continue; // not installed (already reported), or already in this VM
}
}
host.loaded
}
/// The harness's side of `AddOn_Load`: run a registry row's `.toc` files out of the corpus folder
/// and keep what each did. Rows are the corpus's, in [`corpus`]'s order.
struct HarnessLoad<'a> {
script: &'a UiScript,
root: &'a Path,
registry: &'a [benilla_ui::script::AddOnInfo],
/// Every addon the driver ran, in load order.
loaded: Vec<LoadedDep>,
}
impl<'a> HarnessLoad<'a> {
fn new(
script: &'a UiScript,
root: &'a Path,
registry: &'a [benilla_ui::script::AddOnInfo],
) -> Self {
HarnessLoad {
script,
root,
registry,
loaded: Vec::new(),
}
}
}
impl benilla_ui::script::AddonLoad for HarnessLoad<'_> {
fn run(&mut self, i: usize) {
let name = &self.registry[i].name;
let Some(toc) = manifest_path(self.root, name)
let Some(dep_toc) = manifest_path(root, dep)
.and_then(|p| std::fs::read(p).ok())
.map(|b| Toc::parse(&benilla_ui::source::decode(&b)))
else {
return;
continue;
};
// `ADDON_LOADED` follows from the driver, after the files (`0x51f5ad`); a raise in a
// library's initialiser is its own row, outside the surveyed addon's error mark.
let files = load_addon_files(self.script, self.root, name, &toc);
self.loaded.push(LoadedDep {
name: name.clone(),
load_dependencies(script, root, &dep_toc, installed, seen, loaded);
let files = load_addon_files(script, root, dep, &dep_toc);
// `ADDON_LOADED` fires here, at the 1.12 client's position: `AddOn_Load` (`0x51f240`) runs
// an addon's dependencies, its `.toc` files (`0x51f3fa`), `Bindings.xml`, the two
// SavedVariables chunks, then `ADDON_LOADED` (event 429, `0x51f5ad`), before the next
// addon. The loaded flag is set first (`[rec+0x18]=1` at `0x51f313`), so a handler's
// `IsAddOnLoaded` is true. Fired outside the surveyed addon's error mark: a library raising
// in its initialiser is its own row.
script.mark_addon_loaded(dep);
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(dep.to_string())],
);
loaded.push(LoadedDep {
name: dep.to_string(),
files,
});
}
@@ -1054,6 +1038,23 @@ fn seat_a_session(script: &mut UiScript) {
}
// The shipped CVar table, before the realm below writes into it.
script.register_cvars(crate::cvars::registered_pairs());
// `Spell.dbc` behind ClassicAPI's `C_Spell` getters, the same installer the live VM lifecycle
// runs. Without it an addon that keys a table on `C_Spell.GetSpellTexture(<an id this seated
// warrior has not learned>)` reads nil here but not in the client — a harness artefact, and
// this survey is what the `C_Spell` coverage is measured by.
if let Some(catalog) = spell_catalog() {
crate::ui_script::install_spell_probe(script, catalog);
// The extra columns too (nampower's `GetSpellDuration`/`spellIconID`,
// `GetSpellIconTexture`, `C_Spell.GetSpellMechanicByID`), or the survey reports them as
// gaps the client does not actually have. `SpellDuration.dbc` and `SpellMechanic.dbc` are
// separate files: when either is missing only the columns it owns go quiet.
crate::ui_script::install_spell_extras_probes(
script,
catalog,
spell_durations().cloned().unwrap_or_default(),
spell_mechanics().cloned(),
);
}
// A display: `GetScreenResolutions()` empty and `GetCurrentResolution()` 0 is no state the 1.12
// client can be in (`CT_Viewport` indexes its screen size by it at load). One mode, the
// `set_screen_size(1024, 768)` the interface is already told.
@@ -1080,6 +1081,33 @@ fn seat_a_session(script: &mut UiScript) {
script.set_realm_name("Harness");
// The bind point: the server sends `SMSG_BINDPOINTUPDATE` at login, before any addon runs.
script.set_bind_location("Stormwind City");
// A zone. The live client pushes these four host globals from `area.rs` on every area change
// and so always has them in world; an in-world session with `GetRealZoneText() == ""` is a
// state only the harness reaches. Elwynn Forest to agree with the quest log seated below,
// whose header row is that zone — pfQuest's zone tracker (`trackingmethod` 5, its default)
// matches a quest's log header against `pfMap:GetCurrentZone()`'s name (`map.lua:678` falls
// back to `GetRealZoneText()` when `GetMapZones()` cannot name the browsed map), so with no
// zone at all its tracker lists nothing however full the quest log is.
{
let g = script.lua().globals();
let _ = g
.set("__benilla_zone_name", "Elwynn Forest")
.and_then(|()| g.set("__benilla_real_zone_name", "Elwynn Forest"))
// `GetSubZoneText()` is "" when the leaf *is* the zone (`stdlib.rs`'s own note), and
// `GetMinimapZoneText()` is subzone-else-zone.
.and_then(|()| g.set("__benilla_subzone_name", ""))
.and_then(|()| g.set("__benilla_zone_text", "Elwynn Forest"));
}
// The `AreaTable.dbc` name column behind ClassicAPI's `C_Map.GetAreas`, which the live client
// pushes at every VM build off the player's own install (`ui_world_map::seed_area_names`), so
// an in-world session with an empty one is another state only the harness reaches. Seated here
// because the fault it hides is invisible to the survey's error columns: pfQuest repoints
// `pfDB.zones.loc` at this table (`database.lua:265-290`) and its minimap pass resolves the
// player's zone out of it by name alone (`map.lua:1365`), so an empty table is a minimap with
// no pins, no raise and no log line.
if let Some(names) = area_names() {
script.set_area_names(names.clone());
}
script.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -1097,9 +1125,6 @@ fn seat_a_session(script: &mut UiScript) {
class_file: Some("WARRIOR".into()),
sex: 2,
is_player: true,
// `UnitCreatureType("player")`: the feed's snapshot carries the race's type, and a
// Human is a Humanoid.
creature_type_name: Some("Humanoid".into()),
// `UnitFactionGroup("player")`: every playable race has a side, and AceDB-2.0
// concatenates it into its per-realm key at file scope.
faction_group: Some("Alliance".into()),
@@ -1109,25 +1134,68 @@ fn seat_a_session(script: &mut UiScript) {
}),
);
// `C_CreatureInfo`'s race and class tables. The live client pushes these once in `PostStartup`
// off the player's own `ChrRaces.dbc`/`ChrClasses.dbc` (`ui_chr_races.rs`,
// `ui_chr_classes.rs`), so every logged-in session has them and a harness without them is a
// state the client never reaches. Seated from this crate's own id tables rather than a second
// copy written here, so the harness cannot disagree with `UnitRace`/`UnitClass` above.
//
// Load-bearing, and not only for a getter: pfQuest builds `pfDB.bitraces`/`pfDB.bitclasses` by
// walking `for id = 1, 32` over these two (`database.lua:402-412`), and `GetBitByClass` has
// **no fallback** — an empty table answers nil, which then reaches `bit.band(…, nil)` inside
// `QuestFilter` (`:1707`). That raise aborts `SearchQuests` at `quest.lua:383`, which runs
// *before* the queue drain in the same `OnUpdate`, so it also costs every `SearchQuestID` map
// node — and `updateQuestGivers` is only cleared after the call, so it re-raises every frame.
{
use std::collections::BTreeMap;
let races: BTreeMap<u32, benilla_ui::script::RaceInfo> = (1..=10u8)
.filter_map(|id| {
let (name, file) = crate::ui_unit::race_names(id)?;
Some((
u32::from(id),
benilla_ui::script::RaceInfo {
name: name.into(),
client_file_string: file.into(),
faction_group: crate::ui_unit::race_faction_group(id).map(str::to_owned),
},
))
})
.collect();
script.set_race_info(races);
// 1.12's class ids have gaps at 6 and 10; `class_names` answers `None` there, which is the
// same nil `GetClassInfo` gives for a missing row, so the walk sees the real shape.
let classes: BTreeMap<u32, benilla_ui::script::ClassInfo> = (1..=11u8)
.filter_map(|id| {
let (name, file) = crate::ui_unit::class_names(id)?;
Some((
u32::from(id),
benilla_ui::script::ClassInfo {
name: name.into(),
class_file: file.into(),
},
))
})
.collect();
script.set_class_info(classes);
}
// ── A populated world ──
//
// One buff, one target and one action with a running cooldown, so an addon that only draws when
// there is content (a buff bar, a target frame, cooldown text) is separated from one that
// failed.
// The player's guid, which a token resolves to before its aura list is read.
script.set_unit_guids(&benilla_ui::script::UnitGuids {
player: 1,
..Default::default()
});
script.set_player_auras(vec![benilla_ui::script::AuraState {
spell_id: 1243,
name: Some("Power Word: Fortitude".into()),
icon: Some("Interface\\Icons\\Spell_Holy_WordFortitude".into()),
count: 1,
helpful: true,
cancelable: true,
..Default::default()
}]);
script.set_auras(
"player",
Some(vec![benilla_ui::script::AuraState {
spell_id: 1243,
name: Some("Power Word: Fortitude".into()),
icon: Some("Interface\\Icons\\Spell_Holy_WordFortitude".into()),
count: 1,
helpful: true,
cancelable: true,
..Default::default()
}]),
);
script.set_unit(
"target",
Some(benilla_ui::script::UnitState {
@@ -1177,6 +1245,9 @@ fn seat_a_session(script: &mut UiScript) {
finished: cur >= req,
cur,
req,
// Kobold Vermin, `creature_template` 6, so `GetQuestLogLeaderBoardID` has an identity
// to answer with: pfQuest's node builder keys its unit lookup on it.
id: 6,
};
let header = QuestLogEntryView {
quest_id: 0,
@@ -1235,6 +1306,30 @@ fn seat_a_session(script: &mut UiScript) {
slots,
}),
);
// The Hearthstone's *template*, not just its bag slot. In a live session the bag push is
// itself what queries it (`ui_items/feed.rs` calls `items.template(entry, …)` per slot and
// the answer lands in `Model::item_templates`), so a logged-in character always has it —
// and a harness bag with no template behind it is a state the real client never reaches.
// Load-bearing for pfQuest: `database.lua:2238` branches on
// `C_Item.IsItemDataCachedByID(HEARTHSTONE_ITEM_ID)`, and only the cached arm resolves
// synchronously. The uncached arm is a `ContinueOnItemLoad` poll bounded at 600 ticks,
// which a session start of ten 0.1 s ticks never reaches — so without this the probe
// cannot reach pfQuest's run gate (`pfDatabase.localized`) at all, and every quest feature
// behind it reads as absent for a reason that is the harness's and not the client's.
script.set_item_template(
6948,
benilla_ui::script::ItemTemplateView {
name: "Hearthstone".into(),
quality: 1,
// `ITEM_CLASS_MISCELLANEOUS`, subclass 0, bind-on-pickup and unique, as the real
// template is; nothing in the harness branches on these, they just stop a reader
// from meeting an all-zero row.
class: 15,
bonding: 1,
max_count: 1,
..Default::default()
},
);
}
script.set_spellbook(benilla_ui::script::SpellBookState {
tabs: vec![
@@ -1300,27 +1395,78 @@ fn auction_item_classes() -> Vec<benilla_ui::script::AuctionCategory> {
.clone()
}
/// `Spell.dbc` once per process, shared by every VM the survey builds: it backs ClassicAPI's
/// `C_Spell` getters (through [`crate::ui_script::install_spell_probe`], the same installer the
/// live client's VM lifecycle uses) and the talent pages' spell lookup. `None` with no install.
/// The whole `AreaTable.dbc` name column, read once per process; `None` with no install, which is
/// also the only state in which the live client has none.
fn area_names() -> Option<&'static std::collections::BTreeMap<u32, String>> {
use std::sync::OnceLock;
static NAMES: OnceLock<Option<std::collections::BTreeMap<u32, String>>> = OnceLock::new();
NAMES
.get_or_init(|| {
let data = benilla_formats::wow_data()?;
let mut chain = benilla_formats::open_chain(&data).ok()?;
let cat = benilla_formats::load_area_table_catalog(&mut chain).ok()?;
Some(
cat.names()
.map(|(id, name)| (id, name.to_owned()))
.collect(),
)
})
.as_ref()
}
/// `SpellDuration.dbc`, behind nampower's `GetSpellDuration`; `None` when the chain lacks it.
fn spell_durations() -> Option<&'static benilla_formats::SpellDurationCatalog> {
use std::sync::OnceLock;
static ROWS: OnceLock<Option<benilla_formats::SpellDurationCatalog>> = OnceLock::new();
ROWS.get_or_init(|| {
let data = benilla_formats::wow_data()?;
let mut chain = benilla_formats::open_chain(&data).ok()?;
benilla_formats::load_spell_durations(&mut chain).ok()
})
.as_ref()
}
/// `SpellMechanic.dbc`, behind `C_Spell.GetSpellMechanicByID`'s name.
fn spell_mechanics() -> Option<&'static benilla_formats::SpellMechanicCatalog> {
use std::sync::OnceLock;
static ROWS: OnceLock<Option<benilla_formats::SpellMechanicCatalog>> = OnceLock::new();
ROWS.get_or_init(|| {
let data = benilla_formats::wow_data()?;
let mut chain = benilla_formats::open_chain(&data).ok()?;
benilla_formats::load_spell_mechanic_catalog(&mut chain).ok()
})
.as_ref()
}
fn spell_catalog() -> Option<&'static benilla_formats::SpellCatalog> {
use std::sync::OnceLock;
static CATALOG: OnceLock<Option<benilla_formats::SpellCatalog>> = OnceLock::new();
CATALOG
.get_or_init(|| {
let data = benilla_formats::wow_data()?;
let mut chain = benilla_formats::open_chain(&data).ok()?;
benilla_formats::load_spell_catalog(&mut chain).ok()
})
.as_ref()
}
/// The seated character's talent pages, built by the live client's `build_pages`. The DBCs are read
/// once per process; the pages are built per VM, since the requirement lines come from that VM's
/// `GlobalStrings.lua`. `None` with no install.
fn talent_snapshot(script: &UiScript) -> Option<benilla_ui::script::TalentUiState> {
use std::sync::OnceLock;
#[allow(clippy::type_complexity)]
static DBCS: OnceLock<
Option<(
benilla_formats::TalentCatalog,
benilla_formats::SpellCatalog,
)>,
> = OnceLock::new();
let (talents, spells) = DBCS
static TALENTS: OnceLock<Option<benilla_formats::TalentCatalog>> = OnceLock::new();
let talents = TALENTS
.get_or_init(|| {
let data = benilla_formats::wow_data()?;
let mut chain = benilla_formats::open_chain(&data).ok()?;
let talents = benilla_formats::load_talent_catalog(&mut chain).ok()?;
let spells = benilla_formats::load_spell_catalog(&mut chain).ok()?;
Some((talents, spells))
benilla_formats::load_talent_catalog(&mut chain).ok()
})
.as_ref()?;
let spells = spell_catalog()?;
let get = |key: &str| {
script
.lua()
@@ -1420,19 +1566,6 @@ struct FileLoad {
/// FrameXML, in the install-relative path space (`Interface/AddOns/<Folder>`, as
/// `ui_script::addons::Addon::prefix`), so chunk names match a live session's.
fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> FileLoad {
// Under the addon's own context, as the live walk loads it.
let outer = script.swap_exec_context(benilla_ui::script::addon_load_context(
name,
// The corpus is folder addons, never the chain.
crate::ui_script::addons::secure(false, toc),
));
let load = load_addon_files_in_context(script, root, name, toc);
script.swap_exec_context(outer);
load
}
/// [`load_addon_files`]' body.
fn load_addon_files_in_context(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> FileLoad {
let provider = |req: &str| -> Option<Vec<u8>> { read_under(root, req) };
let base = addon_base(name);
let mut errors = Vec::new();
@@ -2672,134 +2805,6 @@ mod dependency_tests {
let _ = std::fs::remove_dir_all(&tmp);
}
/// A folder of addons under a temp root, `(name, toc, lua body)` each with one file `main.lua`;
/// every body starts by appending its folder to `Order`.
fn corpus_folder(tag: &str, addons: &[(&str, &str, &str)]) -> PathBuf {
let tmp =
std::env::temp_dir().join(format!("benilla-harness-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
for (name, toc, body) in addons {
let dir = tmp.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{name}.toc")),
format!("## Interface: 11200\n{toc}main.lua\n"),
)
.unwrap();
std::fs::write(
dir.join("main.lua"),
format!("Order = (Order or '') .. '{name},'\n{body}"),
)
.unwrap();
}
tmp
}
/// What one probe step answers, `= <value>`.
fn answer(root: &Path, name: &str, chunk: &str) -> String {
let out = probe::probe(root, name, &[probe::Step::Eval(chunk.into())]).unwrap();
out.answers[0].1.clone()
}
/// `AddOn_Load` fires `ADDON_LOADED` with the record's folder name (`[rec+0x14]`, `0x51f573`,
/// `0x51f5a2`), whatever case the dependent's `.toc` spelled it in.
#[test]
fn a_dependencys_addon_loaded_carries_its_folder_name() {
let tmp = corpus_folder(
"deps-spelling",
&[
(
"Lib",
"",
"Seen = {} Watch = CreateFrame('Frame') Watch:RegisterEvent('ADDON_LOADED') \
Watch:SetScript('OnEvent', function() table.insert(Seen, arg1) end)",
),
("User", "## Dependencies: LIB\n", ""),
],
);
assert_eq!(
answer(&tmp, "User", "return table.concat(Seen, ',')"),
"= Lib,User"
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// The gate runs before anything loads (`0x51f2fa`): a dependency that requires a missing
/// addon does not load, its files do not run, and its own dependencies are left alone.
#[test]
fn a_dependency_the_gate_refuses_does_not_load() {
let tmp = corpus_folder(
"deps-gate",
&[
("Blocked", "## Dependencies: Ghost\n", ""),
("User", "## Dependencies: Blocked\n", ""),
],
);
assert_eq!(answer(&tmp, "User", "return Order"), "= User,");
assert_eq!(
answer(&tmp, "User", "return IsAddOnLoaded('Blocked')"),
"= nil"
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// `AddOn_Load` loads the addons whose `## LoadWith` names the loaded one, right after its
/// `ADDON_LOADED` (`0x51f5b2`).
#[test]
fn a_dependencys_load_with_dependents_load_after_it() {
let tmp = corpus_folder(
"deps-load-with",
&[
("Lib", "", ""),
("Tag", "## LoadWith: Lib\n", ""),
("User", "## Dependencies: Lib\n", ""),
],
);
assert_eq!(answer(&tmp, "User", "return Order"), "= Lib,Tag,User,");
let _ = std::fs::remove_dir_all(&tmp);
}
/// The surveyed addon's own `AddOn_Load` ends with the addons that name it in `## LoadWith`
/// (`0x51f5b2`-`0x51f5e1`): they load right after its `ADDON_LOADED`, before the session events.
#[test]
fn the_surveyed_addons_load_with_dependents_load_after_its_addon_loaded() {
let tmp = corpus_folder(
"deps-own-load-with",
&[
("Lib", "", ""),
(
"Tag",
"## LoadWith: User\n",
"TagSawUser = IsAddOnLoaded('User')",
),
("User", "## Dependencies: Lib\n", ""),
],
);
assert_eq!(answer(&tmp, "User", "return Order"), "= Lib,User,Tag,");
assert_eq!(answer(&tmp, "User", "return TagSawUser"), "= 1");
let _ = std::fs::remove_dir_all(&tmp);
}
/// The surveyed addon is loaded (`0x51f313`) before its dependencies, so one that requires it
/// back finds it loaded and neither runs twice.
#[test]
fn a_dependency_cycle_through_the_surveyed_addon_loads_each_once() {
let tmp = corpus_folder(
"deps-cycle",
&[
("Ping", "## Dependencies: Pong\n", ""),
(
"Pong",
"## Dependencies: Ping\n",
"PingLoadedForPong = IsAddOnLoaded('Ping')\n",
),
],
);
assert_eq!(answer(&tmp, "Ping", "return Order"), "= Pong,Ping,");
assert_eq!(answer(&tmp, "Ping", "return PingLoadedForPong"), "= 1");
let _ = std::fs::remove_dir_all(&tmp);
}
/// An indexed frame (`ColorPickerFrame.func`, `ColorPickerFrame:SetColorRGB`) is a missing
/// surface, not a missing function.
#[test]
+15 -5
View File
@@ -6,6 +6,7 @@
//! after `PLAYER_ENTERING_WORLD` and the ticks, before the render and use probes touch the VM.
//! An eval can mutate the VM, so a probe is a debugger; the survey is the measurement.
use std::collections::BTreeSet;
use std::path::Path;
use benilla_ui::script::UiScript;
@@ -70,16 +71,25 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
script.set_screen_size(1024.0, 768.0);
// No saved-variable roots, so a probe never touches a player's saved variables; the AddOns
// root is passed as the survey passes it, so `LoadAddOn` resolves.
script.register_addons(registry.clone(), Some(root.to_path_buf()), None, None);
script.register_addons(registry, Some(root.to_path_buf()), None, None);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
// Marked before its dependencies load (`0x51f313`), as the survey does.
script.mark_addon_loaded(name);
let _ = super::load_dependencies(&script, root, &registry, &toc);
let mut dep_order: Vec<super::LoadedDep> = Vec::new();
super::load_dependencies(
&mut script,
root,
&toc,
&installed,
&mut BTreeSet::new(),
&mut dep_order,
);
let load_errors = super::load_addon_files(&script, root, name, &toc).errors;
let session_errors = super::drive_session_start(&mut script, name, &installed, root, &registry);
// The registry must agree with the VM, or `IsAddOnLoaded` answers for another session; the
// dependency chain stamped itself as it loaded.
script.mark_addon_loaded(name);
let session_errors = super::drive_session_start(&mut script, name, &installed);
let answers = steps
.iter()
@@ -0,0 +1,863 @@
//! What a frame of the **player's own** interface costs: the whole shipped FrameXML plus every
//! addon in `benilla-config/AddOns`, in one VM, with their real saved variables, ticked at a
//! crowded outdoor pull (a full party, 40 nameplates and each plate's target).
//!
//! This is the instrument the in-game profilers cannot be. pfUI's own `pfDebug` times with
//! `GetTime()`, which advances once a frame, so its time column reads 0 for every handler no
//! matter what it cost; all it can honestly report is `gcinfo()` deltas, i.e. Lua bytes. The
//! per-handler split here is wall time off `Instant`, through
//! [`UiScript::profile_handlers`](benilla_ui::script::UiScript::profile_handlers).
//!
//! ```text
//! WOW_DATA=~/Games/New-Octo/Data cargo test --release -p benilla-app --lib \
//! -- --ignored --nocapture tick_bench
//! ```
use std::path::Path;
use std::time::Instant;
use benilla_ui::script::{PlateFeed, PlateGeometry, PlateState, UiScript, UnitState};
use benilla_ui::toc::Toc;
use super::{
corpus, load_addon_files, load_dependencies, manifest_path, LoadedDep, ADDON_INSTRUCTION_BUDGET,
};
/// Plates a crowded pull shows; the nameplate CVar cap and `pfValidUnits`' own range.
const PLATES: u32 = 40;
/// Frames per timed window.
const FRAMES: u32 = 240;
fn ms(t: Instant) -> f64 {
t.elapsed().as_secs_f64() * 1000.0
}
/// A unit that exists and carries a GUID, as every snapshot the app feeds does.
fn live(guid: u64, name: &str) -> UnitState {
UnitState {
exists: true,
has_object: true,
name: Some(name.to_string()),
health: 2400,
max_health: 3000,
level: 60,
power: 100,
max_power: 100,
guid,
..Default::default()
}
}
const GEOMETRY: PlateGeometry = PlateGeometry {
width: 110.0,
height: 36.0,
bar_off_x: 4.0,
bar_off_y: 4.0,
bar_width: 102.0,
bar_height: 10.0,
level_off_x: 4.0,
level_off_y: 4.0,
skull_size: 14.0,
raid_size: 20.0,
name_height: 10.0,
level_height: 9.0,
shadow_offset: 1.0,
};
/// `PLATES` plates with their units and each one's target, the feed `vplates.rs` builds per frame.
fn plate_feeds() -> Vec<PlateFeed> {
(1..=PLATES)
.map(|i| PlateFeed {
state: PlateState {
key: 0x3000 + u64::from(i),
top_centre: (300.0 + f32::from(i as u16) * 7.0, 500.0),
health: 2400.0,
max_health: 3000.0,
bar_colour: [1.0, 0.0, 0.0],
name: format!("Defias Thug {i}"),
level: Some(58),
skull: false,
level_colour: [1.0, 1.0, 1.0],
raid_icon: None,
alpha: 1.0,
lit: false,
hovered: false,
},
unit: live(0x3000 + u64::from(i), "Defias Thug"),
unit_target: Some(live(0x1000, "Probefour")),
})
.collect()
}
/// Which of the app's unit tokens this frame's feed fills. `Grouped` is the crowded pull every
/// token answers for; `Solo` is one player alone with nothing targeted, which is most of a real
/// session and the shape a grouped feed cannot measure: a handler that asks about a token nobody
/// filled takes its absent branch, and a binding that raises on an absent token pays the whole
/// `_G` walk the `[raise]` block below measures.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Company {
Grouped,
Solo,
}
/// The stock tokens the app's own feed pushes every frame. `Solo` pushes `None` for every token
/// but `"player"`, which is what [`UiScript::set_unit`] does for a unit the app has no snapshot
/// of (`ui_unit.rs:1329`), rather than leaving last frame's behind.
fn feed_units(s: &mut UiScript, company: Company) {
let filled = |u: UnitState| (company == Company::Grouped).then_some(u);
s.set_unit("player", Some(live(0x1000, "Probefour")));
s.set_unit("target", filled(live(0x3001, "Defias Thug")));
s.set_unit("targettarget", filled(live(0x1000, "Probefour")));
s.set_unit("pet", filled(live(0x1001, "Snarl")));
s.set_unit("pettarget", filled(live(0x3001, "Defias Thug")));
s.set_unit("mouseover", filled(live(0x3002, "Defias Thug")));
for i in 1..=4u32 {
s.set_unit(
&format!("party{i}"),
filled(live(0x2000 + u64::from(i), "Mate")),
);
s.set_unit(
&format!("party{i}target"),
filled(live(0x3000 + u64::from(i), "Defias Thug")),
);
}
}
/// The player's whole interface in one VM: the shipped FrameXML, then every addon under
/// `benilla-config/AddOns` in load order with its own `ADDON_LOADED` and its real saved
/// variables, then the session-start events — the live walk's shape (`ui_script::addons`'
/// `load_third_party`), minus the ECS.
fn load_the_players_ui(root: &Path) -> UiScript {
let (names, installed, registry) = corpus(root);
let mut script = UiScript::new().expect("VM");
// Armed *before* the addons run, not just before the timed window: a `C_Timer` row's site
// label is captured where it is armed, and every persistent ticker in this interface is armed
// during load. A split armed after that names every row `?`. The live client arms the same way
// — from the environment, inside `UiScript::new` — so this is the shape it measures under.
script.profile_tick(true, 0.0);
script.set_screen_size(1600.0, 900.0);
// The real saved variables, account and character scope: pfUI's module enables, bar layout and
// unit-frame roster all live there, so a default-config VM measures an interface nobody runs.
script.register_addons(
registry,
Some(root.to_path_buf()),
crate::local_state::addon_saved_account_dir(),
crate::local_state::addon_saved_character_dir("C_Thun", "Gromgosh"),
);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
feed_units(&mut script, Company::Grouped);
// Where the Lua heap goes, stage by stage: a 5.1 collector's full cycle marks the whole live
// set, so the heap's *size* is what a collection costs, whoever made the garbage that
// triggered it. Reported sorted at the end of the walk.
let heap = |s: &UiScript| {
let lua = s.lua();
lua.gc_collect().expect("settle");
lua.gc_collect().expect("settle");
lua.used_memory()
};
let after_framexml = heap(&script);
println!(
"[heap] shipped FrameXML + session: {:.1} MB",
after_framexml as f64 / (1024.0 * 1024.0)
);
let mut per_addon: Vec<(String, f64)> = Vec::new();
let mut last = after_framexml;
let mut seen = std::collections::BTreeSet::new();
for name in &names {
let Some(toc) = manifest_path(root, name)
.and_then(|p| std::fs::read(p).ok())
.map(|b| Toc::parse(&benilla_ui::source::decode(&b)))
else {
continue;
};
if !seen.insert(name.to_ascii_lowercase()) {
continue;
}
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
let mut pulled: Vec<LoadedDep> = Vec::new();
load_dependencies(&mut script, root, &toc, &installed, &mut seen, &mut pulled);
let _ = load_addon_files(&script, root, name, &toc);
script.load_addon_saved_variables(name);
script.mark_addon_loaded(name);
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(name.clone())],
);
let now = heap(&script);
per_addon.push((name.clone(), (now as f64 - last as f64) / (1024.0 * 1024.0)));
last = now;
}
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
script.fire_event(event, Vec::new());
}
per_addon.push((
"(session start)".into(),
(heap(&script) as f64 - last as f64) / (1024.0 * 1024.0),
));
per_addon.sort_by(|a, b| b.1.total_cmp(&a.1));
for (name, mb) in per_addon.iter().take(10) {
println!("[heap] {mb:8.1} MB {name}");
}
script
}
/// A deterministic font engine: 6 units a character, as `ui_script::resolve_bench` uses.
fn answer_measures(s: &mut UiScript) -> usize {
let reqs = s.fontstrings_needing_measure();
let n = reqs.len();
if n > 0 {
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.map(|r| {
#[allow(clippy::cast_precision_loss)]
let w = r.text.chars().count() as f32 * 6.0;
match r.wrap_width {
Some(ww) if w > ww => (r.id, ww, 12.0 * (w / ww).ceil(), r.key),
_ => (r.id, w, 12.0, r.key),
}
})
.collect();
s.set_measured_text_unwrapped(&answers);
}
n
}
/// How many times each host asset probe was asked, and what that cost, since the last read.
#[derive(Default)]
struct ProbeTally {
texture_calls: u64,
texture_us: f64,
size_calls: u64,
size_us: f64,
font_calls: u64,
font_us: f64,
}
/// Install the three host asset probes the live client installs
/// (`ui_script::lifecycle::install_addon_asset_resolvers`), over the player's real patch chain and
/// AddOns folder, each wrapped in a counter.
///
/// Without them `SetTexture`'s path form and `SetFont` answer from `is_none_or`/`None` at no cost
/// and a zero-size region never asks for its art's texel size — so a bench with no probes measures
/// an interface whose every art verb is free, which is the one shape the live client never runs.
/// The chain walk is the part that cannot be inferred: `Chain::contains` asks every archive,
/// newest first, and three MPQ name hashes per archive, so a *miss* is the whole mount order —
/// which on an HD-pack install is dozens of archives, not the stock dozen.
fn install_live_asset_probes(
script: &mut UiScript,
data: &std::path::Path,
root: &Path,
tally: &'static std::sync::Mutex<ProbeTally>,
) {
let chain = std::sync::Arc::new(std::sync::Mutex::new(
benilla_formats::open_chain(data).expect("patch chain"),
));
// `Chain` does not publish its mount list, so count what `mount_order` would pick up: every
// archive in the folder is one more `contains` a miss has to ask.
let archives = std::fs::read_dir(data)
.map(|d| {
d.flatten()
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.to_ascii_lowercase().ends_with(".mpq"))
})
.count()
})
.unwrap_or(0);
println!(
"[probe] patch chain: {archives} archive(s) under {}",
data.display()
);
let (tex_chain, tex_root) = (chain.clone(), root.to_path_buf());
script.set_texture_probe(Box::new(move |path| {
let t = Instant::now();
let found = benilla_assets::sprite_candidates(path).iter().any(|c| {
tex_chain.lock().unwrap().contains(c)
|| benilla_assets::loose_addon_file(&tex_root, c).is_some()
});
let mut tally = tally.lock().unwrap();
tally.texture_calls += 1;
tally.texture_us += t.elapsed().as_secs_f64() * 1e6;
found
}));
// Memoised exactly as the live probe is, misses included.
let (size_chain, size_root) = (chain.clone(), root.to_path_buf());
let sizes: std::cell::RefCell<std::collections::HashMap<String, Option<(u32, u32)>>> =
std::cell::RefCell::new(std::collections::HashMap::new());
script.set_texture_size_probe(Box::new(move |path| {
let t = Instant::now();
// Copied out before the branch, for the reason the font probe below gives.
let cached = sizes.borrow().get(path).copied();
let measured = if let Some(cached) = cached {
cached
} else {
let m = benilla_assets::sprite_dimensions(&size_chain, Some(&size_root), path);
sizes.borrow_mut().insert(path.to_string(), m);
m
};
let mut tally = tally.lock().unwrap();
tally.size_calls += 1;
tally.size_us += t.elapsed().as_secs_f64() * 1e6;
measured
}));
let (font_chain, font_root) = (chain, root.to_path_buf());
let seen: std::cell::RefCell<std::collections::HashMap<String, bool>> =
std::cell::RefCell::new(std::collections::HashMap::new());
script.set_font_probe(Box::new(move |path| {
let t = Instant::now();
// The lookup is copied out and the borrow dropped before the branch: a `Ref` held in
// the `if let` scrutinee lives to the end of the whole statement, so the `borrow_mut`
// below would panic on the first font the cache has not seen.
let cached = seen.borrow().get(path).copied();
let found = if let Some(cached) = cached {
cached
} else {
let f = font_chain.lock().unwrap().contains(path)
|| benilla_assets::loose_addon_file(
&font_root,
&benilla_assets::normalize_path(path),
)
.is_some();
seen.borrow_mut().insert(path.to_string(), f);
f
};
let mut tally = tally.lock().unwrap();
tally.font_calls += 1;
tally.font_us += t.elapsed().as_secs_f64() * 1e6;
found
}));
}
#[test]
#[ignore = "bench — needs WOW_DATA and the player's AddOns; run explicitly with --release"]
fn tick_bench() {
let data = benilla_formats::wow_data_or_skip!();
let Some(root) = crate::ui_script::addons::root() else {
eprintln!("tick_bench: no benilla-config/AddOns — nothing to measure");
return;
};
let t = Instant::now();
let mut s = load_the_players_ui(&root);
println!("[tick] loaded the player's UI in {:.0} ms", ms(t));
let plates = plate_feeds();
// Settle: the first ticks arm handlers that have never run, and the birth of 40 plates is a
// wide layout solve.
for _ in 0..30 {
feed_units(&mut s, Company::Grouped);
s.sync_nameplates(GEOMETRY, &plates);
s.tick(1.0 / 60.0);
answer_measures(&mut s);
s.resolve();
}
let errs = s.take_errors();
if !errs.is_empty() {
println!(
"[tick] {} load/settle error(s); first: {}",
errs.len(),
errs[0]
);
}
// ── The Lua heap, and the garbage a frame makes ─────────────────────────────────────────
//
// pfDebug reports only this axis, and the user's panel reported 129 collections having freed
// 16,834 MB — i.e. ~130 MB of garbage per cycle. Both halves matter: a 5.1 collector with the
// default 200% pause lets the heap reach twice its live set before a cycle completes, so the
// live set sets the cycle's size and the per-frame churn sets its rate, and the mark/sweep
// cost is paid in whatever Lua happens to be running.
{
let lua = s.lua();
lua.gc_collect().expect("settle");
lua.gc_collect().expect("settle");
let live = lua.used_memory();
lua.gc_stop();
let before = lua.used_memory();
for _ in 0..FRAMES {
feed_units(&mut s, Company::Grouped);
s.sync_nameplates(GEOMETRY, &plates);
s.tick(1.0 / 60.0);
answer_measures(&mut s);
s.resolve();
}
let lua = s.lua();
let churn = lua.used_memory().saturating_sub(before);
lua.gc_restart();
lua.gc_collect().expect("settle");
println!(
"[tick] lua heap live {:.1} MB · churn {:.1} kB/frame ({:.1} MB/s at 60 fps) · \
{:.0} s between 200%-pause cycles",
live as f64 / (1024.0 * 1024.0),
churn as f64 / f64::from(FRAMES) / 1024.0,
churn as f64 / f64::from(FRAMES) * 60.0 / (1024.0 * 1024.0),
if churn == 0 {
f64::INFINITY
} else {
live as f64 / (churn as f64 / f64::from(FRAMES) * 60.0)
},
);
}
// Three windows, each in the app's own per-frame order (`extract::tick_script`): feed, tick
// the VM, answer measures, resolve. Grouped first, then the same interface solo — a real
// session is mostly solo, and only the solo feed takes the absent-token branches — and then
// solo again with the host asset probes the live client installs, which is the half of a UI
// frame no previous window measured.
let per = f64::from(FRAMES);
static PROBES: std::sync::Mutex<ProbeTally> = std::sync::Mutex::new(ProbeTally {
texture_calls: 0,
texture_us: 0.0,
size_calls: 0,
size_us: 0.0,
font_calls: 0,
font_us: 0.0,
});
for (company, probes) in [
(Company::Grouped, false),
(Company::Solo, false),
(Company::Solo, true),
] {
let label = match (company, probes) {
(Company::Grouped, _) => "grouped",
(Company::Solo, false) => "solo",
(Company::Solo, true) => "solo+probes",
};
if probes {
install_live_asset_probes(&mut s, &data, &root, &PROBES);
}
// Re-settle: the first frames after the feed changes shape re-derive layout and arm
// branches that have not run, and that birth belongs to neither window.
for _ in 0..30 {
feed_units(&mut s, company);
s.sync_nameplates(GEOMETRY, &plates);
s.tick(1.0 / 60.0);
answer_measures(&mut s);
s.resolve();
}
let _ = s.take_errors();
// Re-arming clears the rows, so each window's profile is its own.
s.profile_handlers(true, 0.0);
// And the tick's own phase split, the other half of the same question: the handler rows
// say what the bodies cost, these say what the engine around them cost
// (`benilla_ui::script::tick_prof`).
s.profile_tick(true, 0.0);
let (mut feed_ms, mut tick_ms, mut resolve_ms, mut extract_ms) = (0.0, 0.0, 0.0, 0.0);
let mut quads = 0usize;
let whole = Instant::now();
for _ in 0..FRAMES {
let a = Instant::now();
feed_units(&mut s, company);
s.sync_nameplates(GEOMETRY, &plates);
feed_ms += ms(a);
let b = Instant::now();
s.tick(1.0 / 60.0);
tick_ms += ms(b);
let c = Instant::now();
answer_measures(&mut s);
s.resolve();
resolve_ms += ms(c);
// The quad list the app rebuilds every frame (`ui_pass.rs`), the other half of a UI
// frame.
let d = Instant::now();
quads = s.extract().len();
extract_ms += ms(d);
}
println!(
"[tick] {label} {FRAMES} frames: {:.3} ms/frame total — feed {:.3} vm-tick {:.3} \
resolve {:.3} extract {:.3} ({quads} quads)",
ms(whole) / per,
feed_ms / per,
tick_ms / per,
resolve_ms / per,
extract_ms / per,
);
let errs = s.take_errors();
if !errs.is_empty() {
println!(
"[tick] {label} {} error(s) escaped the window; first: {}",
errs.len(),
errs[0]
);
}
if probes {
let t = PROBES.lock().unwrap();
println!(
"[probe] {label}: texture {:.2} calls/f {:.3} ms/f · size {:.2} calls/f \
{:.3} ms/f · font {:.2} calls/f {:.3} ms/f",
t.texture_calls as f64 / per,
t.texture_us / per / 1000.0,
t.size_calls as f64 / per,
t.size_us / per / 1000.0,
t.font_calls as f64 / per,
t.font_us / per / 1000.0,
);
}
let split = s.tick_profile();
let (peak_us, peak_phase) = s.tick_profile_peak();
println!("[tick] {label} worst tick {peak_us:.1} us (mostly {peak_phase})");
println!(
"[tick] {label} tick split (us/tick): {}",
split
.iter()
.map(|r| format!("{} {:.1}", r.phase, r.us_per_tick))
.collect::<Vec<_>>()
.join(" ")
);
println!(
"[tick] {label} per tick: {}",
s.tick_profile_counts()
.iter()
.map(|(l, n)| format!("{l} {n:.2}"))
.collect::<Vec<_>>()
.join(" ")
);
// The `timers` phase by callback site. A live session put 40,371 us of a 40,841 us tick
// here with nothing to say which row that was; these are the rows that answer it, and
// their absence in this window is itself the finding (`timer_bench`).
for r in s.timer_profile().iter().take(8) {
println!(
"[tick] {label} timer {:9.1} us/tick · {:6.2} fires/tick · {:6.2} armed · \
worst fire {:.1} us · {}",
r.us_per_tick, r.fires_per_tick, r.rows_per_tick, r.peak_us, r.site,
);
}
let rows = s.handler_profile();
let total: f64 = rows.iter().map(|r| r.self_us).sum();
println!(
"[tick] {label} {} handlers, {:.3} ms/frame of handler self time",
rows.len(),
total / 1000.0 / per
);
println!("[tick] self/f(us) total/f(us) calls/f handler");
for r in rows.iter().take(15) {
println!(
"[tick] {:11.1} {:12.1} {:8.2} {}:{}",
r.self_us / per,
r.total_us / per,
f64::from(r.calls) / per,
r.frame,
r.script
);
}
}
// ── What one addon bug costs a frame ────────────────────────────────────────────────────
//
// mlua's protected call installs a message handler that, for every stack level, searches the
// global table and one level into every table in it for a name for the function (mlua-sys
// `lua51/compat.rs:73-119`, `compat53_findfield`). Handler and `C_Timer` dispatch therefore go
// through Lua's own `xpcall` instead (`benilla_ui::script::event::protected_call`); this block
// is the number that bought.
{
let probe = r#"
local n, deep, worst, worstn = 0, 0, "", 0
for k, v in pairs(getfenv(0)) do
n = n + 1
if type(v) == "table" then
local c = 0
for _ in pairs(v) do c = c + 1 end
deep = deep + c
if c > worstn then worstn, worst = c, tostring(k) end
end
end
return n .. " globals, " .. deep .. " keys one level in, biggest " .. worst
.. " (" .. worstn .. ")"
"#;
match s.eval::<String>(probe) {
Ok(v) => println!("[raise] what mlua's handler would walk: {v}"),
Err(e) => println!("[raise] global-walk probe failed: {e}"),
}
// A frame born and shown before either window, with only its handler swapped, so the
// birth's layout derivation is charged to neither.
s.run(
r#"
RaiseProbe = CreateFrame("Frame", "RaiseProbe", UIParent)
RaiseProbe:SetWidth(10) RaiseProbe:SetHeight(10)
RaiseProbe:SetPoint("CENTER", 0, 0)
RaiseProbe:SetScript("OnUpdate", function() local x = nil end)
RaiseProbe:Show()
"#,
)
.expect("handler fixture");
const R: u32 = 200;
for _ in 0..4 {
feed_units(&mut s, Company::Grouped);
s.tick(1.0 / 60.0);
answer_measures(&mut s);
s.resolve();
}
let t = Instant::now();
for _ in 0..R {
s.tick(1.0 / 60.0);
}
let quiet = ms(t) / f64::from(R);
s.run("RaiseProbe:SetScript(\"OnUpdate\", function() local x = nil; x() end)")
.expect("arm the raise");
let t = Instant::now();
for _ in 0..R {
s.tick(1.0 / 60.0);
}
let raising = ms(t) / f64::from(R);
let _ = s.take_errors();
println!(
"[raise] one raising OnUpdate costs {:.3} ms/frame ({raising:.3} raising, \
{quiet:.3} the same handler without the raise)",
raising - quiet
);
// ── The live-only shape: raises Lua swallows ─────────────────────────────────────────
//
// The window above measures a raise the host *sees*: the trampoline catches it, it lands
// in `take_errors`, and `run.sh`'s error log carries one line a frame. That is the shape a
// player notices. The shape a player cannot notice is an addon's own
// `pcall(SomeBinding, bad)`: the raise never reaches the host, so no log line exists, and
// the whole traceback is still built and paid for. pfUI alone has 25 such sites
// (`pcall(UnitXP, …)`, `pcall(GetCVar, …)`, `pcall(UnitName, unit .. "target")`), several
// of them inside an `OnUpdate`.
//
// Five a frame is the arithmetic that first made this block worth writing: a user's live
// session spent 34 ms of a 42 ms frame inside `extract::tick_script` against this bench's
// 0.44 ms, with a clean error log, and five raises at the pre-fork 6.95 ms is the whole
// deficit. Whether that user's frames really held five is a question only a live session
// answers (`WOW_UI_RAISES=<secs>`, `benilla_ui::script::raise_prof`); what this row
// answers is what five would cost, which is the half a bench can own.
s.run(
r#"
RaiseProbeTarget = CreateFrame("Frame")
RaiseProbe:SetScript("OnUpdate", function()
for _ = 1, 5 do pcall(UnitName, RaiseProbeTarget) end
end)
"#,
)
.expect("arm the swallowed raises");
let t = Instant::now();
for _ in 0..R {
s.tick(1.0 / 60.0);
}
let swallowed = ms(t) / f64::from(R);
let _ = s.take_errors();
println!(
"[raise] five `pcall`'d binding raises a frame cost {:.3} ms/frame \
({swallowed:.3} raising, {quiet:.3} quiet) — and log nothing",
swallowed - quiet
);
s.run("RaiseProbe:SetScript(\"OnUpdate\", function() local x = nil end)")
.expect("disarm the raise");
// The control: `UiScript::run` still goes through mlua's own protected call, so this row
// is what every path that has not been moved off it still pays for a raise.
s.run("function __bench_raise() local x = nil; x() end function __bench_ok() end")
.expect("fixtures");
let t = Instant::now();
for _ in 0..R {
let _ = s.run("__bench_ok()");
}
let ok_us = ms(t) * 1000.0 / f64::from(R);
let t = Instant::now();
for _ in 0..R {
let _ = s.run("__bench_raise()");
}
let raise_us = ms(t) * 1000.0 / f64::from(R);
let t = Instant::now();
let _ = s.run(&format!("for _ = 1, {R} do pcall(__bench_raise) end"));
let lua_pcall_us = ms(t) * 1000.0 / f64::from(R);
let _ = s.take_errors();
println!(
"[raise] through mlua: run ok {ok_us:.1} us · run raising {raise_us:.1} us · \
lua pcall raising {lua_pcall_us:.1} us"
);
// The other site mlua builds that traceback at: a Rust binding returning `Err` (`mlua`
// `state/util.rs`, `callback_error_ext`). Every `Usage:` and `Unknown unit name:` raise
// goes through it, and no Lua-side trampoline can reach it — it is inside the
// `lua_CFunction` mlua wraps each binding in. `third_party/mlua-sys` is where that cost
// was removed, by deleting the globals-table search from the traceback's frame naming;
// this row and the bare-VM control below are the before/after it is justified by
// (6949.2 us → 2.8 us here, 83.2 us → 2.6 us bare).
s.run("function __bench_usage() UnitName(CreateFrame(\"Frame\")) end")
.expect("fixtures");
let t = Instant::now();
let _ = s.run(&format!("for _ = 1, {R} do pcall(__bench_usage) end"));
println!(
"[raise] a binding's own `Usage:` raise, caught by Lua: {:.1} us",
ms(t) * 1000.0 / f64::from(R)
);
let _ = s.take_errors();
// The control that named the cause: the identical raise in a bare VM, whose globals are
// ours alone. Upstream `compat53_findfield` walked the global table and one level into
// every table in it, once per stack level, so the cost was the *size* of `_G` — which the
// addons set; a player's interface paid ~84× a bare VM's price for the same mistake, and a
// `pcall` around it hid every bit of that from the error log. The two rows now agree
// within a factor, which is what says the walk is gone;
// `benilla_ui::script::tests::caught_raise_cost` is the test that fails if it returns.
{
let bare = UiScript::new().expect("VM");
bare.run("function __bench_usage() UnitName(CreateFrame(\"Frame\")) end")
.expect("fixtures");
let n: i64 = bare
.eval("local n = 0 for _ in pairs(getfenv(0)) do n = n + 1 end return n")
.unwrap_or(-1);
let t = Instant::now();
let _ = bare.run(&format!("for _ = 1, {R} do pcall(__bench_usage) end"));
println!(
"[raise] the same raise in a bare VM ({n} globals): {:.1} us",
ms(t) * 1000.0 / f64::from(R)
);
}
let t = Instant::now();
s.lua().gc_collect().expect("full gc");
println!("[raise] one full gc_collect on this heap: {:.1} ms", ms(t));
}
}
/// **The live-only shape, reproduced.** `tick_bench` above measured 0.38 ms a frame with
/// `timers` at 1.6 us while the user's own session spent **40,371 us of a 40,841 us tick** in that
/// one phase, and the reason the bench could not see it is seated three screens up: `seat_a_session`
/// pre-caches the Hearthstone's template (`addon_harness/mod.rs:1309`), so pfQuest's locale check
/// takes its *synchronous* arm (`database.lua:2238`) and the `ContinueOnItemLoad` poll — the only
/// `C_Timer` row in this interface that benilla itself arms — is never created at all.
///
/// A real login cannot take that arm: addons run before the server's bag push arrives, so
/// `database.lua:2242` arms the poll, the template lands a few frames later, and the poll resolves.
/// At which point `ticker:Cancel()` was silently dropped by the drain — a cancel taken from inside
/// a callback could not reach a row `mem::take` had moved out of `model.timers` — leaving a
/// **zero-interval** ticker armed with pfQuest's `BuildNameIndex` + `BuildStaticRejectSet` rebuild
/// as its body. Every tick. For the rest of the session.
///
/// So this drives exactly that sequence over the player's whole interface, with pfQuest's own
/// payload, and holds the phase to its fixed cost.
#[test]
#[ignore = "bench — needs WOW_DATA and the player's AddOns; run explicitly with --release"]
fn a_resolved_item_load_leaves_no_poll_running_on_the_players_whole_interface() {
benilla_formats::wow_data_or_skip!();
let Some(root) = crate::ui_script::addons::root() else {
eprintln!("timer bench: no benilla-config/AddOns — nothing to measure");
return;
};
let mut s = load_the_players_ui(&root);
for _ in 0..30 {
feed_units(&mut s, Company::Solo);
s.tick(1.0 / 60.0);
answer_measures(&mut s);
s.resolve();
}
let _ = s.take_errors();
assert!(
s.eval::<bool>("return pfDatabase ~= nil and pfDatabase.BuildNameIndex ~= nil")
.unwrap_or(false),
"this bench measures pfQuest's own locale-check payload; without pfQuest loaded it \
measures nothing"
);
// Linen Cloth: in the seated backpack, and the one bag entry `seat_a_session` deliberately
// leaves *without* a template — so it stands in for the live Hearthstone at the moment
// `database.lua` runs, which is the state the pre-cached 6948 cannot reproduce.
const UNCACHED: u32 = 2589;
s.run(
r#"
__poll_runs = 0
local item = Item:CreateFromItemID(2589)
item:ContinueOnItemLoad(function()
__poll_runs = __poll_runs + 1
-- pfQuest's `RunNameCheck` tail verbatim (`database.lua:2234-2235`): the payload the
-- live session was running once a frame.
pfDatabase:BuildNameIndex()
pfDatabase:BuildStaticRejectSet()
end)
"#,
)
.expect("arm the poll");
for _ in 0..3 {
s.tick(1.0 / 60.0);
}
assert_eq!(
s.eval::<i64>("return __poll_runs").unwrap(),
0,
"the item is not cached yet, so the poll has nothing to resolve"
);
// The bag push lands. One `[ui-tick]`-shaped window follows it, on a fresh split so the rows
// are this window's alone.
s.set_item_template(
UNCACHED,
benilla_ui::script::ItemTemplateView {
name: "Linen Cloth".into(),
..Default::default()
},
);
s.profile_tick(true, 0.0);
for _ in 0..FRAMES {
feed_units(&mut s, Company::Solo);
s.tick(1.0 / 60.0);
answer_measures(&mut s);
s.resolve();
}
let _ = s.take_errors();
let split = s.tick_profile();
let timers_us = split
.iter()
.find(|r| r.phase == "timers")
.expect("a timers row")
.us_per_tick;
println!(
"[timers] {FRAMES} frames after the item arrived: timers {timers_us:.1} us/tick, \
payload ran {} time(s)",
s.eval::<i64>("return __poll_runs").unwrap(),
);
for r in s.timer_profile().iter().take(8) {
println!(
"[timers] {:9.1} us/tick · {:6.2} fires/tick · {:6.2} armed · worst fire {:.1} us \
· {}",
r.us_per_tick, r.fires_per_tick, r.rows_per_tick, r.peak_us, r.site,
);
}
assert_eq!(
s.eval::<i64>("return __poll_runs").unwrap(),
1,
"one item load, one callback — not one a tick from here to the end of the session"
);
// And the steady state, on a fresh split: the window above still carries the one legitimate
// 41 ms fire the addon asked for, amortised. This one has nothing left but the eight
// persistent tickers, and it is the number a player feels.
s.profile_tick(true, 0.0);
for _ in 0..FRAMES {
feed_units(&mut s, Company::Solo);
s.tick(1.0 / 60.0);
answer_measures(&mut s);
s.resolve();
}
let _ = s.take_errors();
let settled = s
.tick_profile()
.into_iter()
.find(|r| r.phase == "timers")
.expect("a timers row")
.us_per_tick;
println!("[timers] steady state, the load long since resolved: timers {settled:.1} us/tick");
assert!(
s.timer_profile()
.iter()
.all(|r| !r.site.contains("item_mixin")),
"no poll row may outlive the load it was waiting for: {:?}",
s.timer_profile()
);
// The eight persistent tickers this interface arms cost a few microseconds a tick between
// them (`tick_bench`'s own rows). A thousand is slack for a slow machine and still two orders
// off the leak, which put the single payload fire at tens of milliseconds *every* tick.
assert!(
timers_us < 1000.0,
"the `timers` phase must be back to what the eight persistent tickers cost: \
{timers_us:.1} us/tick"
);
}
@@ -16,11 +16,15 @@
//! `LoadLoadOnDemandPlugins`, Auctioneer's stub), so honouring the flag would model a shorter
//! session than anyone plays.
use std::collections::BTreeSet;
use std::path::Path;
use benilla_ui::script::UiScript;
use super::{corpus, HarnessLoad, ADDON_INSTRUCTION_BUDGET};
use super::{
corpus, load_addon_files, load_dependencies, manifest_path, LoadedDep, ADDON_INSTRUCTION_BUDGET,
};
use benilla_ui::toc::Toc;
/// How many fresh-VM runs [`survey_together`] makes: three is the fewest that tell "always" from
/// "sometimes".
@@ -84,33 +88,47 @@ pub fn survey_together(root: &Path) -> Vec<TogetherRow> {
/// One walk in one VM.
fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
let (names, _, registry) = corpus(root);
let (names, installed, registry) = corpus(root);
let Ok(mut script) = UiScript::new() else {
return Vec::new();
};
script.set_screen_size(1024.0, 768.0);
script.register_addons(registry.clone(), Some(root.to_path_buf()), None, None);
script.register_addons(registry, Some(root.to_path_buf()), None, None);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
// The live walk: `AddOn_Load` on every addon in order, through the same driver, so a
// library loads once under whichever dependent reaches it first and an addon that names
// another in `## LoadWith` loads right after it. The corpus is walked whole, LoadOnDemand
// included, and past the version check.
let mut host = HarnessLoad::new(&script, root, &registry);
for row in 0..registry.len() {
// `seen` spans the whole walk: a library declared by many dependents loads once.
let mut seen: BTreeSet<String> = BTreeSet::new();
// `(folder, its load failures)`, kept beside the walk: the VM's diagnostics log caps at 256
// distinct rows and evicts.
let mut load_errors: Vec<(String, Vec<String>)> = Vec::new();
for name in &names {
let Some(toc) = manifest_path(root, name)
.and_then(|p| std::fs::read(p).ok())
.map(|b| Toc::parse(&benilla_ui::source::decode(&b)))
else {
continue;
};
if !seen.insert(name.to_ascii_lowercase()) {
continue; // already loaded as a dependency
}
// Re-armed per addon, as the live walk does, so one runaway does not starve the rest.
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
script.load_addon_row(&mut host, row, false, false);
let mut pulled: Vec<LoadedDep> = Vec::new();
load_dependencies(&mut script, root, &toc, &installed, &mut seen, &mut pulled);
// A dependency's load failures are collected only here: it loads once, under the first
// dependent to reach it, and never gets a turn of its own.
for dep in pulled {
load_errors.push((dep.name, dep.files.errors));
}
let files = load_addon_files(&script, root, name, &toc);
load_errors.push((name.clone(), files.errors));
script.mark_addon_loaded(name);
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(name.clone())],
);
}
// `(folder, its load failures)`, kept beside the walk: the VM's diagnostics log caps at 256
// distinct rows and evicts. A dependency's failures are collected here too: it loads once and
// never gets a turn of its own.
let load_errors: Vec<(String, Vec<String>)> = host
.loaded
.into_iter()
.map(|l| (l.name, l.files.errors))
.collect();
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
script.fire_event(event, Vec::new());
@@ -24,9 +24,9 @@ use super::render::{Painted, RenderBaseline};
/// it, in painter order, are dropped, so a broken ninth widget reads clean.
pub const MAX_USE_TARGETS: usize = 8;
/// How far the drag travels: past the drag-start distance (0.01 of the screen diagonal, 12.8 root
/// units at the 1024×768 these runs use) so `OnDragStart` fires.
const DRAG_PX: f32 = 14.0;
/// How far the drag travels: past `cursor::DRAG_START_THRESHOLD` (4 px, strict `>`) so
/// `OnDragStart` fires, and inside a 16 px item slot so the release lands on the same frame.
const DRAG_PX: f32 = 5.0;
/// Where the cursor is parked at the end, off-screen so the last `OnLeave` always fires.
const PARKED: (f32, f32) = (-10_000.0, -10_000.0);
@@ -90,7 +90,7 @@ fn the_use_column_can_fail() {
"#,
),
);
// `OnDragStart` fires only past the drag-start distance, so the drag must actually move.
// `OnDragStart` fires only past the 4 px threshold, so the drag must actually move.
fx.addon(
"RaisesOnDrag",
&painted_button(
+37 -214
View File
@@ -26,13 +26,13 @@
//! Deviation: the indoor dedup keys `(WMOID, MOGP uniqueID)`, not the client's raw per-WMO group
//! index, because the raw index can alias across adjacent buildings.
//!
//! `GetZonePVPInfo`'s type and faction show only on a PvP realm or in one of the six capitals
//! (zone Flags `0x10`); elsewhere they are nil and the stock zone texts stay uncoloured.
//! The reference's PvP display gate `[0x88272c]` (a cached boolean, not the realm type) is
//! untraced; ours is always open.
use bevy::ecs::system::NonSendMut;
use bevy::prelude::*;
use benilla_formats::{AreaTableCatalog, AreaTableRow};
use benilla_formats::AreaTableCatalog;
use benilla_ui::script::UiScript;
use crate::net::{ObjectStore, SelfPlayer};
@@ -99,7 +99,7 @@ fn elect_event(cache: &ZoneCache, next: &ZoneSignal) -> Option<&'static str> {
None
}
/// Resolves the texts and PvP info, pushes the zone caches, then fires the elected zone event and,
/// Resolves the texts and PvP info, writes the host globals, then fires the elected zone event and,
/// independently, `MINIMAP_ZONE_CHANGED`.
fn feed_zone_events(
script: Option<NonSendMut<UiScript>>,
@@ -107,7 +107,6 @@ fn feed_zone_events(
areas: Option<Res<AreaTableRes>>,
factions: Option<Res<Factions>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
realm_pvp: Option<Res<RealmPvp>>,
mut cache: Local<crate::ui_script::VmMemo<ZoneCache>>,
) {
let (Some(mut script), Some(areas)) = (script, areas) else {
@@ -194,34 +193,41 @@ fn feed_zone_events(
return;
}
let zone_row = areas.0.get(zone);
let template = factions
// GetZonePVPInfo (`0x48d540`): isArena is the leaf's Flags `0x80`; the type is the zone's
// FactionGroupMask against the player template's friend then enemy masks, "contested" when
// neither; never "arena"; nil only on structural failure. factionName is FactionGroup.dbc's
// Name for the zone's mask bit; the realm type never enters.
let is_arena = leaf_row.flags & 0x80 != 0;
let zone_mask = areas.0.get(zone).map_or(0, |r| r.faction_group_mask);
let pvp = factions
.as_ref()
.zip(self_store.single().ok())
.and_then(|(f, store)| f.catalog().template(store.0.unit_faction_template()?))
.map(|t| (t.friend_group_mask, t.enemy_group_mask));
let realm_pvp = realm_pvp.is_some_and(|r| r.0);
let (ty, is_arena) = zone_pvp_info(realm_pvp, leaf_row, zone_row, template);
// factionName is FactionGroup.dbc's Name for the zone's mask bit.
let pvp = ty.map(|ty| {
let mask = zone_row.map_or(0, |r| r.faction_group_mask);
let name = factions
.as_ref()
.and_then(|f| f.catalog().faction_group_name(mask))
.unwrap_or("");
(ty, name)
});
let pvp_type = pvp.map_or("", |(ty, _)| ty);
.and_then(|(f, store)| {
let tpl = f.catalog().template(store.0.unit_faction_template()?)?;
let ty = if zone_mask & tpl.friend_group_mask != 0 {
"friendly"
} else if zone_mask & tpl.enemy_group_mask != 0 {
"hostile"
} else {
"contested"
};
Some((ty, f.catalog().faction_group_name(zone_mask).unwrap_or("")))
});
let (pvp_type, pvp_faction) = pvp.unwrap_or(("", ""));
script.set_zone_texts(benilla_ui::script::ZoneTexts {
zone: signal.zone_text.clone(),
real_zone: real_zone_text,
subzone: signal.subzone_text.clone(),
minimap: minimap_text.clone(),
pvp_type: pvp.map(|(ty, _)| ty.to_string()),
pvp_faction: pvp_faction_text(pvp),
is_arena,
});
let globals = script.lua().globals();
let pushed = globals
.set("__benilla_zone_name", signal.zone_text.clone())
.and_then(|()| globals.set("__benilla_real_zone_name", real_zone_text))
.and_then(|()| globals.set("__benilla_subzone_name", signal.subzone_text.clone()))
.and_then(|()| globals.set("__benilla_zone_text", minimap_text.clone()))
.and_then(|()| globals.set("__benilla_pvp_type", pvp_type))
.and_then(|()| globals.set("__benilla_pvp_faction", pvp_faction))
.and_then(|()| globals.set("__benilla_pvp_arena", is_arena));
if let Err(e) = pushed {
warn!("area: zone host globals: {e}");
return;
}
if let Some(event) = event {
script.fire_event(event, vec![]);
@@ -242,78 +248,12 @@ fn feed_zone_events(
cache.wmo_group = wmo_group;
}
/// `GetZonePVPInfo`'s second value: with a type answered, the faction name is always a string,
/// starting as the empty string (`0x48d633`, `0x882748`) and pushed by `lua_pushstring`
/// (`0x48d68f`) whether or not a FactionGroup row supplied one; only the bail (`0x48d6b5`) pushes
/// nil.
fn pvp_faction_text(pvp: Option<(&str, &str)>) -> Option<String> {
pvp.map(|(_, faction)| faction.to_string())
}
/// `GetZonePVPInfo` (`0x48d540`): `(pvpType, isArena)`. isArena is the leaf's Flags `0x80`, read
/// before any bail. The type is nil with no zone row, no faction template, or the display gate
/// closed: it opens on a PvP realm ([`RealmPvp`]) or in a zone whose Flags carry `0x10`, the six
/// capitals (`0x48d5c2`/`0x48d5cc`). Open, it is the zone's FactionGroupMask against the template's
/// `(friend, enemy)` masks, "contested" when neither, never "arena".
fn zone_pvp_info(
realm_pvp: bool,
leaf: &AreaTableRow,
zone: Option<&AreaTableRow>,
template: Option<(u32, u32)>,
) -> (Option<&'static str>, bool) {
let is_arena = leaf.flags & 0x80 != 0;
let ty = zone
.filter(|z| realm_pvp || z.flags & 0x10 != 0)
.zip(template)
.map(|(z, (friend, enemy))| {
let mask = z.faction_group_mask;
if mask & friend != 0 {
"friendly"
} else if mask & enemy != 0 {
"hostile"
} else {
"contested"
}
});
(ty, is_arena)
}
/// `[0x88272c]`, the realm's PvP flag the display gate reads: the selected realm's
/// `PlayerKillingAllowed` in `Cfg_Configs.dbc`, stored once per world entry from the character
/// list (`0x4015de` in `0x401570`), so a `/reload` or a far teleport keeps it.
#[derive(Resource, Default)]
pub(crate) struct RealmPvp(bool);
/// Latch [`RealmPvp`] from the realm this session entered the world on.
fn latch_realm_pvp(
mut latch: ResMut<RealmPvp>,
roster: Option<Res<crate::char_select::Roster>>,
realms: Option<Res<crate::realm_select::Realms>>,
) {
let realm_type = roster.and_then(|r| r.realm.as_ref().map(|realm| realm.realm_type));
let row = realm_type
.zip(realms)
.and_then(|(t, realms)| realms.row_pvp(t));
latch.0 = latched_realm_pvp(latch.0, row);
}
/// A realm type with no `Cfg_Configs.dbc` row skips the store (`0x4015d2`) and keeps the last
/// world entry's value, which the process starts at 0 (`.data`).
fn latched_realm_pvp(previous: bool, row_pvp: Option<bool>) -> bool {
row_pvp.unwrap_or(previous)
}
/// The shared area catalog and the zone-event feed.
pub(crate) struct AreaPlugin;
impl Plugin for AreaPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<RealmPvp>()
.add_systems(Startup, load_area_table.after(AssetSet::Open))
.add_systems(
OnEnter(crate::char_select::ClientState::InWorld),
latch_realm_pvp,
)
app.add_systems(Startup, load_area_table.after(AssetSet::Open))
.add_systems(
Update,
// After the leaf authority: leaf, indoor bit and names must come from one frame,
@@ -390,123 +330,6 @@ mod tests {
);
}
fn row(flags: u32, faction_group_mask: u32) -> AreaTableRow {
AreaTableRow {
map_id: 0,
zone_id: 0,
explore_flag: 0,
flags,
faction_group_mask,
exploration_level: 0,
name: String::new(),
}
}
/// An Alliance player's template: friend Alliance (2), enemy Horde (4).
const ALLIANCE: Option<(u32, u32)> = Some((2, 4));
/// The display gate (`0x48d5c2`/`0x48d5cc`): a PvE realm outside a capital gets no type; a
/// capital's Flags `0x10` opens it there, and a PvP realm opens it everywhere.
#[test]
fn the_pvp_type_shows_on_a_pvp_realm_or_in_a_capital() {
let elwynn = row(0x40, 2);
let stormwind = row(0x138, 2);
let orgrimmar = row(0x138, 4);
let stranglethorn = row(0x40, 0);
let pvp =
|realm_pvp, zone: &AreaTableRow| zone_pvp_info(realm_pvp, zone, Some(zone), ALLIANCE).0;
assert_eq!(pvp(false, &elwynn), None, "PvE realm, outside a capital");
assert_eq!(
pvp(false, &stranglethorn),
None,
"PvE realm, ownerless zone"
);
assert_eq!(
pvp(false, &stormwind),
Some("friendly"),
"PvE realm, own capital"
);
assert_eq!(
pvp(false, &orgrimmar),
Some("hostile"),
"PvE realm, enemy capital"
);
assert_eq!(pvp(true, &elwynn), Some("friendly"));
assert_eq!(pvp(true, &stranglethorn), Some("contested"));
assert_eq!(pvp(true, &orgrimmar), Some("hostile"));
}
/// An answered type always carries a faction string, `""` in an ownerless zone such as
/// Stranglethorn Vale (`0x48d633`); no type, no string (`0x48d6b5`).
#[test]
fn an_answered_type_carries_an_empty_faction_name_not_nil() {
assert_eq!(
pvp_faction_text(Some(("contested", ""))),
Some(String::new())
);
assert_eq!(
pvp_faction_text(Some(("friendly", "Alliance"))),
Some("Alliance".to_string())
);
assert_eq!(pvp_faction_text(None), None);
}
/// isArena is the leaf's `0x80`, read before any bail, so a closed gate keeps it.
#[test]
fn is_arena_survives_a_closed_gate() {
let battle_ring = row(0xd0, 0);
let stranglethorn = row(0x40, 0);
assert_eq!(
zone_pvp_info(false, &battle_ring, Some(&stranglethorn), ALLIANCE),
(None, true)
);
assert_eq!(
zone_pvp_info(true, &battle_ring, Some(&stranglethorn), ALLIANCE),
(Some("contested"), true)
);
assert_eq!(
zone_pvp_info(true, &battle_ring, Some(&stranglethorn), None),
(None, true),
"no faction template"
);
}
/// A realm type with no row keeps what the last world entry stored.
#[test]
fn a_realm_type_with_no_row_keeps_the_last_latch() {
assert!(!latched_realm_pvp(false, None));
assert!(latched_realm_pvp(true, None));
assert!(!latched_realm_pvp(true, Some(false)));
assert!(latched_realm_pvp(false, Some(true)));
}
/// No zone row is a bail, not an ownerless zone.
#[test]
fn a_missing_zone_row_has_no_pvp_type() {
assert_eq!(
zone_pvp_info(true, &row(0, 0), None, ALLIANCE),
(None, false)
);
}
/// On the install's AreaTable the gate opens in exactly the six capitals for a PvE realm.
#[test]
fn the_six_capitals_are_the_zones_a_pve_realm_colours() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("chain");
let cat = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable");
let open: Vec<u32> = (0..=u32::from(u16::MAX))
.filter(|&id| {
cat.get(id).is_some_and(|r| {
r.zone_id == 0 && zone_pvp_info(false, r, Some(r), ALLIANCE).0.is_some()
})
})
.collect();
assert_eq!(open, [1497, 1519, 1537, 1637, 1638, 1657]);
}
/// The `0x67e670` override skip (abbey) and fire (inn) branches on the install's data.
#[test]
fn indoor_naming_matches_the_byte_law_on_real_data() {
+256
View File
@@ -0,0 +1,256 @@
//! Auto-login and the character screen's per-realm memory, benilla's native build of
//! `Minimalist-AutoLogin-Plus` (`Stormhand-dev/Minimalist-AutoLogin-Plus`, a GlueXML addon).
//!
//! **Why native.** That mod is Lua under `Interface\GlueXML\`, and this client runs no glue Lua
//! at all: the login, character-select and character-create screens are Rust (`login/`,
//! `char_select/`, `char_create/`), and the only glue files read are read as *data* — text, not
//! script (`glue_strings.rs`, `challenges.rs`). There is no glue VM, no `GlueXML.toc` loader and
//! no glue widget surface for an addon to build frames against, so the shipped `AutoLogin.lua`
//! is inert however it is installed. Its behaviour is rebuilt here instead, the same way the
//! injected DLLs' behaviour is rebuilt rather than loaded (`docs/ADDON_COMPAT.md`).
//!
//! **What is built**: the account and password are remembered and refilled, the character last
//! played on a realm is re-selected by name, and the character screen's reorder toggle with its
//! per-row arrows.
//!
//! **Deliberately not automatic.** The mod submits the login form and enters the world for you;
//! this fills the password box and stops. Both of those were built and then cut on the player's
//! own call — an auto-login that lands you in the world is a client you cannot get a screen out
//! of without knowing a trick, and the saving is one click. Filling the box is the part that
//! removes the typing.
//!
//! **Not built**: the multi-account manager (the account button column, labels,
//! autocomplete-by-letter, add/remove/reorder of accounts) — one account is what this store
//! holds, and the UI for the rest would be a second screen's worth of work. The mod's own
//! `ImportFile`/`ExportFile` persistence is SuperWoW's and also unbuilt; this writes its own
//! file.
//!
//! **The password is stored in plaintext**, which is the mod's behaviour and its own README's
//! warning. [`local_state::write_atomic_private`] keeps the file `0600`, so it is protected from
//! other users on the machine and from nothing else: anything running as this user can read it,
//! and it lands in any backup of the folder. Nothing is stored until the Remember Password box
//! is ticked, and unticking it clears what was stored, so a player who never opts in never has
//! a secret on disk.
use std::collections::BTreeMap;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use crate::local_state;
/// The auto-login store, `benilla-config/autologin.toml`.
///
/// Loaded once at startup and written back whenever a field it owns changes. A missing or
/// unparseable file is [`Default`], never an error that blocks the screen: the login screen has
/// to come up even if this file is corrupt, since it is the only way to fix it.
#[derive(Resource, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct AutoLogin {
/// Whether the password is kept and refilled. The screen's own Remember Password box.
pub(crate) enabled: bool,
/// The account name. Shared in spirit with `benilla-config/account` (the stock Remember
/// Account Name box) but kept separately: that file is the reference's `accountName` and is
/// written by the stock checkbox, and tying the two would make unticking one clear the other.
pub(crate) account: String,
/// **Plaintext.** Empty means no auto-login, whatever `enabled` says — see [`Self::armed`].
pub(crate) password: String,
/// Per-realm memory, keyed by the realm's name as the realm list gives it.
pub(crate) realms: BTreeMap<String, RealmMemory>,
}
/// What the character screen remembers for one realm.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct RealmMemory {
/// The character last entered the world as, by name; the row auto-entry picks.
pub(crate) last_character: Option<String>,
/// The player's own row order, by character name. Names no longer on the account are
/// ignored at apply time rather than pruned here, so a character deleted on another machine
/// (or a realm temporarily returning a short roster) does not silently lose the whole order.
pub(crate) order: Vec<String>,
/// Whether the reorder arrows are hidden. The mod's `char_buttons_locked`, and locked is its
/// default: the arrows sit on top of the row art, so they are opt-in.
pub(crate) characters_locked: bool,
}
/// Locked is the resting state — the arrows sit over the row art, so they are opt-in — and a
/// derived `Default` would give `false`, so the impl is written out.
impl Default for RealmMemory {
fn default() -> Self {
Self {
last_character: None,
order: Vec::new(),
characters_locked: true,
}
}
}
impl AutoLogin {
/// The store at [`local_state::autologin_path`]; defaults when absent, unreadable or
/// malformed. A parse failure is warned about once rather than swallowed, because the file
/// is hand-editable and a typo would otherwise look like the feature silently turning off.
pub(crate) fn load() -> Self {
let Some(path) = local_state::autologin_path() else {
return Self::default();
};
let Ok(text) = std::fs::read_to_string(&path) else {
return Self::default();
};
match toml::from_str(&text) {
Ok(v) => v,
Err(e) => {
warn!(
"autologin: {} is not valid TOML ({e}) — ignoring it",
path.display()
);
Self::default()
}
}
}
/// Write the store back, `0600`. Failure is warned about and dropped: losing the memory of
/// which character was last played must never take the login screen down with it.
pub(crate) fn save(&self) {
let Some(path) = local_state::autologin_path() else {
return;
};
// Nothing worth keeping and no file to clear: don't create one just to hold defaults.
if *self == Self::default() && !path.exists() {
return;
}
match toml::to_string_pretty(self) {
Ok(text) => {
if let Err(e) = local_state::write_atomic_private(&path, &text) {
warn!("autologin: saving {} failed: {e}", path.display());
}
}
Err(e) => warn!("autologin: serializing failed: {e}"),
}
}
/// The realm's memory, inserted empty on first use.
pub(crate) fn realm_mut(&mut self, realm: &str) -> &mut RealmMemory {
self.realms.entry(realm.to_string()).or_default()
}
/// The realm's memory if it has any; no entry is created by a read.
pub(crate) fn realm(&self, realm: &str) -> Option<&RealmMemory> {
self.realms.get(realm)
}
}
/// Order `names` by the player's saved row order.
///
/// The contract, which is what the tests pin: every name in `names` comes out exactly once.
/// Saved names are taken first, in their saved order, and anything the order does not mention
/// keeps its server order on the end — so a character created since the order was saved appears
/// at the bottom rather than vanishing, and a name in the order that is no longer on the account
/// is skipped rather than leaving a hole.
pub(crate) fn apply_order(order: &[String], names: &[String]) -> Vec<usize> {
let mut out: Vec<usize> = Vec::with_capacity(names.len());
for want in order {
if let Some(i) = names.iter().position(|n| n.eq_ignore_ascii_case(want)) {
if !out.contains(&i) {
out.push(i);
}
}
}
for i in 0..names.len() {
if !out.contains(&i) {
out.push(i);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn names(v: &[&str]) -> Vec<String> {
v.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn an_empty_order_keeps_the_servers_own_order() {
let n = names(&["Aaa", "Bbb", "Ccc"]);
assert_eq!(apply_order(&[], &n), vec![0, 1, 2]);
}
#[test]
fn a_saved_order_is_applied() {
let n = names(&["Aaa", "Bbb", "Ccc"]);
assert_eq!(
apply_order(&names(&["Ccc", "Aaa", "Bbb"]), &n),
vec![2, 0, 1]
);
}
/// A character made on another machine since the order was saved: it must still be shown.
#[test]
fn a_character_the_order_does_not_mention_lands_at_the_bottom() {
let n = names(&["Aaa", "Bbb", "Ccc"]);
assert_eq!(apply_order(&names(&["Ccc"]), &n), vec![2, 0, 1]);
}
/// A character deleted elsewhere: the order skips it rather than leaving a hole or panicking.
#[test]
fn an_order_naming_a_character_that_is_gone_skips_it() {
let n = names(&["Aaa", "Bbb"]);
assert_eq!(apply_order(&names(&["Zzz", "Bbb", "Aaa"]), &n), vec![1, 0]);
}
/// The property the screen depends on: a permutation, never a drop and never a duplicate.
#[test]
fn every_character_comes_out_exactly_once() {
let n = names(&["Aaa", "Bbb", "Ccc", "Ddd"]);
for order in [
vec![],
names(&["Ddd"]),
names(&["Ddd", "Ccc", "Bbb", "Aaa"]),
names(&["Zzz", "Zzz", "Bbb"]),
// A duplicate in the file must not duplicate the row.
names(&["Aaa", "Aaa", "Ccc"]),
] {
let got = apply_order(&order, &n);
let mut sorted = got.clone();
sorted.sort_unstable();
assert_eq!(sorted, vec![0, 1, 2, 3], "order {order:?} gave {got:?}");
}
}
/// The file is hand-editable, so it has to survive a round trip unchanged.
#[test]
fn the_store_round_trips_through_toml() {
let mut v = AutoLogin {
enabled: true,
account: "ACCT".into(),
password: "pw".into(),
..Default::default()
};
let m = v.realm_mut("C'Thun");
m.last_character = Some("Testermcgeee".into());
m.order = names(&["Testermcgeee", "Tesetertoo"]);
m.characters_locked = false;
let text = toml::to_string_pretty(&v).unwrap();
assert_eq!(toml::from_str::<AutoLogin>(&text).unwrap(), v);
assert!(
text.contains("C'Thun"),
"a realm name with an apostrophe has to survive its own quoting: {text}"
);
}
/// A field benilla does not know is a typo or a newer build's key; either way it must not be
/// silently dropped on the next save, which `deny_unknown_fields` turns into a load warning.
#[test]
fn an_unreadable_file_falls_back_to_defaults_rather_than_failing() {
assert!(toml::from_str::<AutoLogin>("this is not toml").is_err());
assert_eq!(
toml::from_str::<AutoLogin>("").unwrap(),
AutoLogin::default(),
"an empty file is the same as no file"
);
}
}
File diff suppressed because it is too large Load Diff
+248 -324
View File
@@ -4,37 +4,15 @@
//!
//! Prefix order is ALT-CTRL-SHIFT (`Blizzard_BindingUI.lua:176-182`; the emitter `0x4b6630` walks
//! the table at `0x846bd0`), and it decides which modifier the fallback drops. Super/Cmd is not a
//! 1.12 modifier, so a chord never carries it; that a press with it held runs nothing is
//! `crate::bindings`'s deviation.
use std::fmt;
//! 1.12 modifier: a chord never carries it and a press with it held never matches.
use bevy::input::mouse::MouseButton;
use bevy::prelude::KeyCode;
use super::layout::{LayoutName, LayoutNames};
/// A key's 1.12 name: one character (`Z`, `1`, `ù`), or a word from the reference's name table
/// (`F1`, `NUMPAD7`, `SPACE`).
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) enum KeyName {
Char(char),
Word(&'static str),
}
impl fmt::Display for KeyName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KeyName::Char(c) => write!(f, "{c}"),
KeyName::Word(w) => f.write_str(w),
}
}
}
/// A bindable base input: a keyboard key by its 1.12 name, a mouse button, or one wheel direction.
/// A bindable base input: a keyboard key, a mouse button, or one wheel direction.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) enum BindKey {
Key(KeyName),
Key(KeyCode),
Mouse(MouseButton),
WheelUp,
WheelDown,
@@ -100,148 +78,186 @@ impl Chord {
}
}
/// The 1.12 name a key press gets: a letter, digit or punctuation key what the active layout
/// names it ([`LayoutNames`]), every other key its fixed name. `None` for a key the reference
/// names `UNKNOWN` or drops, and for the modifiers, which are only prefixes
/// (`IsKeyPressIgnoredForBinding`). Both Enters are `ENTER`.
pub(crate) fn key_token(k: KeyCode, layout: &LayoutNames) -> Option<KeyName> {
match layout.name(k) {
// The lookup folds `a`-`z` up (`0x64b419`), so Turkish `VK_OEM_7`'s `i` is `I`'s key.
Some(LayoutName::Char(c)) => Some(KeyName::Char(c.to_ascii_uppercase())),
Some(LayoutName::Dropped) => None,
None => fixed_name(k),
/// Build the canonical chord string from live modifier state and a base token.
pub(crate) fn chord_string(alt: bool, ctrl: bool, shift: bool, token: &str) -> String {
let mut s = String::new();
if alt {
s.push_str("ALT-");
}
if ctrl {
s.push_str("CTRL-");
}
if shift {
s.push_str("SHIFT-");
}
s.push_str(token);
s
}
/// The name the key-down walk's `arg1` carries for a press (`0x76b7d0`), `None` for a key the
/// client drops before any walk: the modifiers go by their own names (`0`, `1`, `2` of the table,
/// `SHIFT`, `CTRL`, `ALT` at `0x846e80`, `0x846e88`, `0x846e90`), and `UNKNOWN` is the name of the
/// codes the table cannot name, `0x210` and `0x211` (ScrollLock and Pause, and F14 and F15 on a
/// Mac). Every other key without a name never reaches the walk: the Windows key table drops it
/// where `MapVirtualKeyA` answers 0 (`0x42da39`: the Windows and Menu keys, media keys, a key the
/// layout drops), the Mac table `0x5bf320` has -1 for Command and fn. Unlike [`key_token`], which
/// is a binding's, a modifier still gets a down and an up.
pub(crate) fn walk_name(k: KeyCode, layout: &LayoutNames) -> Option<String> {
if let Some(name) = key_token(k, layout) {
return Some(name.to_string());
}
Some(
match k {
KeyCode::ShiftLeft | KeyCode::ShiftRight => "SHIFT",
KeyCode::ControlLeft | KeyCode::ControlRight => "CTRL",
KeyCode::AltLeft | KeyCode::AltRight => "ALT",
KeyCode::ScrollLock | KeyCode::Pause => "UNKNOWN",
#[cfg(target_os = "macos")]
KeyCode::F14 | KeyCode::F15 => "UNKNOWN",
_ => return None,
}
.to_string(),
)
}
/// The fixed half of `0x42d800` and the Mac table: every key no layout names
/// ([`super::layout::position`]).
fn fixed_name(k: KeyCode) -> Option<KeyName> {
/// The 1.12 token for a physical key; `None` for a key the reference names `UNKNOWN` and for the
/// modifiers, which are only prefixes (`IsKeyPressIgnoredForBinding`). Both Enters are `ENTER`.
pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
use KeyCode::*;
use KeyName::Word;
Some(match k {
F1 => Word("F1"),
F2 => Word("F2"),
F3 => Word("F3"),
F4 => Word("F4"),
F5 => Word("F5"),
F6 => Word("F6"),
F7 => Word("F7"),
F8 => Word("F8"),
F9 => Word("F9"),
F10 => Word("F10"),
F11 => Word("F11"),
F12 => Word("F12"),
KeyA => "A",
KeyB => "B",
KeyC => "C",
KeyD => "D",
KeyE => "E",
KeyF => "F",
KeyG => "G",
KeyH => "H",
KeyI => "I",
KeyJ => "J",
KeyK => "K",
KeyL => "L",
KeyM => "M",
KeyN => "N",
KeyO => "O",
KeyP => "P",
KeyQ => "Q",
KeyR => "R",
KeyS => "S",
KeyT => "T",
KeyU => "U",
KeyV => "V",
KeyW => "W",
KeyX => "X",
KeyY => "Y",
KeyZ => "Z",
Digit1 => "1",
Digit2 => "2",
Digit3 => "3",
Digit4 => "4",
Digit5 => "5",
Digit6 => "6",
Digit7 => "7",
Digit8 => "8",
Digit9 => "9",
Digit0 => "0",
F1 => "F1",
F2 => "F2",
F3 => "F3",
F4 => "F4",
F5 => "F5",
F6 => "F6",
F7 => "F7",
F8 => "F8",
F9 => "F9",
F10 => "F10",
F11 => "F11",
F12 => "F12",
// On a Mac F13 is print-screen: the Mac key table `0x5bf320` maps keycode `0x69` to
// `0x212`, `PRINTSCREEN`, and macOS has no PrintScreen keycode.
#[cfg(target_os = "macos")]
F13 => Word("PRINTSCREEN"),
F13 => "PRINTSCREEN",
// `IsValidBindingKeyString` accepts `F` + any digits (`0x846c04`) and the Mac table maps
// keycode `0x6A` to `F16` (`0x30d`). Deviation: the Windows key table stops at `VK_F12`
// and drops later F-keys; they are named here so a stored `F13`-`F24` can be pressed.
#[cfg(not(target_os = "macos"))]
F13 => Word("F13"),
F13 => "F13",
// On a Mac F14 and F15 are ScrollLock and Pause: keycodes `0x6B` and `0x71` map to
// `0x210` and `0x211`, which the namer calls `UNKNOWN`, so they stay unbindable there.
#[cfg(not(target_os = "macos"))]
F14 => Word("F14"),
F14 => "F14",
#[cfg(not(target_os = "macos"))]
F15 => Word("F15"),
F16 => Word("F16"),
F17 => Word("F17"),
F18 => Word("F18"),
F19 => Word("F19"),
F20 => Word("F20"),
F21 => Word("F21"),
F22 => Word("F22"),
F23 => Word("F23"),
F24 => Word("F24"),
Space => Word("SPACE"),
Tab => Word("TAB"),
Enter | NumpadEnter => Word("ENTER"),
Escape => Word("ESCAPE"),
Backspace => Word("BACKSPACE"),
Insert => Word("INSERT"),
Delete => Word("DELETE"),
Home => Word("HOME"),
End => Word("END"),
PageUp => Word("PAGEUP"),
PageDown => Word("PAGEDOWN"),
ArrowUp => Word("UP"),
ArrowDown => Word("DOWN"),
ArrowLeft => Word("LEFT"),
ArrowRight => Word("RIGHT"),
Numpad0 => Word("NUMPAD0"),
Numpad1 => Word("NUMPAD1"),
Numpad2 => Word("NUMPAD2"),
Numpad3 => Word("NUMPAD3"),
Numpad4 => Word("NUMPAD4"),
Numpad5 => Word("NUMPAD5"),
Numpad6 => Word("NUMPAD6"),
Numpad7 => Word("NUMPAD7"),
Numpad8 => Word("NUMPAD8"),
Numpad9 => Word("NUMPAD9"),
NumpadAdd => Word("NUMPADPLUS"),
NumpadSubtract => Word("NUMPADMINUS"),
NumpadDivide => Word("NUMPADDIVIDE"),
NumpadMultiply => Word("NUMPADMULTIPLY"),
NumpadDecimal => Word("NUMPADDECIMAL"),
F15 => "F15",
F16 => "F16",
F17 => "F17",
F18 => "F18",
F19 => "F19",
F20 => "F20",
F21 => "F21",
F22 => "F22",
F23 => "F23",
F24 => "F24",
Space => "SPACE",
Tab => "TAB",
Enter | NumpadEnter => "ENTER",
Escape => "ESCAPE",
Backspace => "BACKSPACE",
Insert => "INSERT",
Delete => "DELETE",
Home => "HOME",
End => "END",
PageUp => "PAGEUP",
PageDown => "PAGEDOWN",
ArrowUp => "UP",
ArrowDown => "DOWN",
ArrowLeft => "LEFT",
ArrowRight => "RIGHT",
Numpad0 => "NUMPAD0",
Numpad1 => "NUMPAD1",
Numpad2 => "NUMPAD2",
Numpad3 => "NUMPAD3",
Numpad4 => "NUMPAD4",
Numpad5 => "NUMPAD5",
Numpad6 => "NUMPAD6",
Numpad7 => "NUMPAD7",
Numpad8 => "NUMPAD8",
Numpad9 => "NUMPAD9",
NumpadAdd => "NUMPADPLUS",
NumpadSubtract => "NUMPADMINUS",
NumpadDivide => "NUMPADDIVIDE",
NumpadMultiply => "NUMPADMULTIPLY",
NumpadDecimal => "NUMPADDECIMAL",
// The Mac keypad's `=`: the namer's `0x30c`, and in `IsValidBindingKeyString`'s table.
NumpadEqual => Word("NUMPADEQUALS"),
NumLock => Word("NUMLOCK"),
PrintScreen => Word("PRINTSCREEN"),
NumpadEqual => "NUMPADEQUALS",
NumLock => "NUMLOCK",
PrintScreen => "PRINTSCREEN",
// No ScrollLock or Pause: the namer (`0x4b66b0`) calls `0x210`/`0x211` `UNKNOWN`, and
// `IsValidBindingKeyString` (`0x4b7890`) has neither name.
CapsLock => Word("CAPSLOCK"),
CapsLock => "CAPSLOCK",
Minus => "-",
Equal => "=",
BracketLeft => "[",
BracketRight => "]",
Backslash => "\\",
Semicolon => ";",
Quote => "'",
Comma => ",",
Period => ".",
Slash => "/",
Backquote => "`",
_ => return None,
})
}
/// Fold the keys the reference gives one code (`NumpadEnter` → `Enter`, both `0x201`), so the
/// pressed-key list and a latch treat them as one key.
/// Fold key aliases that share one 1.12 token (`NumpadEnter` → `Enter`) so a chord parsed from
/// `ENTER` matches either physical key.
pub(crate) fn normalize_key(k: KeyCode) -> KeyCode {
match k {
KeyCode::NumpadEnter => KeyCode::Enter,
// The Mac print-screen key; must agree with `key_token`, which names it for capture.
#[cfg(target_os = "macos")]
KeyCode::F13 => KeyCode::PrintScreen,
other => other,
}
}
/// `BUTTON6` to `BUTTON20`, indexed by `Other(n) - 5`: the reference names further buttons
/// `BUTTON<n>` (`0x4b6aa0`'s bit-scan fallback), and winit passes the platform's button number, so
/// the sixth button is `Other(5)`.
/// The 1.12 token for a mouse button: BUTTON4 is winit's `Forward` and BUTTON5 its `Back`, so the
/// 1.12 default `BUTTON4 TOGGLEAUTORUN` sits on `Forward`. Which physical button the reference
/// calls BUTTON4 is untraced.
pub(crate) fn mouse_token(b: MouseButton) -> Option<&'static str> {
Some(match b {
MouseButton::Left => "BUTTON1",
MouseButton::Right => "BUTTON2",
MouseButton::Middle => "BUTTON3",
MouseButton::Forward => "BUTTON4",
MouseButton::Back => "BUTTON5",
// The reference names further buttons `BUTTON<n>` (`0x4b6aa0`'s bit-scan fallback).
// winit passes the platform's button number, so the sixth button is `Other(5)`.
MouseButton::Other(n) => return EXTRA_BUTTONS.get(usize::from(n).wrapping_sub(5)).copied(),
})
}
/// `BUTTON6` to `BUTTON20`, indexed by `Other(n) - 5`.
const EXTRA_BUTTONS: &[&str] = &[
"BUTTON6", "BUTTON7", "BUTTON8", "BUTTON9", "BUTTON10", "BUTTON11", "BUTTON12", "BUTTON13",
"BUTTON14", "BUTTON15", "BUTTON16", "BUTTON17", "BUTTON18", "BUTTON19", "BUTTON20",
];
/// Token to base input: the inverse of [`key_token`], the mouse buttons and the wheel pair.
/// BUTTON4 is winit's `Forward` and BUTTON5 its `Back`, so the 1.12 default `BUTTON4
/// TOGGLEAUTORUN` sits on `Forward`; which physical button the reference calls BUTTON4 is untraced.
/// Token to base input: the inverse of [`key_token`] and [`mouse_token`], plus the wheel pair.
fn token_key(t: &str) -> Option<BindKey> {
use KeyCode::*;
if let Some(b) = match t {
@@ -263,15 +279,43 @@ fn token_key(t: &str) -> Option<BindKey> {
"MOUSEWHEELDOWN" => return Some(BindKey::WheelDown),
_ => {}
}
// One character: what a layout names some key, which the namer writes as UTF-8 (`0x4b66b0`'s
// `[0x21, 0xff]` arm through `0x41abb0`). [`key_token`] folds `a`-`z` up as the lookup does,
// so a lowercase letter is no press's name.
let mut chars = t.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
let pressable = !c.is_control() && !c.is_whitespace() && !c.is_ascii_lowercase();
return pressable.then_some(BindKey::Key(KeyName::Char(c)));
}
let k = match t {
"A" => KeyA,
"B" => KeyB,
"C" => KeyC,
"D" => KeyD,
"E" => KeyE,
"F" => KeyF,
"G" => KeyG,
"H" => KeyH,
"I" => KeyI,
"J" => KeyJ,
"K" => KeyK,
"L" => KeyL,
"M" => KeyM,
"N" => KeyN,
"O" => KeyO,
"P" => KeyP,
"Q" => KeyQ,
"R" => KeyR,
"S" => KeyS,
"T" => KeyT,
"U" => KeyU,
"V" => KeyV,
"W" => KeyW,
"X" => KeyX,
"Y" => KeyY,
"Z" => KeyZ,
"1" => Digit1,
"2" => Digit2,
"3" => Digit3,
"4" => Digit4,
"5" => Digit5,
"6" => Digit6,
"7" => Digit7,
"8" => Digit8,
"9" => Digit9,
"0" => Digit0,
"F1" => F1,
"F2" => F2,
"F3" => F3,
@@ -330,45 +374,47 @@ fn token_key(t: &str) -> Option<BindKey> {
"NUMLOCK" => NumLock,
"PRINTSCREEN" => PrintScreen,
"CAPSLOCK" => CapsLock,
"-" => Minus,
"=" => Equal,
"[" => BracketLeft,
"]" => BracketRight,
"\\" => Backslash,
";" => Semicolon,
"'" => Quote,
"," => Comma,
"." => Period,
"/" => Slash,
"`" => Backquote,
_ => return None,
};
// Only a name the namer gives on this platform: on a Mac, F13-F15 are other keys.
match fixed_name(k) {
Some(name @ KeyName::Word(w)) if w == t => Some(BindKey::Key(name)),
_ => None,
// Fold aliases so a parsed chord equals a normalized press, and accept only a token the
// namer produces on this platform (on a Mac, F13-F15 are other keys).
let k = normalize_key(k);
if key_token(k) != Some(t) {
return None;
}
Some(BindKey::Key(k))
}
#[cfg(test)]
mod tests {
use super::*;
/// Ties this namer to the engine's `IsValidBindingKeyString`: every chord the install's
/// `WTF\DefaultBindings.wtf` ships is a key string it accepts and a chord the dispatcher can
/// press, and one key of each token shape, since `KeyCode` cannot be enumerated.
/// Ties this namer to the engine's `IsValidBindingKeyString`: every default chord, and one key
/// of each token shape, since `KeyCode` cannot be enumerated.
#[test]
fn every_token_the_codec_names_is_one_setbinding_accepts() {
use benilla_ui::script::keybind::normalize_binding_key;
let defaults = crate::ui_script::default_bindings();
if benilla_formats::wow_data().is_some() {
assert!(
defaults.len() > 100,
"the install's defaults: {}",
defaults.len()
);
for spec in super::super::commands::SPECS {
for default in [spec.d1, spec.d2].into_iter().flatten() {
assert_eq!(
normalize_binding_key(default).as_deref(),
Some(default),
"the default chord '{default}' ({}) is not a bindable key string",
spec.name
);
}
}
for (default, command) in &defaults {
assert_eq!(
normalize_binding_key(default).as_deref(),
Some(default.as_str()),
"the default chord '{default}' ({command}) is not a bindable key string"
);
assert!(
Chord::parse(default).is_some(),
"{command}: default '{default}' does not parse"
);
}
let us = LayoutNames::default();
for k in [
KeyCode::KeyW,
KeyCode::Digit0,
@@ -403,24 +449,28 @@ mod tests {
KeyCode::Slash,
KeyCode::Backquote,
] {
let token = key_token(k, &us).expect("named").to_string();
let token = key_token(k).expect("named");
assert!(
normalize_binding_key(&token).is_some(),
normalize_binding_key(token).is_some(),
"{k:?} names '{token}', which SetBinding refuses"
);
}
for token in [
"BUTTON1", "BUTTON2", "BUTTON3", "BUTTON4", "BUTTON5", "BUTTON20",
for b in [
MouseButton::Left,
MouseButton::Right,
MouseButton::Middle,
MouseButton::Forward,
MouseButton::Back,
] {
assert!(token_key(token).is_some(), "'{token}' names no button");
assert!(normalize_binding_key(token).is_some(), "'{token}'");
let token = mouse_token(b).expect("named");
assert!(normalize_binding_key(token).is_some(), "{b:?} → '{token}'");
}
for token in ["MOUSEWHEELUP", "MOUSEWHEELDOWN"] {
assert!(normalize_binding_key(token).is_some());
}
// The reference's namer calls these `UNKNOWN` (`0x210`/`0x211`).
assert_eq!(key_token(KeyCode::ScrollLock, &us), None);
assert_eq!(key_token(KeyCode::Pause, &us), None);
assert_eq!(key_token(KeyCode::ScrollLock), None);
assert_eq!(key_token(KeyCode::Pause), None);
}
/// The validator's accept set is infinite, so this covers the tokens a real keyboard or mouse
@@ -489,7 +539,6 @@ mod tests {
#[test]
fn the_codec_round_trips_every_keyboard_token() {
let us = LayoutNames::default();
let mut checked = 0;
for k in [
KeyCode::KeyW,
@@ -503,33 +552,38 @@ mod tests {
KeyCode::Backquote,
KeyCode::Quote,
] {
let name = key_token(k, &us).unwrap();
let t = key_token(k).unwrap();
assert_eq!(
token_key(&name.to_string()),
Some(BindKey::Key(name)),
"token {name}"
token_key(t),
Some(BindKey::Key(normalize_key(k))),
"token {t}"
);
checked += 1;
}
assert_eq!(checked, 10);
let enter = Some(KeyName::Word("ENTER"));
assert_eq!(key_token(KeyCode::NumpadEnter, &us), enter);
assert_eq!(key_token(KeyCode::Enter, &us), enter);
assert_eq!(token_key("ENTER"), enter.map(BindKey::Key));
assert_eq!(key_token(KeyCode::NumpadEnter), Some("ENTER"));
assert_eq!(token_key("ENTER"), Some(BindKey::Key(KeyCode::Enter)));
}
/// macOS has no `PrintScreen`, so capture and dispatch must both read F13 as `PRINTSCREEN`.
#[cfg(target_os = "macos")]
#[test]
fn f13_is_print_screen_on_a_mac() {
let name = key_token(KeyCode::F13, &LayoutNames::default());
assert_eq!(name, Some(KeyName::Word("PRINTSCREEN")));
assert_eq!(
key_token(KeyCode::F13),
Some("PRINTSCREEN"),
"the capture arm"
);
assert_eq!(
normalize_key(KeyCode::F13),
KeyCode::PrintScreen,
"the dispatch arm"
);
assert_eq!(
token_key("PRINTSCREEN"),
name.map(BindKey::Key),
Some(BindKey::Key(normalize_key(KeyCode::F13))),
"a chord parsed from the shipped default matches an F13 press"
);
assert_eq!(token_key("F13"), None, "no Mac key is F13");
}
#[test]
@@ -540,7 +594,7 @@ mod tests {
alt: true,
ctrl: true,
shift: true,
key: BindKey::Key(KeyName::Word("F1"))
key: BindKey::Key(KeyCode::F1)
})
);
// `CTRL--` is Ctrl + the minus key.
@@ -550,7 +604,7 @@ mod tests {
alt: false,
ctrl: true,
shift: false,
key: BindKey::Key(KeyName::Char('-'))
key: BindKey::Key(KeyCode::Minus)
})
);
assert_eq!(
@@ -572,148 +626,18 @@ mod tests {
})
);
assert_eq!(Chord::parse("BOGUS"), None);
assert_eq!(
chord_string(true, false, true, "PAGEDOWN"),
"ALT-SHIFT-PAGEDOWN"
);
assert_eq!(
Chord::parse("ALT-SHIFT-PAGEDOWN").unwrap(),
Chord {
alt: true,
ctrl: false,
shift: true,
key: BindKey::Key(KeyName::Word("PAGEDOWN"))
key: BindKey::Key(KeyCode::PageDown)
}
);
}
/// The names a layout gives its keys, as `layout::record_layout_names` keeps them.
fn layout(keys: &[(KeyCode, char)]) -> LayoutNames {
let mut names = LayoutNames::default();
for &(k, c) in keys {
names.set(k, Some(LayoutName::Char(c)));
}
names
}
fn name(k: KeyCode, layout: &LayoutNames) -> Option<String> {
key_token(k, layout).map(|n| n.to_string())
}
/// 1.12 names a letter, digit or punctuation key what the active layout names it, its ASCII
/// letters folded up as the lookup folds them, and every other key by the fixed table. AZERTY
/// on Windows: `VK_Z` where a US W sits, `VK_M` on the semicolon key.
#[test]
fn a_layout_key_is_named_what_its_layout_names_it() {
use KeyCode::*;
let mut azerty = layout(&[
(KeyW, 'Z'),
(KeyZ, 'W'),
(KeyQ, 'A'),
(KeyA, 'Q'),
(Semicolon, 'M'),
(KeyM, ','),
(Comma, ';'),
(Period, ':'),
(Slash, '!'),
(Quote, 'ù'),
(Backquote, '²'),
(Minus, ')'),
(BracketLeft, '^'),
(BracketRight, '$'),
(Backslash, '*'),
(IntlBackslash, '<'),
(NumpadComma, '.'),
(Digit1, '1'),
]);
// Turkish Q's `VK_OEM_7` names its key `i`, which the lookup folds to `I`.
azerty.set(KeyCode::KeyI, Some(LayoutName::Char('i')));
for (k, expected) in [
(KeyW, "Z"),
(KeyZ, "W"),
(KeyQ, "A"),
(KeyA, "Q"),
(Semicolon, "M"),
(KeyM, ","),
(Comma, ";"),
(Period, ":"),
(Slash, "!"),
(Quote, "ù"),
(Backquote, "²"),
(Minus, ")"),
(BracketLeft, "^"),
(BracketRight, "$"),
(Backslash, "*"),
(IntlBackslash, "<"),
(NumpadComma, "."),
(Digit1, "1"),
(KeyI, "I"),
(F1, "F1"),
(Numpad7, "NUMPAD7"),
(Space, "SPACE"),
] {
assert_eq!(name(k, &azerty).as_deref(), Some(expected), "{k:?}");
let token = key_token(k, &azerty).unwrap();
assert_eq!(
token_key(expected),
Some(BindKey::Key(token)),
"the stored '{expected}' is that key's chord"
);
}
// A key its layout drops binds nothing (`0x42da49`).
azerty.set(Backquote, Some(LayoutName::Dropped));
assert_eq!(name(Backquote, &azerty), None);
}
/// A US layout, reported or not, names every key as it always has.
#[test]
fn a_us_layout_names_its_keys_as_the_us_positions() {
use KeyCode::*;
let reported = layout(&[
(KeyW, 'w'),
(KeyZ, 'z'),
(Quote, '\''),
(Backquote, '`'),
(Minus, '-'),
(IntlBackslash, '\\'),
]);
let unreported = LayoutNames::default();
for layout in [&reported, &unreported] {
for (k, expected) in [
(KeyW, "W"),
(KeyZ, "Z"),
(Quote, "'"),
(Backquote, "`"),
(Minus, "-"),
(Digit1, "1"),
] {
assert_eq!(name(k, layout).as_deref(), Some(expected), "{k:?}");
}
}
assert_eq!(name(IntlBackslash, &reported).as_deref(), Some("\\"));
assert_eq!(
name(IntlBackslash, &unreported),
None,
"an ISO key no layout has named yet"
);
}
/// Modifiers are prefixes, never part of the name: Shift+1 is `SHIFT-1`, not `!`.
#[test]
fn a_chord_names_its_key_unshifted() {
let azerty = layout(&[(KeyCode::KeyW, 'z')]);
let key = |k| BindKey::Key(key_token(k, &azerty).unwrap());
assert_eq!(
Chord::parse("SHIFT-Z").map(|c| (c.shift, c.key)),
Some((true, key(KeyCode::KeyW)))
);
assert_eq!(
Chord::parse("SHIFT-1").map(|c| (c.shift, c.key)),
Some((true, key(KeyCode::Digit1)))
);
// Nothing presses a lowercase letter, a space or a control character.
for t in ["w", " ", "\u{7}"] {
assert_eq!(Chord::parse(t), None, "{t:?}");
}
assert_eq!(
Chord::parse("ù").map(|c| c.key),
Some(BindKey::Key(KeyName::Char('ù')))
);
}
}
File diff suppressed because it is too large Load Diff
-668
View File
@@ -1,668 +0,0 @@
//! What the active keyboard layout names each letter, digit and punctuation key, which 1.12 binds
//! by. The Windows client names a key from the virtual-key code the layout produced: the
//! translator `0x42d800` keeps `VK_0`-`VK_9` as themselves (`0x42d81c`) and sends every other key
//! outside its fixed table, `VK_A`-`VK_Z` and `VK_OEM_*` among them, to
//! `MapVirtualKeyA(vk, MAPVK_VK_TO_CHAR)` (`0x42da39`), which returns `VK_A`-`VK_Z`'s own letter
//! whatever the layout types there. The Mac client fills its letter and punctuation keys from the
//! live layout, `KeyTranslate` (`0x8a3c3`) on the current `KCHR` (re-read when it changes,
//! `0x8a345`), and fixes its digits by position (`0x5bf320`). Bevy's `KeyboardInput` carries the
//! physical key and a `logical_key` with Shift applied, so each platform's name is read off the
//! raw winit key messages here, before any `Update` reader.
use std::collections::HashMap;
use bevy::input::keyboard::KeyCode;
use bevy::prelude::*;
/// A layout-named key's place on a US keyboard, with the US name there.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Position {
Letter(char),
Digit(char),
/// The ISO and JIS extra keys and the keypad comma have no US name.
Punctuation(Option<char>),
}
impl Position {
fn us(self) -> Option<char> {
match self {
Position::Letter(c) | Position::Digit(c) => Some(c),
Position::Punctuation(c) => c,
}
}
}
/// The keys a layout names: the letters, the digits, the punctuation, the ISO and JIS extras and
/// the keypad comma. Windows sends `VK_OEM_102`, `VK_ABNT_C2` and `VK_SEPARATOR` to the layout arm
/// like any `VK_OEM_*` key, and the Mac table leaves the ISO key `0x0A` and the keypad comma
/// `0x5F` to `KeyTranslate` (`-1` at `0x5bf320`). winit 0.30 cannot deliver those on a Mac, as on
/// main: it reports `0x0A` as `Backquote` (whose scancode it gives back as `0x32`) and leaves
/// `0x5E` and `0x5F` unidentified, so the `IntlBackslash`, `IntlRo` and `NumpadComma` arms never
/// fire there.
pub(crate) fn position(k: KeyCode) -> Option<Position> {
use KeyCode::*;
use Position::{Digit, Letter, Punctuation};
Some(match k {
KeyA => Letter('A'),
KeyB => Letter('B'),
KeyC => Letter('C'),
KeyD => Letter('D'),
KeyE => Letter('E'),
KeyF => Letter('F'),
KeyG => Letter('G'),
KeyH => Letter('H'),
KeyI => Letter('I'),
KeyJ => Letter('J'),
KeyK => Letter('K'),
KeyL => Letter('L'),
KeyM => Letter('M'),
KeyN => Letter('N'),
KeyO => Letter('O'),
KeyP => Letter('P'),
KeyQ => Letter('Q'),
KeyR => Letter('R'),
KeyS => Letter('S'),
KeyT => Letter('T'),
KeyU => Letter('U'),
KeyV => Letter('V'),
KeyW => Letter('W'),
KeyX => Letter('X'),
KeyY => Letter('Y'),
KeyZ => Letter('Z'),
Digit1 => Digit('1'),
Digit2 => Digit('2'),
Digit3 => Digit('3'),
Digit4 => Digit('4'),
Digit5 => Digit('5'),
Digit6 => Digit('6'),
Digit7 => Digit('7'),
Digit8 => Digit('8'),
Digit9 => Digit('9'),
Digit0 => Digit('0'),
Minus => Punctuation(Some('-')),
Equal => Punctuation(Some('=')),
BracketLeft => Punctuation(Some('[')),
BracketRight => Punctuation(Some(']')),
Backslash => Punctuation(Some('\\')),
Semicolon => Punctuation(Some(';')),
Quote => Punctuation(Some('\'')),
Comma => Punctuation(Some(',')),
Period => Punctuation(Some('.')),
Slash => Punctuation(Some('/')),
Backquote => Punctuation(Some('`')),
IntlBackslash | IntlRo | IntlYen | NumpadComma => Punctuation(None),
_ => return None,
})
}
/// A layout-named key's name.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum LayoutName {
Char(char),
/// None: `MapVirtualKeyA` returned 0 and the reference drops the key's message (`0x42da49`).
Dropped,
}
/// Each layout-named key's name under the active layout, as its platform last reported it: an
/// entry changes with the next message of its key after a layout switch, so a press is named under
/// the layout it was typed in. Empty where no winit feeds it (a headless run, a test).
#[derive(Resource, Default, Debug)]
pub(crate) struct LayoutNames(HashMap<KeyCode, LayoutName>);
impl LayoutNames {
/// The name of `key` if a layout names it, `None` for a key the fixed table names. A key never
/// reported (a headless run) keeps its US name.
///
/// Deviation: on the Mac and Linux a key its platform reports no character for keeps its US
/// name too, where the Mac reference drops it (`0x8a4aa`), and a Linux dead key not yet pressed
/// bare stays bindable.
pub(crate) fn name(&self, key: KeyCode) -> Option<LayoutName> {
let position = position(key)?;
Some(match self.0.get(&key) {
Some(name) => *name,
None => position.us().map_or(LayoutName::Dropped, LayoutName::Char),
})
}
/// Set what the layout names `key`; `None` clears it back to the US name.
pub(crate) fn set(&mut self, key: KeyCode, name: Option<LayoutName>) {
match name {
Some(name) => self.0.insert(key, name),
None => self.0.remove(&key),
};
}
}
/// Keeps [`LayoutNames`] current from the raw key messages (every shipped platform).
pub(super) fn plugin(app: &mut App) {
app.init_resource::<LayoutNames>();
// A headless build has no winit to register it.
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
app.add_message::<bevy::winit::RawWinitWindowEvent>()
.add_systems(PreUpdate, record_layout_names);
}
/// Names each key a raw key message reports, by its platform's rule. On the main thread, the
/// window's: Windows reads that thread's layout, and macOS's input-source calls want it.
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
fn record_layout_names(
mut raw: MessageReader<bevy::winit::RawWinitWindowEvent>,
mut names: ResMut<LayoutNames>,
#[cfg(target_os = "linux")] mut mods: Local<LinuxMods>,
_main_thread: bevy::ecs::system::NonSendMarker,
) {
use bevy::winit::converters::convert_physical_key_code;
use winit::event::WindowEvent;
#[cfg(target_os = "macos")]
let mut ascii_capable = None;
for ev in raw.read() {
let event = match &ev.event {
WindowEvent::KeyboardInput { event, .. } => event,
#[cfg(target_os = "linux")]
WindowEvent::ModifiersChanged(m) => {
mods.state = m.state();
continue;
}
_ => continue,
};
#[cfg(target_os = "linux")]
mods.key(event.physical_key, event.state.is_pressed());
let key = convert_physical_key_code(event.physical_key);
let Some(position) = position(key) else {
continue;
};
#[cfg(target_os = "windows")]
{
let _ = position;
names.set(key, windows::name(event.physical_key));
}
// The Mac and Linux keep their digits by position.
#[cfg(target_os = "macos")]
if !matches!(position, Position::Digit(_)) {
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
let capable = *ascii_capable.get_or_insert_with(mac::current_is_ascii_capable);
let current = one_char(&event.key_without_modifiers());
let name = mac_name(capable, current, || {
mac::ascii_capable_char(event.physical_key)
});
names.set(key, name);
}
#[cfg(target_os = "linux")]
if !matches!(position, Position::Digit(_)) {
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
let unshifted = event.key_without_modifiers();
if let Some(name) = linux_name(position, &unshifted, &event.logical_key, mods.bare()) {
names.set(key, name);
}
}
}
}
/// The one printable character a winit key stands for.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
fn one_char(key: &winit::keyboard::Key) -> Option<char> {
let winit::keyboard::Key::Character(s) = key else {
return None;
};
let mut chars = s.chars();
chars
.next()
.filter(|c| chars.next().is_none() && !c.is_control() && !c.is_whitespace())
}
/// The reference's name for the virtual key `vk`, given `MapVirtualKeyExW(vk, MAPVK_VK_TO_CHAR)`;
/// `None` for a key `0x42d800`'s fixed table names.
///
/// Deviation: a character above U+00FF names the key by itself. The reference's `MapVirtualKeyA`
/// returns an ANSI code-page byte, which `0x42da4b` drops past `0xff` and the namer's UTF-8
/// encoder (`0x41abb0`) otherwise reads as Latin-1, so such a key is mislabelled or dropped; named
/// by what it types, it stays bindable and readable.
#[cfg(any(target_os = "windows", test))]
fn windows_name(vk: u32, to_char: u32) -> Option<LayoutName> {
match vk {
// `VK_A`-`VK_Z`, named by their letter, and `VK_0`-`VK_9`, kept as themselves.
0x41..=0x5a | 0x30..=0x39 => Some(LayoutName::Char(char::from(vk as u8))),
_ if fixed_vk(vk) => None,
// The dynamic arm: `and eax,0xffff` (`0x42da42`) drops the dead-key bit, and 0 drops the
// key (`0x42da49`). U+F000-U+F002 are a layout table's `WCH_NONE`, `WCH_DEAD` and
// `WCH_LGTR` markers, no character, should the call ever return one.
_ => Some(match char::from_u32(to_char & 0xffff) {
Some(c)
if !c.is_control()
&& !c.is_whitespace()
&& !('\u{f000}'..='\u{f002}').contains(&c) =>
{
LayoutName::Char(c)
}
_ => LayoutName::Dropped,
}),
}
}
/// `0x42d800`'s fixed table, read from its byte remap at `0x42dafc` and jump table at `0x42da60`:
/// the keys it names itself, never by character. F1-F12 take their own arm (`0x42d800`).
#[cfg(any(target_os = "windows", test))]
fn fixed_vk(vk: u32) -> bool {
matches!(
vk,
0x08 | 0x09
| 0x0d
| 0x10..=0x14
| 0x1b
| 0x20..=0x28
| 0x2c..=0x2e
| 0x60..=0x6b
| 0x6d..=0x7b
| 0x90
| 0x91
)
}
#[cfg(target_os = "windows")]
mod windows {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
GetKeyboardLayout, MapVirtualKeyExW, MAPVK_VK_TO_CHAR, MAPVK_VSC_TO_VK_EX,
};
/// The reference's own path for the key at `physical`: the virtual key the window thread's
/// layout gives its scancode, then that key's name (`super::windows_name`).
pub(super) fn name(physical: winit::keyboard::PhysicalKey) -> Option<super::LayoutName> {
use winit::platform::scancode::PhysicalKeyExtScancode;
let scancode = physical.to_scancode()?;
// SAFETY: plain Win32 calls with no pointers. `GetKeyboardLayout(0)` is the calling
// thread's layout; the caller runs on the main thread, the window's, so it is the layout
// the window's key messages were made under.
let (vk, to_char) = unsafe {
let hkl = GetKeyboardLayout(0);
let vk = MapVirtualKeyExW(scancode, MAPVK_VSC_TO_VK_EX, hkl);
(vk, MapVirtualKeyExW(vk, MAPVK_VK_TO_CHAR, hkl))
};
if vk == 0 {
return None;
}
super::windows_name(vk, to_char)
}
}
/// The Mac name of a letter or punctuation key: the layout's own unshifted character, as
/// `KeyTranslate` gives the reference, or none.
///
/// Deviation: a layout that is not ASCII-capable (Hebrew, Greek, Russian, Arabic, Thai) names its
/// keys through the ASCII-capable layout macOS pairs with it, as macOS names its own shortcuts. The
/// reference reads a non-Roman `KCHR`'s byte as MacRoman (`0x8a40f`), which names no real key.
///
/// Deviation: a character above U+00FF (Czech `ě`, Polish `ł`, Turkish `ş`) names the key by
/// itself. The reference reads `KeyTranslate`'s byte as MacRoman (`0x8a40f`) and keeps it only if
/// it converts to Latin-1 (`CFStringGetBytes`, `0x8a46b`, stored at `0x8a482`), else leaves the
/// slot `-1`, which `0x8a4aa` drops, so such a key is mislabelled or dropped; named by what it
/// types, it stays bindable and readable.
#[cfg(any(target_os = "macos", test))]
fn mac_name(
ascii_capable: bool,
current: Option<char>,
ascii_layout_char: impl FnOnce() -> Option<char>,
) -> Option<LayoutName> {
let c = if ascii_capable {
current
} else {
ascii_layout_char()
};
c.map(LayoutName::Char)
}
#[cfg(target_os = "macos")]
mod mac {
use std::ffi::c_void;
use std::os::raw::c_ulong;
type CFTypeRef = *const c_void;
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
fn CFRelease(cf: CFTypeRef);
fn CFDataGetBytePtr(data: CFTypeRef) -> *const u8;
fn CFBooleanGetValue(boolean: CFTypeRef) -> u8;
}
#[link(name = "Carbon", kind = "framework")]
extern "C" {
static kTISPropertyInputSourceIsASCIICapable: CFTypeRef;
static kTISPropertyUnicodeKeyLayoutData: CFTypeRef;
fn TISCopyCurrentKeyboardLayoutInputSource() -> CFTypeRef;
fn TISCopyCurrentASCIICapableKeyboardLayoutInputSource() -> CFTypeRef;
fn TISGetInputSourceProperty(source: CFTypeRef, key: CFTypeRef) -> CFTypeRef;
fn LMGetKbdType() -> u8;
fn UCKeyTranslate(
layout: *const u8,
virtual_key_code: u16,
key_action: u16,
modifier_key_state: u32,
keyboard_type: u32,
key_translate_options: u32,
dead_key_state: *mut u32,
max_string_length: c_ulong,
actual_string_length: *mut c_ulong,
unicode_string: *mut u16,
) -> i32;
}
const K_UC_KEY_ACTION_DISPLAY: u16 = 3;
const K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK: u32 = 1;
/// Is the current keyboard layout one macOS types shortcuts with directly?
pub(super) fn current_is_ascii_capable() -> bool {
// SAFETY: the copied source is released here; the property is a borrowed CFBoolean.
unsafe {
let source = TISCopyCurrentKeyboardLayoutInputSource();
if source.is_null() {
return true;
}
let value = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsASCIICapable);
let capable = value.is_null() || CFBooleanGetValue(value) != 0;
CFRelease(source);
capable
}
}
/// The unshifted character the ASCII-capable layout macOS pairs with the current input
/// source makes on `physical`, dead keys off.
pub(super) fn ascii_capable_char(physical: winit::keyboard::PhysicalKey) -> Option<char> {
use winit::platform::scancode::PhysicalKeyExtScancode;
let keycode = u16::try_from(physical.to_scancode()?).ok()?;
let mut buf = [0u16; 4];
let mut len: c_ulong = 0;
// SAFETY: the copied source owns the layout bytes and is released after the translate;
// `buf` and `len` are valid for the length passed.
let status = unsafe {
let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource();
if source.is_null() {
return None;
}
let data = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData);
let status = if data.is_null() {
-1
} else {
let mut dead = 0u32;
UCKeyTranslate(
CFDataGetBytePtr(data),
keycode,
K_UC_KEY_ACTION_DISPLAY,
0,
u32::from(LMGetKbdType()),
K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK,
&mut dead,
buf.len() as c_ulong,
&mut len,
buf.as_mut_ptr(),
)
};
CFRelease(source);
status
};
if status != 0 {
return None;
}
let units = buf.get(..usize::try_from(len).ok()?)?;
let mut chars = char::decode_utf16(units.iter().copied());
let c = chars.next()?.ok()?;
(chars.next().is_none() && !c.is_control() && !c.is_whitespace()).then_some(c)
}
}
/// The modifiers a Linux key press is made under, from the raw key stream. winit's
/// `ModifiersState` has no AltGr, so the right Alt key is followed by its own messages: an AltGr
/// press on a dead key types its AltGr level, never the key's own accent.
#[cfg(any(target_os = "linux", test))]
#[derive(Default)]
struct LinuxMods {
state: winit::keyboard::ModifiersState,
alt_right: bool,
}
#[cfg(any(target_os = "linux", test))]
impl LinuxMods {
/// Follow the right Alt key through a key message.
fn key(&mut self, physical: winit::keyboard::PhysicalKey, pressed: bool) {
use winit::keyboard::{KeyCode, PhysicalKey};
if physical == PhysicalKey::Code(KeyCode::AltRight) {
self.alt_right = pressed;
}
}
/// No modifier held, AltGr included.
fn bare(&self) -> bool {
self.state.is_empty() && !self.alt_right
}
}
/// The Linux name of a letter or punctuation key, `None` to keep what the key had. No 1.12 client
/// ran on Linux, so this is the closest consistent rule: a letter position takes an ASCII letter or
/// ASCII punctuation the layout types there, and anything else there (another script's letter, an
/// accented letter, a mark) keeps its US letter, as Windows keeps `VK_A`-`VK_Z` there on nearly
/// every such layout; any other key takes its character. A dead keysym has no character
/// (`key_without_modifiers` is `Unidentified`), so it takes the dead key's own character from a
/// press with no modifier held, and otherwise keeps what it had.
#[cfg(any(target_os = "linux", test))]
fn linux_name(
position: Position,
unshifted: &winit::keyboard::Key,
logical: &winit::keyboard::Key,
bare: bool,
) -> Option<Option<LayoutName>> {
use winit::keyboard::Key;
let c = match (unshifted, logical) {
(Key::Unidentified(_), Key::Dead(Some(c))) if bare => Some(*c),
(Key::Unidentified(_), _) => return None,
_ => one_char(unshifted),
};
Some(c.map(|c| {
LayoutName::Char(match position {
Position::Letter(us) if !(c.is_ascii_alphabetic() || c.is_ascii_punctuation()) => us,
_ => c,
})
}))
}
#[cfg(test)]
mod tests {
use super::*;
/// A layout key's name under `names`, its ASCII letters folded up as the lookup folds them.
fn named(names: &LayoutNames, k: KeyCode) -> Option<char> {
match names.name(k) {
Some(LayoutName::Char(c)) => Some(c.to_ascii_uppercase()),
_ => None,
}
}
const VK_OEM_1: u32 = 0xba;
const VK_OEM_COMMA: u32 = 0xbc;
const VK_OEM_3: u32 = 0xc0;
const VK_OEM_6: u32 = 0xdd;
const VK_OEM_7: u32 = 0xde;
/// Windows: the reference's path on rows of the layouts' own tables (the key, its virtual
/// key, its unshifted character), as `MapVirtualKeyExW` returns them.
#[test]
fn windows_names_a_key_by_its_virtual_key_as_the_reference_does() {
let vk = |c: u8| u32::from(c);
let rows: &[(&str, KeyCode, u32, u32, char)] = &[
// Hebrew: the W key types `'` but is `VK_W`; Q types `/` and is `VK_Q`.
("hebrew W", KeyCode::KeyW, vk(b'W'), '\''.into(), 'W'),
("hebrew Q", KeyCode::KeyQ, vk(b'Q'), '/'.into(), 'Q'),
("greek Q", KeyCode::KeyQ, vk(b'Q'), ';'.into(), 'Q'),
("lithuanian Q", KeyCode::KeyQ, vk(b'Q'), 'ą'.into(), 'Q'),
("lithuanian W", KeyCode::KeyW, vk(b'W'), 'ž'.into(), 'W'),
("turkish ı", KeyCode::KeyI, vk(b'I'), 'ı'.into(), 'I'),
// Turkish Q's `VK_OEM_7` types `i`: one name with `I`, as the lookup folds it.
("turkish i", KeyCode::Quote, VK_OEM_7, 'i'.into(), 'I'),
// Bulgarian: `VK_Q` on the Period key, `VK_OEM_COMMA` on Q.
("bulgarian .", KeyCode::Period, vk(b'Q'), 'л'.into(), 'Q'),
("bulgarian Q", KeyCode::KeyQ, VK_OEM_COMMA, ','.into(), ','),
// Hungarian: `VK_0` on Backquote, `VK_OEM_3` `ö` on the 0 key.
("hungarian `", KeyCode::Backquote, vk(b'0'), '0'.into(), '0'),
("hungarian 0", KeyCode::Digit0, VK_OEM_3, 'ö'.into(), 'ö'),
// Dvorak's Q position is `VK_OEM_7`, `'`.
("dvorak Q", KeyCode::KeyQ, VK_OEM_7, '\''.into(), '\''),
("us ;", KeyCode::Semicolon, VK_OEM_1, ';'.into(), ';'),
// A dead key's bit (`0x80000000`) is masked off: French `^`.
("french ^", KeyCode::BracketLeft, VK_OEM_6, 0x8000_005e, '^'),
// Above U+00FF the key is named by what it types (the deviation).
("russian ж", KeyCode::Semicolon, VK_OEM_1, 'ж'.into(), 'ж'),
// The ISO key, German `VK_OEM_102`; the keypad comma, Brazilian ABNT's `VK_ABNT_C2`
// and a `VK_SEPARATOR` keypad.
("german <", KeyCode::IntlBackslash, 0xe2, '<'.into(), '<'),
("abnt keypad .", KeyCode::NumpadComma, 0xc2, '.'.into(), '.'),
("keypad ,", KeyCode::NumpadComma, 0x6c, ','.into(), ','),
];
let mut names = LayoutNames::default();
for &(what, key, vk, to_char, expected) in rows {
names.set(key, windows_name(vk, to_char));
assert_eq!(named(&names, key), Some(expected), "{what}");
}
// `MapVirtualKeyA` returning 0 drops the key (`0x42da49`): Japanese 106's
// `VK_OEM_AUTO` on Backquote.
assert_eq!(windows_name(0xf3, 0), Some(LayoutName::Dropped));
names.set(KeyCode::Backquote, windows_name(0xf3, 0));
assert_eq!(names.name(KeyCode::Backquote), Some(LayoutName::Dropped));
// A layout table's `WCH_NONE`/`WCH_DEAD`/`WCH_LGTR` marker is no character.
for marker in 0xf000..=0xf002 {
assert_eq!(
windows_name(VK_OEM_1, marker),
Some(LayoutName::Dropped),
"{marker:#x}"
);
}
// A key the fixed table names is no layout key's: F1, Space, Numpad 0.
for vk in [0x70, 0x20, 0x60] {
assert_eq!(windows_name(vk, 0), None, "{vk:#x}");
}
}
/// The fixed table is `0x42d800`'s, decoded from its remap bytes: the 60 entries less the
/// digits and F1-F12, which take their own arms.
#[test]
fn the_fixed_virtual_keys_are_the_translators_table() {
let fixed: Vec<u32> = (0..=0xffu32)
.filter(|&vk| fixed_vk(vk) && !(0x70..=0x7b).contains(&vk))
.collect();
assert_eq!(
fixed,
[
0x08, 0x09, 0x0d, 0x10, 0x11, 0x12, 0x13, 0x14, 0x1b, 0x20, 0x21, 0x22, 0x23, 0x24,
0x25, 0x26, 0x27, 0x28, 0x2c, 0x2d, 0x2e, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6d, 0x6e, 0x6f, 0x90, 0x91,
]
);
}
/// macOS: a Latin layout's own character; a layout that is not ASCII-capable names through
/// its ASCII-capable partner.
#[test]
fn a_mac_layout_that_is_not_ascii_capable_names_through_its_ascii_partner() {
// Hebrew: W types `'`; its U.S. partner types `w` there.
assert_eq!(
mac_name(false, Some('\''), || Some('w')),
Some(LayoutName::Char('w'))
);
// French: W types `z`, and the partner is never asked.
assert_eq!(
mac_name(true, Some('z'), || panic!(
"an ASCII-capable layout names itself"
)),
Some(LayoutName::Char('z'))
);
// No character: the US name stands in (the fallback deviation).
let mut names = LayoutNames::default();
names.set(KeyCode::KeyW, mac_name(true, None, || None));
assert_eq!(named(&names, KeyCode::KeyW), Some('W'));
}
/// Linux: a letter position keeps its US letter unless the layout types an ASCII letter or
/// ASCII punctuation there; any other key takes its character.
#[test]
fn linux_names_a_letter_position_by_its_ascii_character_or_its_us_letter() {
use winit::keyboard::{Key, NativeKey};
let ch = |c: &str| Key::Character(c.into());
for (key, typed, expected) in [
(KeyCode::KeyW, "z", 'z'),
(KeyCode::KeyM, ",", ','),
(KeyCode::KeyQ, "'", '\''),
(KeyCode::KeyQ, "ą", 'Q'),
(KeyCode::KeyI, "ı", 'I'),
(KeyCode::KeyW, "ц", 'W'),
// Thai's tone mark on H, INSCRIPT's virama on D.
(KeyCode::KeyH, "\u{e48}", 'H'),
(KeyCode::KeyD, "\u{94d}", 'D'),
(KeyCode::Semicolon, "ж", 'ж'),
(KeyCode::Quote, "ù", 'ù'),
] {
let got = linux_name(position(key).unwrap(), &ch(typed), &ch(typed), true);
assert_eq!(
got,
Some(Some(LayoutName::Char(expected))),
"{key:?} typing {typed}"
);
}
// A dead keysym: its character from a bare press, kept through a modified one.
let none = Key::Unidentified(NativeKey::Unidentified);
let bracket = position(KeyCode::BracketLeft).unwrap();
assert_eq!(
linux_name(bracket, &none, &Key::Dead(Some('^')), true),
Some(Some(LayoutName::Char('^')))
);
assert_eq!(
linux_name(bracket, &none, &Key::Dead(Some('¨')), false),
None
);
}
#[test]
fn a_layout_switch_renames_a_key_at_its_next_message() {
let mut names = LayoutNames::default();
names.set(KeyCode::KeyW, Some(LayoutName::Char('z')));
assert_eq!(named(&names, KeyCode::KeyW), Some('Z'));
names.set(KeyCode::KeyW, Some(LayoutName::Char('w')));
assert_eq!(
named(&names, KeyCode::KeyW),
Some('W'),
"back on a US layout"
);
// A layout that makes no character there falls back to the US name.
names.set(KeyCode::KeyQ, Some(LayoutName::Char('a')));
names.set(KeyCode::KeyQ, None);
assert_eq!(named(&names, KeyCode::KeyQ), Some('Q'));
// Digits and punctuation name themselves until a layout says otherwise; an ISO key no
// layout reported has no name.
assert_eq!(named(&names, KeyCode::Digit1), Some('1'));
assert_eq!(named(&names, KeyCode::Quote), Some('\''));
for key in [KeyCode::IntlBackslash, KeyCode::NumpadComma] {
assert_eq!(names.name(key), Some(LayoutName::Dropped), "{key:?}");
}
assert_eq!(names.name(KeyCode::F1), None, "the fixed table's");
}
/// Linux: AltGr counts as a modifier for a dead key's bare press, though winit's
/// `ModifiersState` has no AltGr; AltGr on a dead-key position keeps the key's own accent.
#[test]
fn linux_altgr_is_no_bare_press() {
use winit::keyboard::{Key, KeyCode, ModifiersState, NativeKey, PhysicalKey};
let none = Key::Unidentified(NativeKey::Unidentified);
let bracket = position(bevy::input::keyboard::KeyCode::BracketLeft).unwrap();
let mut mods = LinuxMods::default();
assert!(mods.bare());
mods.key(PhysicalKey::Code(KeyCode::AltRight), true);
assert!(!mods.bare(), "AltGr held");
// AltGr on the French `^` key types `~`: the key keeps its `^`.
assert_eq!(
linux_name(bracket, &none, &Key::Dead(Some('~')), mods.bare()),
None
);
mods.key(PhysicalKey::Code(KeyCode::AltRight), false);
assert!(mods.bare(), "AltGr released");
mods.key(PhysicalKey::Code(KeyCode::KeyQ), true);
assert!(mods.bare(), "another key is no modifier");
mods.state = ModifiersState::SHIFT;
assert!(!mods.bare(), "Shift held");
}
}
@@ -1,104 +0,0 @@
//! What the binding functions called from Lua ([`BindingInput`]) leave for the engine. Every key
//! runs its command's `Bindings.xml` body, so this is the one way a key moves the player: in the
//! reference `MOVEFORWARD` runs `MoveForwardStart()` on the press and `MoveForwardStop()` on the
//! release, and both halves set and clear one bit of `[InputControl+4]` through `0x515090`; so a
//! Start with no Stop keeps moving, a Stop clears the bit whichever input set it, and a one-shot
//! fires once per call.
use benilla_ui::script::{BindingInput, FiredInput, HeldInput};
use super::BindingsState;
/// One engine input a binding function drives: a held bit of `[InputControl+4]` or a one-shot.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Input {
/// `MoveForwardStart`/`Stop`, bit `0x10`.
MoveForward,
/// `MoveBackwardStart`/`Stop`, bit `0x20`.
MoveBackward,
/// `TurnLeftStart`/`Stop`, bit `0x100`.
TurnLeft,
/// `TurnRightStart`/`Stop`, bit `0x200`.
TurnRight,
/// `StrafeLeftStart`/`Stop`, bit `0x40`.
StrafeLeft,
/// `StrafeRightStart`/`Stop`, bit `0x80`.
StrafeRight,
/// `TurnOrActionStart`/`Stop`, bit `0x1`.
TurnOrAction,
/// `CameraOrSelectOrMoveStart`/`Stop`, bit `0x2`.
CameraOrSelectOrMove,
/// `Jump()`.
Jump,
/// `ToggleRun()`, the walk toggle.
ToggleRun,
/// `ToggleAutoRun()`.
ToggleAutoRun,
/// `SitOrStand()`.
SitOrStand,
/// `ToggleSheath()`.
ToggleSheath,
/// `CameraZoomIn([yards])`, its yards in [`BindingsState::amount`].
CameraZoomIn,
/// `CameraZoomOut([yards])`, its yards in [`BindingsState::amount`].
CameraZoomOut,
}
/// The bit a Start or Stop names.
fn held_input(input: HeldInput) -> Input {
match input {
HeldInput::MoveForward => Input::MoveForward,
HeldInput::MoveBackward => Input::MoveBackward,
HeldInput::TurnLeft => Input::TurnLeft,
HeldInput::TurnRight => Input::TurnRight,
HeldInput::StrafeLeft => Input::StrafeLeft,
HeldInput::StrafeRight => Input::StrafeRight,
HeldInput::TurnOrAction => Input::TurnOrAction,
HeldInput::CameraOrSelectOrMove => Input::CameraOrSelectOrMove,
}
}
/// The one-shot a fire names, and its amount: a zoom's yards, else 1.0.
fn fired_input(input: FiredInput) -> (Input, f32) {
match input {
FiredInput::Jump => (Input::Jump, 1.0),
FiredInput::ToggleRun => (Input::ToggleRun, 1.0),
FiredInput::ToggleAutoRun => (Input::ToggleAutoRun, 1.0),
FiredInput::SitOrStand => (Input::SitOrStand, 1.0),
FiredInput::ToggleSheath => (Input::ToggleSheath, 1.0),
FiredInput::CameraZoomIn(yards) => (Input::CameraZoomIn, yards),
FiredInput::CameraZoomOut(yards) => (Input::CameraZoomOut, yards),
}
}
impl BindingsState {
/// Apply Lua's binding calls, in call order.
pub(super) fn apply_script_input(&mut self, inputs: Vec<BindingInput>) {
for input in inputs {
match input {
// The set helper `0x514840` early-outs on a bit already set, so a Start on a held
// bit is no new edge.
BindingInput::Start(held) => {
let i = held_input(held);
if !self.pressed(i) {
self.just.push(i);
self.held.push(i);
}
}
// `0x514b70` clears the bit whoever set it, so a second key still down on the
// same command stops too, and its release is a no-op second Stop.
BindingInput::Stop(held) => {
let i = held_input(held);
self.held.retain(|&h| h != i);
}
// A one-shot is its own press edge.
BindingInput::Fire(fired) => {
let (i, amount) = fired_input(fired);
self.fired.push(i);
self.just.push(i);
self.amounts.push((i, amount));
}
}
}
}
}
+82 -86
View File
@@ -13,48 +13,29 @@
//! Deviation: the reference saves a full snapshot (`bindings-cache.wtf`); a diff is stored
//! because a full snapshot would leave every command added later unbound for a saved file.
use benilla_ui::script::keybind::KeyBinding;
use std::collections::HashMap;
/// A key list grouped by command, in first-appearance order; names compare case-insensitively.
fn by_command(list: &[KeyBinding]) -> Vec<(String, Vec<String>)> {
let mut out: Vec<(String, Vec<String>)> = Vec::new();
for (key, command) in list {
match out
.iter_mut()
.find(|(c, _)| c.eq_ignore_ascii_case(command))
{
Some((_, keys)) => keys.push(key.clone()),
None => out.push((command.clone(), vec![key.clone()])),
}
}
out
}
use super::commands::SPECS;
/// Serialize the live `(command, keys)` table (`keybind_snapshot`) as the diff file against the
/// defaults (`WTF\DefaultBindings.wtf`): a line for each command whose keys differ, including a
/// defaulted command the live table left unbound.
pub(crate) fn to_diff(snapshot: &[(String, Vec<String>)], defaults: &[KeyBinding]) -> String {
let defaults = by_command(defaults);
let default_of = |name: &str| {
defaults
.iter()
.find(|(c, _)| c.eq_ignore_ascii_case(name))
.map_or(&[][..], |(_, k)| k.as_slice())
};
let mut out = String::from("# benilla key bindings — diff vs defaults\n");
let unlisted = defaults
/// Serialize the live `(command, keys)` table, in registry order, as the diff file.
pub(crate) fn to_diff(snapshot: &[(String, Vec<String>)]) -> String {
let defaults: HashMap<&str, Vec<&str>> = SPECS
.iter()
.filter(|(c, _)| !snapshot.iter().any(|(n, _)| n.eq_ignore_ascii_case(c)))
.map(|(c, _)| (c.clone(), Vec::new()));
for (name, keys) in snapshot.iter().cloned().chain(unlisted) {
if default_of(&name) == keys.as_slice() {
.map(|s| (s.name, [s.d1, s.d2].into_iter().flatten().collect()))
.collect();
let mut out = String::from("# benilla key bindings — diff vs defaults\n");
for (name, keys) in snapshot {
let is_default = defaults
.get(name.as_str())
.is_some_and(|d| d.iter().copied().eq(keys.iter().map(String::as_str)));
if is_default {
continue;
}
out.push_str("bind ");
out.push_str(&name);
out.push_str(name);
for k in keys {
out.push(' ');
out.push_str(&k);
out.push_str(k);
}
out.push('\n');
}
@@ -83,14 +64,35 @@ pub(crate) fn from_diff(text: &str) -> Vec<(String, Vec<String>)> {
out
}
/// Resolve a diff into the full key table: the defaults, then each override in file order
/// replacing its command's keys and stealing each key from every other command, so one key always
/// maps to one command.
pub(crate) fn resolve(diff: &[(String, Vec<String>)], defaults: &[KeyBinding]) -> Vec<KeyBinding> {
let mut table = defaults.to_vec();
/// Resolve a diff into the full key table: defaults, then each override in file order stealing
/// its keys from every other command, so one key always maps to one command.
pub(crate) fn resolve(diff: &[(String, Vec<String>)]) -> Vec<(String, Vec<String>)> {
let mut table: Vec<(String, Vec<String>)> = SPECS
.iter()
.map(|s| {
(
s.name.to_string(),
[s.d1, s.d2]
.into_iter()
.flatten()
.map(str::to_owned)
.collect(),
)
})
.collect();
for (name, keys) in diff {
table.retain(|(k, c)| !c.eq_ignore_ascii_case(name) && !keys.contains(k));
table.extend(keys.iter().map(|k| (k.clone(), name.clone())));
// Steal each key from wherever it currently sits, then install the command's list.
for k in keys {
for (_, held) in table.iter_mut() {
held.retain(|h| h != k);
}
}
match table.iter_mut().find(|(n, _)| n == name) {
Some((_, slot)) => *slot = keys.clone(),
// A name not in SPECS is an addon's command, registered later at world entry: keep
// its row so the binding has its keys to restore.
None => table.push((name.clone(), keys.clone())),
}
}
table
}
@@ -99,73 +101,65 @@ pub(crate) fn resolve(diff: &[(String, Vec<String>)], defaults: &[KeyBinding]) -
mod tests {
use super::*;
fn defaults() -> Vec<KeyBinding> {
[
("W", "MOVEFORWARD"),
("UP", "MOVEFORWARD"),
("Q", "STRAFELEFT"),
("SPACE", "JUMP"),
("NUMPAD0", "JUMP"),
("Z", "TOGGLESHEATH"),
]
.into_iter()
.map(|(k, c)| (k.to_string(), c.to_string()))
.collect()
}
fn keys(table: &[KeyBinding], command: &str) -> Vec<String> {
table
.iter()
.filter(|(_, c)| c == command)
.map(|(k, _)| k.clone())
.collect()
}
#[test]
fn the_diff_round_trips_and_defaults_stay_silent() {
// All defaults: a header-only file.
let snapshot = by_command(&defaults());
let text = to_diff(&snapshot, &defaults());
let snapshot: Vec<(String, Vec<String>)> = SPECS
.iter()
.map(|s| {
(
s.name.to_string(),
[s.d1, s.d2]
.into_iter()
.flatten()
.map(str::to_owned)
.collect(),
)
})
.collect();
let text = to_diff(&snapshot);
assert_eq!(text.lines().count(), 1, "defaults produce no bind lines");
// Move JUMP to F (unbinding SPACE/NUMPAD0), unbind TOGGLESHEATH entirely, and leave
// STRAFELEFT out of the snapshot with no keys (a defaulted command no file declared).
let mut moved: Vec<(String, Vec<String>)> = snapshot
.into_iter()
.filter(|(n, _)| n != "STRAFELEFT")
.collect();
// Move JUMP to F (unbinding SPACE/NUMPAD0), unbind TOGGLESHEATH entirely.
let mut moved = snapshot.clone();
moved.iter_mut().find(|(n, _)| n == "JUMP").unwrap().1 = vec!["F".into()];
moved
.iter_mut()
.find(|(n, _)| n == "TOGGLESHEATH")
.unwrap()
.1 = vec![];
let text = to_diff(&moved, &defaults());
let text = to_diff(&moved);
assert!(text.contains("bind JUMP F\n"));
assert!(
text.contains("bind TOGGLESHEATH\n"),
"unbound = a bare line"
);
assert!(
text.contains("bind STRAFELEFT\n"),
"a defaulted command the snapshot lacks is unbound too"
);
assert!(
!text.contains("MOVEFORWARD"),
"untouched commands stay absent"
);
let resolved = resolve(&from_diff(&text), &defaults());
assert_eq!(keys(&resolved, "JUMP"), ["F"]);
assert!(keys(&resolved, "TOGGLESHEATH").is_empty());
assert_eq!(keys(&resolved, "MOVEFORWARD"), ["W", "UP"]);
let parsed = from_diff(&text);
let resolved = resolve(&parsed);
let get = |n: &str| {
resolved
.iter()
.find(|(name, _)| name == n)
.map(|(_, k)| k.clone())
.unwrap()
};
assert_eq!(get("JUMP"), vec!["F".to_string()]);
assert!(get("TOGGLESHEATH").is_empty());
assert_eq!(get("MOVEFORWARD"), vec!["W".to_string(), "UP".to_string()]);
}
#[test]
fn resolve_steals_across_commands_even_without_a_line_for_the_victim() {
let resolved = resolve(&[("JUMP".to_string(), vec!["W".to_string()])], &defaults());
assert_eq!(keys(&resolved, "MOVEFORWARD"), ["UP"]);
assert_eq!(keys(&resolved, "JUMP"), ["W"]);
let resolved = resolve(&[("JUMP".to_string(), vec!["W".to_string()])]);
let fwd = &resolved.iter().find(|(n, _)| n == "MOVEFORWARD").unwrap().1;
assert_eq!(fwd, &vec!["UP".to_string()]);
let jump = &resolved.iter().find(|(n, _)| n == "JUMP").unwrap().1;
assert_eq!(jump, &vec!["W".to_string()]);
}
#[test]
@@ -179,10 +173,12 @@ mod tests {
]
);
// An unknown command (an addon's, registered later) is kept, and still steals its key.
let resolved = resolve(&parsed, &defaults());
assert_eq!(keys(&resolved, "BOGUSCMD"), ["Q"]);
let resolved = resolve(&parsed);
let bogus = &resolved.iter().find(|(n, _)| n == "BOGUSCMD").unwrap().1;
assert_eq!(bogus, &vec!["Q".to_string()]);
let strafe = &resolved.iter().find(|(n, _)| n == "STRAFELEFT").unwrap().1;
assert!(
keys(&resolved, "STRAFELEFT").is_empty(),
strafe.is_empty(),
"Q was stolen by the unknown command's line"
);
}
+22 -292
View File
@@ -1,7 +1,6 @@
//! The capture UI fixtures: each arm seeds one window's synthetic but realistic state.
use super::*;
use benilla_protocol::messages::trainer_spell_state;
/// The `vplates` fixture's wolf, the reference screenshot's subject (vmangos `creature_template`
/// 69 "Timber Wolf": level 2, faction template 32, display 604), at the scenario's look point.
@@ -83,66 +82,6 @@ pub(super) fn seed_wind_player(
));
}
/// One trainer fixture's canned seed: the trainer's identity, its `SMSG_TRAINER_LIST` type, its
/// greeting, the character's hearthstone bind area, and the wire rows
/// `(spell, state, copper cost, required level)`.
struct TrainerSeed {
name: &'static str,
subname: &'static str,
trainer_type: u32,
greeting: &'static str,
bind: u32,
rows: &'static [(u32, u8, u32, u8)],
}
/// Llane Beshere (entry 911), Northshire's warrior trainer: the rank-1 learn wrappers a vmangos
/// trainer lists (`1605` teaches Heroic Strike 78 and so on), so each row's name and rank mirror
/// the ability while its description is the taught spell's own text.
const TRAINER_WARRIOR: TrainerSeed = TrainerSeed {
name: "Llane Beshere",
subname: "Warrior Trainer",
trainer_type: 0,
greeting: "I can train you in the ways of the warrior.",
bind: 9, // Northshire Valley, where this character's hearthstone is
rows: &[
(1605, trainer_spell_state::GREEN, 10, 1), // Heroic Strike Rank 1
(1738, trainer_spell_state::GREEN, 100, 4), // Charge Rank 1
(1423, trainer_spell_state::GREEN, 100, 4), // Rend Rank 1
(6674, trainer_spell_state::GREEN, 200, 6), // Battle Shout Rank 1
(1716, trainer_spell_state::GREEN, 300, 8), // Hamstring Rank 1
(3128, trainer_spell_state::GREEN, 200, 8), // Parry
],
};
/// A shaman trainer's Astral Recall (wrapper 1352 teaches 556). One service only: the stock
/// window's `ClassTrainer_SelectFirstLearnableSkill` selects row 2, so the row the shot is about
/// must be the first service of the first group.
const TRAINER_SHAMAN: TrainerSeed = TrainerSeed {
name: "Sian'tsu",
subname: "Shaman Trainer",
trainer_type: 0,
greeting: "The spirits are strong within you, shaman.",
bind: 362, // Razor Hill, a Durotar hearthstone
rows: &[(1352, trainer_spell_state::GREEN, 4000, 30)],
};
/// Bengus Deepforge, Ironforge's blacksmithing trainer, with the two row shapes a profession
/// trainer lists: the profession-learn wrapper (2020 teaches 2018, whose own text the row shows)
/// and a recipe (7820 teaches 7818, whose text is the description of the Silver Rod it makes).
/// The learn row sorts into group 1 (`Effect` 44 `SKILL_STEP`) ahead of the recipe's group 2
/// (`0x4d77b6`), so the stock window's row-2 selection lands on it.
const TRAINER_BLACKSMITH: TrainerSeed = TrainerSeed {
name: "Bengus Deepforge",
subname: "Blacksmithing Trainer",
trainer_type: 2, // a tradeskill trainer
greeting: "The forge shapes what the mine provides.",
bind: 1537, // Ironforge
rows: &[
(2020, trainer_spell_state::GREEN, 9, 5), // Apprentice Blacksmith
(7820, trainer_spell_state::GREEN, 90, 0), // Silver Rod
],
};
/// Seeds the fixture window's state once the scene is resident; the real feeds push it into the VM
/// during the settle window as live wire data would. Icons resolve through the offline
/// `ItemDisplayCatalog`, and names go straight into the caches.
@@ -155,17 +94,13 @@ 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 trainer: ResMut<crate::ui_trainer::TrainerOpen>,
// One param under Bevy's 16-param cap: item objects are entities in the index, spawned as
// the wire does; the hearthstone bind (`SMSG_BINDPOINTUPDATE`) rides along for the `$z` token.
store: (
ResMut<crate::items::Items>,
ResMut<crate::net::GuidIndex>,
ResMut<crate::net::HomeBind>,
),
// 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>>,
mut vplates: ResMut<crate::vplates::VPlateMode>,
mut selection: ResMut<crate::target::Selection>,
mut player: ResMut<crate::player::Player>,
// A nested tuple, one param under Bevy's 16-param cap.
@@ -176,7 +111,7 @@ pub(super) fn seed_ui_fixture(
ResMut<crate::loading_screen::LoadingScreen>,
),
) {
let (mut items, mut index, mut home_bind) = store;
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;
@@ -331,62 +266,6 @@ pub(super) fn seed_ui_fixture(
});
}
}
UiFixture::Trainer(list) => {
// The seed's rows are the wire list: its copper costs, level gates and the states the
// server computed, in wire order.
use benilla_protocol::messages::TrainerSpell;
let seed = match list {
TrainerList::Warrior => &TRAINER_WARRIOR,
TrainerList::Shaman => &TRAINER_SHAMAN,
TrainerList::Blacksmith => &TRAINER_BLACKSMITH,
};
names.insert_creature(
NPC_ENTRY,
Some(crate::names::CreatureRecord {
name: seed.name.into(),
subname: Some(seed.subname.into()),
creature_type: 7,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
display_id: 0,
}),
);
home_bind.0 = Some(seed.bind);
let Some(script) = script.as_mut() else {
return;
};
// The window's title and portrait read `UnitName("npc")`; no session seats it here.
script.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some(seed.name.into()),
level: 12,
..Default::default()
}),
);
// A purse large enough for every cost above, so the money frame renders white.
script.set_money(10_000);
let services: Vec<TrainerSpell> = seed
.rows
.iter()
.map(|&(spell, state, cost, req_level)| TrainerSpell {
spell,
state,
cost,
can_learn_primary_prof: false,
is_primary_prof_first_rank: false,
req_level,
req_skill: 0,
req_skill_value: 0,
req_spells: [0; 3],
})
.collect();
trainer.open(NPC_GUID, seed.trainer_type, services, seed.greeting.into());
}
UiFixture::Gossip => {
names.insert_creature(
NPC_ENTRY,
@@ -948,8 +827,6 @@ pub(super) fn seed_ui_fixture(
"mouseover",
Some(benilla_ui::script::UnitState {
exists: true,
// A unit is found by its guid; the builder hides for guid 0 (`0x52a04e`).
guid: 0xF130_0000_0044_0001,
name: Some("Stormwind Guard".into()),
health: 38,
max_health: 50,
@@ -966,33 +843,6 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-tooltip-world seed failed to open the tooltip");
}
}
UiFixture::TooltipRank => {
let Some(mut script) = script else {
return;
};
// A ranked player under the cursor, pushed as the mouseover feed does, then the call
// `drive_mouseover_tooltip` makes: the title is `0x609370`'s rank leg over the name,
// "Sergeant Bob".
script.set_unit(
"mouseover",
Some(benilla_ui::script::UnitState {
exists: true,
guid: 0x0000_0000_0000_0B0B,
is_player: true,
name: Some("Bob".into()),
level: 60,
reaction: 5,
pvp: true,
pvp_rank: 7,
pvp_team: 1,
sex: 2,
..Default::default()
}),
);
if !script.world_tooltip_unit("mouseover") {
warn!("capture: ui-tooltip-rank seed failed to open the tooltip");
}
}
UiFixture::Character => {
// A synthetic self player carrying the full stat block, which the `ui_char` feed turns
// into snapshots and events as live. A level-12 warrior; positive (stamina, fire) and
@@ -1153,8 +1003,7 @@ pub(super) fn seed_ui_fixture(
Visibility::default(),
))
.id();
// As the V binding does, so FrameXML's replay at `PLAYER_ENTERING_WORLD` agrees.
set_enemy_plates(script.as_deref(), true);
vplates.enemies = true;
// The wolf is the target, so its plate draws lit with the target ring.
selection.target = Some(wolf);
selection.guid = Some(WOLF_GUID);
@@ -1248,17 +1097,17 @@ pub(super) fn seed_ui_fixture(
}
}
UiFixture::KeyBindings => {
let Some(script) = script else {
let Some(mut script) = script else {
return;
};
// The Keybindings page over the commands the load registered: the real CVar set
// first, then the live open path, with Movement expanded to show a header row and the
// default bindings.
// The Keybindings page: the real command registry and CVar set first, then the live
// open path, with Movement expanded to show a header row and the default bindings.
script.register_cvars(crate::cvars::registered_pairs());
script.register_bindings(&crate::bindings::registry_commands());
if let Err(e) = script.run(
"ShowUIPanel(BenillaOptionsFrame); \
BenillaOptionsFrameCategoryListRowKeybindings:Click(); \
BenillaKeyBindings_ExpandSection(1, true); BenillaKeyBindingsPage_Update()",
KeyBindings_ExpandSection(1, true); KeyBindingsPage_Update()",
) {
warn!("capture: ui-keybindings seed failed: {e}");
}
@@ -1432,23 +1281,15 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-chat-tabhover select failed: {e}");
}
script.resolve();
// Each in the root's units the cursor is fed in: the frame's own times its scale.
let expr = match mode.as_str() {
"2" | "3" => {
"local x, y = ChatFrame2Tab:GetCenter() \
local k = ChatFrame2Tab:GetEffectiveScale() return x * k, y * k"
}
"2" | "3" => "return ChatFrame2Tab:GetCenter()",
"0" => {
"local k = ChatFrame2:GetEffectiveScale() \
return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2 * k, \
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2 * k"
"return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2, \
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2"
}
// 9: park far away, so the dock conceals itself.
"9" => "return 2000, 2000",
_ => {
"local x, y = ChatFrame1Tab:GetCenter() \
local k = ChatFrame1Tab:GetEffectiveScale() return x * k, y * k"
}
_ => "return ChatFrame1Tab:GetCenter()",
};
let centre: Result<(f32, f32), _> = script.eval(expr);
match centre {
@@ -1525,95 +1366,7 @@ pub(super) fn seed_ui_fixture(
Visibility::default(),
));
// A plated unit draws no floating name, so enemy plates go off.
set_enemy_plates(script.as_deref(), false);
}
UiFixture::NameRank => {
use benilla_protocol::messages::ObjectFields;
// The self player at the eye, as `name-water`; the subject is the other player's name.
const SELF_GUID: u64 = 0x51;
names.insert_player(SELF_GUID, "Benilla".into(), None);
commands.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[
(34, 2), // UNIT_FIELD_LEVEL
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human
(36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior
])),
crate::net::SelfPlayer,
crate::net::Guid(SELF_GUID),
Transform::from_translation(wow_to_bevy(scenario.eye)),
));
// The subject at the `vplates` wolf's dry spot: a human player holding rank,
// `PLAYER_BYTES_3` byte 3 (`PLAYER_BYTES_3_OFFSET_HONOR_RANK`, vmangos
// `HonorMgr.cpp:900`) at internal 7: the visual rank 3, "Sergeant" (team 1 off race
// 1's `ChrRaces` row).
const RANKED_GUID: u64 = 0x52;
names.insert_player(RANKED_GUID, "Bob".into(), None);
commands.spawn((
crate::net::Guid(RANKED_GUID),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Player,
// HumanMale, the body a human `UNIT_FIELD_DISPLAYID` carries.
display_id: Some(49),
scale: 1.0,
},
crate::net::ObjectStore(ObjectFields::from_pairs(&[
(34, 60), // UNIT_FIELD_LEVEL
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human
(36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior, male
(195, 7 << 24), // PLAYER_BYTES_3 byte 3: the current honor rank, internal 7
])),
Transform {
translation: wow_to_bevy(WOLF_POS),
..default()
},
Visibility::default(),
));
// A plated unit draws no floating name, so enemy plates go off.
set_enemy_plates(script.as_deref(), false);
}
UiFixture::NameRank => {
use benilla_protocol::messages::ObjectFields;
// The self player at the eye, as `name-water`; the subject is the other player's name.
const SELF_GUID: u64 = 0x51;
names.insert_player(SELF_GUID, "Benilla".into(), None);
commands.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[
(34, 2), // UNIT_FIELD_LEVEL
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human
(36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior
])),
crate::net::SelfPlayer,
crate::net::Guid(SELF_GUID),
Transform::from_translation(wow_to_bevy(scenario.eye)),
));
// The subject at the `vplates` wolf's dry spot: a human player holding rank,
// `PLAYER_BYTES_3` byte 3 (`PLAYER_BYTES_3_OFFSET_HONOR_RANK`, vmangos
// `HonorMgr.cpp:900`) at internal 7: the visual rank 3, "Sergeant" (team 1 off race
// 1's `ChrRaces` row).
const RANKED_GUID: u64 = 0x52;
names.insert_player(RANKED_GUID, "Bob".into(), None);
commands.spawn((
crate::net::Guid(RANKED_GUID),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Player,
// HumanMale, the body a human `UNIT_FIELD_DISPLAYID` carries.
display_id: Some(49),
scale: 1.0,
},
crate::net::ObjectStore(ObjectFields::from_pairs(&[
(34, 60), // UNIT_FIELD_LEVEL
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human
(36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior, male
(195, 7 << 24), // PLAYER_BYTES_3 byte 3: the current honor rank, internal 7
])),
Transform {
translation: wow_to_bevy(WOLF_POS),
..default()
},
Visibility::default(),
));
// A plated unit draws no floating name, so enemy plates go off.
set_enemy_plates(script.as_deref(), false);
vplates.enemies = false;
}
// The lighting matrix: one spawn with a streamed entity's component set, anonymous and
// with plates off, so no glyph rides over the body.
@@ -1657,23 +1410,11 @@ pub(super) fn seed_ui_fixture(
));
}
}
set_enemy_plates(script.as_deref(), false);
vplates.enemies = false;
}
}
}
/// Turn the enemy nameplates on or off through the stock `NAMEPLATES_ON` global.
fn set_enemy_plates(script: Option<&benilla_ui::script::UiScript>, on: bool) {
let lua = if on {
"NAMEPLATES_ON = 1; ShowNameplates()"
} else {
"NAMEPLATES_ON = nil; HideNameplates()"
};
if let Some(Err(e)) = script.map(|s| s.run(lua)) {
warn!("nameplates: {e}");
}
}
/// The `GetTime()` the cooldown filmstrip pins the VM's session clock at, seconds. Large, as
/// `CooldownFrame_SetTimer` ignores a start not above 0 (`Cooldown.lua:2`) and the starts here
/// are `now - fraction * span`.
@@ -1690,7 +1431,9 @@ fn seed_cooldown_filmstrip(
) {
// The clock first: `set_container` stores each triple against it, and
// `GetContainerItemCooldown`'s expiry guard reads it.
script.set_now(COOLDOWN_NOW_S);
if let Err(e) = script.run(&format!("__benilla_now = {COOLDOWN_NOW_S}")) {
warn!("capture: ui-cooldown failed to pin the session clock: {e}");
}
const DISP_STONE: u32 = 6418;
let texture = icons
.and_then(|i| i.catalog.get(DISP_STONE))
@@ -1707,11 +1450,11 @@ fn seed_cooldown_filmstrip(
petition: None,
durability: None,
duration_ms: None,
charges: 0,
bar_placeable: true,
texture: texture.clone(),
count: 1,
quality: Some(1),
inventory_type: 0,
item_id: 6948,
link: Some("|cffffffff|Hitem:6948|h[Hearthstone]|h|r".into()),
locked: false,
@@ -1760,11 +1503,11 @@ fn seed_bag_window(
petition: None,
durability: None,
duration_ms: None,
charges: 0,
bar_placeable: true,
texture: icon(disp),
count,
quality: Some(quality),
inventory_type: 0,
item_id: id,
link: Some(format!("|cffffffff|Hitem:{id}|h[{name}]|h|r")),
locked: false,
@@ -1847,11 +1590,11 @@ fn seed_equipped_bags(
petition: None,
durability: None,
duration_ms: None,
charges: 0,
bar_placeable: true,
texture: icon(disp),
count,
quality: Some(quality),
inventory_type: 0,
item_id: 0,
link: Some(format!("|cffffffff|Hitem:0|h[{name}]|h|r")),
locked: false,
@@ -2012,16 +1755,3 @@ pub(super) fn seed_perf_crowd(
));
}
}
/// Turn enemy plates on or off as the V binding does, the verb and FrameXML's global together,
/// so FrameXML's own replay agrees.
fn set_enemy_plates(script: Option<&benilla_ui::script::UiScript>, on: bool) {
let lua = if on {
"NAMEPLATES_ON = 1; ShowNameplates()"
} else {
"NAMEPLATES_ON = nil; HideNameplates()"
};
if let Some(Err(e)) = script.map(|s| s.run(lua)) {
warn!("nameplates: {e}");
}
}
+5 -65
View File
@@ -78,7 +78,6 @@ mod probe_vendor_swap;
mod probes;
mod realm_list;
mod scenarios;
mod spell_light;
use crate::run_mode::CaptureMode;
pub(crate) use depth_probe::DepthProbePlugin;
use fixtures::{seed_perf_crowd, seed_ui_fixture, seed_wind_player};
@@ -113,39 +112,12 @@ pub(crate) use probes::{
fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin,
JitterMeterPlugin, LiftCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin,
ProbeChatPlugin, ProbeClock, ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin,
ProbeGlueLuaPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
RevealAuditPlugin, SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin,
ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, RevealAuditPlugin,
SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin,
};
/// A probe that reads the native screens' rosters (the park's own, and the one re-sent after a
/// create or delete) cannot run under `WOW_GLUE_STOCK=1`, where the screen asks for its own and the
/// probe would sit until its timeout. Refuse it where it is requested: an error at plugin build,
/// and the exit of a driverless run ([`crate::run_mode::fatal_when_driverless`]), at the first
/// frame. `true` means the probe must not install.
pub(crate) fn refuse_under_stock(app: &mut App, probe: &'static str) -> bool {
refuse_if_stock(app, probe, crate::ui_script::glue::GlueStock::from_env().0)
}
fn refuse_if_stock(app: &mut App, probe: &'static str, stock: bool) -> bool {
if !stock {
return false;
}
error!(
"probe: {probe} reads the native screens' rosters and cannot run under WOW_GLUE_STOCK=1; \
unset one of them"
);
if crate::run_mode::fatal_when_driverless(&format!("{probe} under WOW_GLUE_STOCK=1")) {
app.add_systems(Startup, |mut exit: MessageWriter<AppExit>| {
exit.write(AppExit::error());
});
}
true
}
pub(crate) use scenarios::ui_opted_in;
use scenarios::GlueScreen;
use scenarios::{
Scenario, SubjectKind, TrainerList, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS,
};
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
pub(crate) mod fxview;
// The scripted aim, swim-pitch and camera-rig drivers order themselves before `player::control`:
@@ -708,11 +680,6 @@ impl Plugin for CapturePlugin {
})
.add_systems(Update, pin_scene.in_set(WorldStage::Present))
.add_systems(Update, seed_wind_player)
.add_systems(Update, spell_light::seed)
.add_systems(
Last,
spell_light::trace.after(crate::shadow_core::ShadowSet::Lanes),
)
// MONKEY (perf): the probe crowd (`WOW_PERF_CROWD`).
.add_systems(Update, seed_perf_crowd)
// Before `UnitFeed`: the seed stands in for wire data live play delivers on earlier
@@ -741,21 +708,7 @@ fn pin_scene(
return;
};
debug.lighting.follow_server_time = false;
// MONKEY (capture framing): `WOW_CAPTURE_MINUTE=<0..1440>`, `WOW_CAPTURE_EYE=x,y,z` and
// `WOW_CAPTURE_LOOK=x,y,z` (raw WoW coords) re-aim a scenario without a rebuild, for framing a
// reported spot; a scenario's baseline never sets them.
let triple = |var: &str| {
std::env::var(var).ok().and_then(|s| {
let v: Vec<f32> = s.split(',').filter_map(|x| x.trim().parse().ok()).collect();
(v.len() == 3).then(|| [v[0], v[1], v[2]])
})
};
debug.lighting.manual_minute = std::env::var("WOW_CAPTURE_MINUTE")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(scenario.minute);
let scene_eye = triple("WOW_CAPTURE_EYE").unwrap_or(scenario.eye);
let scene_look = triple("WOW_CAPTURE_LOOK").unwrap_or(scenario.look);
debug.lighting.manual_minute = scenario.minute;
// `WOW_MM_PROBE=x,y,z` (raw WoW coords) places an active player there, so the interior
// minimap renders server-less, with the camera above so the WMO streams in around it.
@@ -789,7 +742,7 @@ fn pin_scene(
* Quat::from_rotation_x(-req.el_deg.to_radians());
(look + orbit * (Vec3::Z * req.dist), look)
}
_ => (wow_to_bevy(scene_eye), wow_to_bevy(scene_look)),
_ => (wow_to_bevy(scenario.eye), wow_to_bevy(scenario.look)),
};
for mut t in &mut cam {
*t = Transform::from_translation(eye).looking_at(look, Vec3::Y);
@@ -1108,16 +1061,3 @@ fn drive_capture(
}
}
}
#[cfg(test)]
mod refuse_tests {
use super::*;
/// A native-only probe installs under the native screens and is refused under the stock ones.
#[test]
fn a_native_only_probe_is_refused_under_the_stock_screens() {
let mut app = App::new();
assert!(!refuse_if_stock(&mut app, "WOW_RIG", false));
assert!(refuse_if_stock(&mut app, "WOW_RIG", true));
}
}
@@ -431,7 +431,6 @@ fn auction_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -782,7 +781,6 @@ fn auction_probe(
kind: ChatKind::Say,
target: None,
text: format!(".additem {ITEM_ENTRY} {ITEM_COUNT}"),
language: None,
});
probe.phase = Phase::EnsureItem {
since: now,
@@ -202,7 +202,6 @@ fn bank_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -277,7 +276,6 @@ fn bank_probe(
kind: ChatKind::Say,
target: None,
text: format!(".additem {ITEM_ENTRY} 1"),
language: None,
});
probe.phase = Phase::EnsureItem {
since: now,
@@ -397,7 +395,6 @@ fn bank_probe(
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
text: format!(".modify money {grant}"),
language: None,
target: None,
});
probe.phase = Phase::BuySlot {
@@ -485,7 +482,6 @@ fn bank_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {by} {bz} 0"),
language: None,
});
probe.phase = Phase::Refusal {
since: now,
@@ -335,7 +335,6 @@ fn gm(net: &NetCommands, text: impl Into<String>) {
kind: ChatKind::Say,
target: None,
text: text.into(),
language: None,
});
}
@@ -87,7 +87,6 @@ fn bg_queue_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: ".revive".to_string(),
language: None,
});
// A body under the bracket floor is refused at the hello, with no list to join from.
let level = store.0.unit_level().unwrap_or(0);
@@ -97,7 +96,6 @@ fn bg_queue_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".levelup {}", QUEUE_LEVEL - level),
language: None,
});
}
let [x, y, z] = BATTLEMASTER_AT;
@@ -106,7 +104,6 @@ fn bg_queue_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
language: None,
});
probe.phase = Phase::Hopped { sent_at: now };
}
@@ -235,7 +235,6 @@ fn binder_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {INNKEEPER_MAP}"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -239,7 +239,6 @@ fn book_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -21,9 +21,6 @@ impl Plugin for ProbeCharCreatePlugin {
else {
return; // inert without a valid env spec
};
if super::refuse_under_stock(app, "WOW_PROBE_CHARCREATE") {
return;
}
let keep = std::env::var("WOW_PROBE_CHARCREATE_KEEP").as_deref() == Ok("1");
app.insert_resource(CharCreateProbe {
req,
@@ -51,6 +48,7 @@ fn parse_req(spec: &str) -> Option<CharCreateReq> {
hair_style: next_u8(0),
hair_color: next_u8(0),
facial_hair: next_u8(0),
challenge_mask: None,
})
}
@@ -371,7 +371,6 @@ fn charter_probe(
kind: ChatKind::Say,
target: None,
text: format!(".modify money {FUND_COPPER}"),
language: None,
});
info!(
"PROBE_CHARTER: 0 (precheck) — funding the body with {FUND_COPPER} copper so the \
@@ -711,7 +710,7 @@ fn charter_probe(
}
}
// ── Step 6: the item-use fork ───────────────────────────────────────────────────────
// A real bag right-click through `UseContainerItem`, so `use_container_item` reaches
// A real bag right-click through `UseContainerItem`, so `drain_container_uses` reaches
// `ItemUseRoute::ShowPetition`. A FAIL is either the fork missing the charter arm or
// `SMSG_PETITION_SHOW_SIGNATURES` never becoming `PETITION_SHOW`. No getter exposes the
// open charter's item guid, so its identity rests on no window being up before the click.
@@ -1047,7 +1046,6 @@ fn hop(probe: &mut CharterProbe, net: &NetCommands, now: f64) {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {REGISTRAR_MAP}"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -200,7 +200,6 @@ fn chest_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {map}"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
+1 -3
View File
@@ -126,7 +126,6 @@ fn finish(net: &NetCommands, entry: u32, now: f64) -> Phase {
kind: ChatKind::Say,
target: None,
text: format!(".additem {entry} -1"),
language: None,
});
Phase::Cleanup { since: now }
}
@@ -198,7 +197,6 @@ fn clam_probe(
kind: ChatKind::Say,
target: None,
text: format!(".additem {entry}"),
language: None,
});
probe.phase = Phase::Stocking { sent_at: now };
}
@@ -323,7 +321,7 @@ fn clam_probe(
}
/// Right-click the clam through the live VM's `UseContainerItem`, so the real dispatcher
/// (`ui_items::ScriptItemUse::use_container_item`) picks the packet.
/// (`ui_items::drain::drain_container_uses`) picks the packet.
fn click(script: &mut UiScript, probe: &mut ClamProbe, round: u8) {
let slot = probe.slot;
info!("PROBE_CLAM: round {round} — UseContainerItem(0, {slot}) through the live VM");
@@ -134,7 +134,6 @@ fn crossing_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {}", m as i32),
language: None,
});
return;
}
@@ -168,7 +167,6 @@ fn crossing_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {} ", z + DROP_HEIGHT),
language: None,
});
probe.phase = Phase::Boarding {
boat: guid.0,
@@ -72,26 +72,6 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
// First fire at `_AT` seconds, re-armed `_AGAIN` seconds into every later world entry.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_GLUE_LUA",
purpose: "\"<chunk>\" — under WOW_GLUE_STOCK=1, run a Lua chunk once in the stock glue VM (press the login screen's buttons headlessly)",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_GLUE_LUA_AT",
purpose: "<secs> — when the glue chunk runs (default 10)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_GLUE_SHOT",
purpose: "<path> — one PNG after the glue chunk runs",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_GLUE_SHOT_AFTER",
purpose: "<secs> — how long after the glue chunk the shot is taken (default 1)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_LUA_AT",
purpose: "<secs> — when the chunk runs after the first world entry (default 10)",
@@ -147,7 +147,6 @@ fn level_to(net: &NetCommands, from: u32, to: u32) {
kind: ChatKind::Say,
target: None,
text: format!(".levelup {delta}"),
language: None,
});
}
@@ -210,7 +209,6 @@ fn goquest_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {map}"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -83,7 +83,6 @@ fn guard_poi_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
language: None,
});
probe.phase = Phase::Hopped { sent_at: now };
}
@@ -232,7 +232,6 @@ fn mail_probe(
kind: ChatKind::Say,
target: None,
text,
language: None,
});
}
probe.phase = Phase::Settling { sent_at: now };
@@ -1,7 +1,7 @@
//! The melee live probe (`WOW_PROBE=melee`), inert without the env: once in-world it presses the
//! player's Attack with nothing selected ([`AttackNearestRequest`]) every few seconds, so the
//! character acquires and fights whatever is nearest while `WOW_MOVE_TRACE=<path>` records the
//! swing, impact and combat-text timeline.
//! The melee live probe (`WOW_PROBE=melee`), inert without the env: once in-world it fires the
//! attack-nearest core ([`AttackNearestRequest`]) every few seconds, so the character fights
//! whatever is closest while `WOW_MOVE_TRACE=<path>` records the swing, impact and combat-text
//! timeline.
//!
//! Never run it unattended (`docs/METHOD.md`, "The local server"): it fights with no health
//! awareness and the character dies.
@@ -21,7 +21,7 @@ impl Plugin for ProbeMeleePlugin {
}
/// Every 3 s: swing at the selection (idempotent while auto-attacking, and it covers an attacker
/// the acquire skips), or with none, press Attack, which acquires.
/// the acquire core skips), or with none, run the acquire core.
fn melee_probe(
time: ProbeClock,
mut last: Local<f64>,
+1 -3
View File
@@ -73,9 +73,6 @@ impl Plugin for ProbeRigPlugin {
else {
return; // inert without a parseable spec (parse() has already said why)
};
if super::refuse_under_stock(app, "WOW_RIG") {
return;
}
info!("rig: {}", spec.describe());
// Publish the claim on the character pick, so the roster knows the pick is spoken for.
if let Some(name) = rig_char_name(&spec) {
@@ -291,6 +288,7 @@ fn drive_rig(
hair_style: 0,
hair_color: 0,
facial_hair: 0,
challenge_mask: None,
}));
rig.phase = RigPhase::Creating;
}
@@ -641,7 +641,6 @@ fn service_probe(
kind: ChatKind::Say,
target: None,
text: format!(".additem {SELL_ITEM_ENTRY} 1"),
language: None,
});
probe.phase = Phase::VendorArm { since: now, guid };
} else if now - since > WINDOW_TIMEOUT_SECS {
@@ -814,7 +813,6 @@ fn hop(probe: &mut ServiceProbe, net: &NetCommands, i: usize, h: usize, now: f64
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {}", leg.map),
language: None,
});
probe.polls = 0;
probe.phase = Phase::Hop {
@@ -839,7 +837,6 @@ fn next(probe: &mut ServiceProbe, net: &NetCommands, i: usize, now: f64) {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {VENDOR_MAP}"),
language: None,
});
probe.phase = Phase::VendorHop { sent_at: now };
}
@@ -192,7 +192,6 @@ fn stone_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {map}"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -252,7 +251,6 @@ fn stone_probe(
kind: ChatKind::Say,
target: None,
text: format!(".character level {LEVEL_BELOW}"),
language: None,
});
probe.phase = Phase::WaitLevel {
want: LEVEL_BELOW,
@@ -304,7 +302,6 @@ fn stone_probe(
kind: ChatKind::Say,
target: None,
text: format!(".character level {want}"),
language: None,
});
probe.phase = Phase::WaitLevel {
want,
@@ -170,7 +170,6 @@ fn taxi_probe(
kind: crate::net::ChatKind::Say,
target: None,
text,
language: None,
});
}
probe.phase = Phase::Hopped { sent_at: now };
@@ -173,7 +173,6 @@ fn vendor_swap_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {MAP}"),
language: None,
});
probe.phase = Phase::Settle { sent_at: now };
}
+4 -124
View File
@@ -141,12 +141,8 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
"Tab" => KeyCode::Tab,
// TOGGLEAUTORUN's 1.12 default; with `WOW_PROBE_LOOK` it scripts a drive.
"NumLock" => KeyCode::NumLock,
// The nameplate toggles: `V`, `Shift`+`V`, `Ctrl`+`V`.
// The nameplate toggle: bare `V` shows enemy plates, `Shift`+`V` friendly ones.
"V" => KeyCode::KeyV,
// A window toggle (TOGGLEBACKPACK) and an action button (ACTIONBUTTON1), by their 1.12
// defaults.
"B" => KeyCode::KeyB,
"1" => KeyCode::Digit1,
// Free-fly is the dev chord (`Ctrl`+`Shift`) + `F` (`player.rs`); a held `Ctrl` is its ×5
// speed boost (`camera.rs`).
"F" => KeyCode::KeyF,
@@ -365,97 +361,6 @@ fn fire_probe_lua(
}
}
/// `WOW_PROBE_GLUE_LUA="<chunk>"`: under `WOW_GLUE_STOCK=1`, runs the chunk once in the stock
/// glue VM `WOW_PROBE_GLUE_LUA_AT` seconds (default 10) into the run, to press the login screen's
/// buttons headlessly; `WOW_PROBE_GLUE_SHOT=<path>` then saves one PNG
/// `WOW_PROBE_GLUE_SHOT_AFTER` seconds (default 1) later. The chunk is never logged: it may carry
/// a typed password.
pub(crate) struct ProbeGlueLuaPlugin;
impl Plugin for ProbeGlueLuaPlugin {
fn build(&self, app: &mut App) {
let secs = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
app.insert_resource(ProbeGlueLua {
chunk: std::env::var("WOW_PROBE_GLUE_LUA").unwrap_or_default(),
at: secs("WOW_PROBE_GLUE_LUA_AT", 10.0),
shot: std::env::var("WOW_PROBE_GLUE_SHOT").ok(),
shot_after: secs("WOW_PROBE_GLUE_SHOT_AFTER", 1.0),
ran_at: None,
shot_done: false,
})
.add_systems(Update, fire_probe_glue_lua.after(crate::ui_script::UiInput));
}
}
#[derive(Resource)]
struct ProbeGlueLua {
chunk: String,
at: f32,
shot: Option<String>,
shot_after: f32,
ran_at: Option<f32>,
shot_done: bool,
}
/// The log line for a failed glue chunk: its kind alone. The error's text can quote the chunk (a
/// syntax error's `near '<token>'`, a chunk named by its own source), and the chunk may carry a
/// typed password.
fn glue_chunk_failure(e: &dyn std::fmt::Display) -> String {
let text = e.to_string();
let kind = if text.starts_with("syntax error") {
"a syntax error"
} else if text.starts_with("runtime error") {
"a runtime error"
} else if text.starts_with("callback error") {
"an error in a callback"
} else {
"an error"
};
format!("probe-glue-lua: the chunk failed with {kind}")
}
/// Runs the chunk once the glue VM stands and its time has come, then the one shot.
fn fire_probe_glue_lua(
mut probe: ResMut<ProbeGlueLua>,
time: ProbeClock,
script: Option<NonSend<benilla_ui::script::UiScript>>,
mut commands: Commands,
) {
let now = time.elapsed_secs();
match probe.ran_at {
None => {
let Some(script) = script.filter(|s| s.vm_kind() == benilla_ui::script::VmKind::Glue)
else {
return;
};
if now < probe.at {
return;
}
probe.ran_at = Some(now);
info!("probe-glue-lua: running the chunk at {now:.1}s");
if let Err(e) = script.run(&probe.chunk) {
error!("{}", glue_chunk_failure(&e));
}
}
Some(ran) if !probe.shot_done && now >= ran + probe.shot_after => {
probe.shot_done = true;
if let Some(out) = probe.shot.clone() {
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(out.clone()));
info!("probe-glue-lua: shot writing {out}");
}
}
_ => {}
}
}
/// `WOW_PROBE_HOVER="Frame1;Frame2;…"`: moves the pointer across each named frame's centre
/// through the real pointer path, pressing nothing, from `WOW_PROBE_HOVER_AT` (default 14 s),
/// one step per `WOW_PROBE_HOVER_STEP` (default 0.25 s; ~0.016 moves every frame), until exit.
@@ -641,13 +546,13 @@ struct ProbeDrag {
to: (f32, f32),
}
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, times its effective
/// scale: the edges answer in the frame's own units, the cursor is fed in the root's.
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, in the space
/// [`crate::ui_script::input`] feeds the cursor in.
fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> {
let read = |edge: &str| {
script
.eval::<f32>(&format!(
"local f = getglobal(\"{name}\") return f and f:Get{edge}() * f:GetEffectiveScale()"
"local f = getglobal(\"{name}\") return f and f:Get{edge}()"
))
.ok()
};
@@ -816,28 +721,3 @@ mod tests {
);
}
}
#[cfg(test)]
mod glue_chunk_tests {
/// A failing chunk is logged by its kind, never its text: a syntax error quotes its token.
#[test]
fn a_failed_glue_chunk_never_echoes_its_text() {
let s = benilla_ui::script::UiScript::new_glue().unwrap();
for (chunk, kind) in [
("x = 1 'SECRET'", "a syntax error"),
("error('SECRET')", "a runtime error"),
("SECRET()", "a runtime error"),
] {
let e = s.run(chunk).unwrap_err();
assert!(
e.to_string().contains("SECRET"),
"the raw error quotes the chunk: {e}"
);
let line = super::glue_chunk_failure(&e);
assert_eq!(
line,
format!("probe-glue-lua: the chunk failed with {kind}")
);
}
}
}
@@ -455,36 +455,38 @@ fn drive_live_fps(
};
// Per thread, ms per frame; threads sharing a name (a pool) are summed. The
// table is macOS/Linux; elsewhere it is absent and `main_in`/`faults` stand alone.
let mut threads = match (&probe.threads_at_start, crate::perf::thread_cpu_table()) {
(Some(t0), Some(t1)) => {
let start: std::collections::HashMap<usize, (f64, f64)> =
t0.iter().map(|(k, _, u, s)| (*k, (*u, *s))).collect();
let mut by_name: std::collections::HashMap<String, (f64, f64)> =
std::collections::HashMap::new();
for (k, name, u1, s1) in &t1 {
let (u0, s0) = start.get(k).copied().unwrap_or((0.0, 0.0));
let name = match name.rfind(" (") {
Some(i) if name.ends_with(')') => name[..i].to_string(),
_ => name.clone(),
};
let e = by_name.entry(name).or_default();
e.0 += u1 - u0;
e.1 += s1 - s0;
let mut threads =
match (&probe.threads_at_start, crate::perf::thread_cpu_table()) {
(Some(t0), Some(t1)) => {
let start: std::collections::HashMap<usize, (f64, f64)> =
t0.iter().map(|(k, _, u, s)| (*k, (*u, *s))).collect();
let mut by_name: std::collections::HashMap<String, (f64, f64)> =
std::collections::HashMap::new();
for (k, name, u1, s1) in &t1 {
let (u0, s0) = start.get(k).copied().unwrap_or((0.0, 0.0));
let name = match name.rfind(" (") {
Some(i) if name.ends_with(')') => name[..i].to_string(),
_ => name.clone(),
};
let e = by_name.entry(name).or_default();
e.0 += u1 - u0;
e.1 += s1 - s0;
}
let mut v: Vec<(String, (f64, f64))> =
by_name.into_iter().collect();
v.sort_by(|a, b| (b.1 .0 + b.1 .1).total_cmp(&(a.1 .0 + a.1 .1)));
let per = |secs: f64| secs * 1000.0 / v_len as f64;
// `name:user+sys`, ms per frame.
let list = v
.iter()
.take(8)
.map(|(n, (u, s))| format!("{n}:{:.2}+{:.2}", per(*u), per(*s)))
.collect::<Vec<_>>()
.join(",");
format!(" thr=[{list}]")
}
let mut v: Vec<(String, (f64, f64))> = by_name.into_iter().collect();
v.sort_by(|a, b| (b.1 .0 + b.1 .1).total_cmp(&(a.1 .0 + a.1 .1)));
let per = |secs: f64| secs * 1000.0 / v_len as f64;
// `name:user+sys`, ms per frame.
let list = v
.iter()
.take(8)
.map(|(n, (u, s))| format!("{n}:{:.2}+{:.2}", per(*u), per(*s)))
.collect::<Vec<_>>()
.join(",");
format!(" thr=[{list}]")
}
_ => String::new(),
};
_ => String::new(),
};
threads.push_str(&inside);
threads.push_str(&faults);
format!(
+1 -2
View File
@@ -9,8 +9,7 @@ mod fx_draw_census;
pub(crate) use fx_draw_census::plugin as fx_draw_census_plugin;
mod act;
pub(crate) use act::{
ProbeChatPlugin, ProbeDragPlugin, ProbeGlueLuaPlugin, ProbeHoverPlugin, ProbeKeyPlugin,
ProbeLuaPlugin,
ProbeChatPlugin, ProbeDragPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin,
};
/// The probe run's shell: its bounded lifetime and its window's level, parking and size.
+1 -4
View File
@@ -47,10 +47,7 @@ pub(super) fn seed_realm_list(
}
match *step {
0 => {
lists.write(crate::net::RealmListMessage {
realms: fixture(),
failed: false,
});
lists.write(crate::net::RealmListMessage { realms: fixture() });
}
2 => crate::realm_select::open_over_char_select(&mut realms, &choice),
3.. => return,
+4 -159
View File
@@ -18,20 +18,6 @@ pub(super) struct Scenario {
pub(super) ui: Option<UiFixture>,
}
/// Which trainer [`UiFixture::Trainer`] captures.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum TrainerList {
/// Llane Beshere (entry 911), Northshire's warrior trainer: the rank-1 learn wrappers a
/// vmangos trainer lists, so the description is the taught ability's own text.
Warrior,
/// A shaman trainer's Astral Recall row (wrapper 1352 teaches 556), the shipped spell whose
/// description carries the `$z` token, so the shot pins the bind area's name too.
Shaman,
/// A profession trainer's two row shapes: the learn wrapper whose text is the taught
/// profession's, and a recipe whose text is its product's.
Blacksmith,
}
/// A UI window opened with synthetic state that mirrors what the server sends, so the capture
/// runs the real feed, VM, extract and render chain.
#[derive(Clone, Copy, PartialEq)]
@@ -43,9 +29,6 @@ pub(super) enum UiFixture {
Merchant,
Gossip,
Quest,
/// The class trainer window fed as `SMSG_TRAINER_LIST` would: the first learnable service
/// selected, its description expanded from `Spell.dbc`.
Trainer(TrainerList),
/// The bank window fed through a synthetic self-player descriptor: `PLAYER_FIELD_BANK_SLOT`
/// guids, a bank bag, the purchased count in `PLAYER_BYTES_2` byte 2, and coinage.
Bank,
@@ -58,8 +41,8 @@ pub(super) enum UiFixture {
Bag,
/// The cooldown sweep at sixteen phases in one still: each backpack slot's
/// `GetContainerItemCooldown` sits at its own fraction of one long cooldown, in reading order
/// (slot 1 renders top-left, `ContainerFrame_GenerateFrame` numbering backwards). The VM's
/// `GetTime` clock is parked at a large value, so the settle's seconds are ~5e-4 of a phase.
/// (slot 1 renders top-left, `ContainerFrame_GenerateFrame` numbering backwards). The VM clock
/// `__benilla_now` is parked at a large value, so the settle's seconds are ~5e-4 of a phase.
Cooldown,
/// The cooldown filmstrip with the pet bar's autocast shine beside it: `UI-AutoCastButton.m2`
/// is four additive emitters and no batch, sharing the tile atlas, so this asks whether its
@@ -70,9 +53,6 @@ pub(super) enum UiFixture {
/// The world-mouseover tooltip over a seeded unit: the default anchor puts it at the screen's
/// bottom-right (`GameTooltip.lua:73-77`), never on the hovered model.
TooltipWorld,
/// The world-mouseover tooltip over a seeded ranked player: the same anchor, with the title's
/// PvP rank leg ("Sergeant Bob").
TooltipRank,
/// The character window fed through a synthetic self player's stat block and equipped item
/// guids, with the items in [`crate::items::Items`].
Character,
@@ -126,17 +106,13 @@ pub(super) enum UiFixture {
OptionsDropdownList,
/// The Options window mid-search: "volume" lists the four volume sliders under the Audio head.
OptionsSearch,
/// The Options window's Keybindings page, Movement expanded, over the stock commands and
/// default bindings the load read off the install, and GlobalStrings.
/// The Options window's Keybindings page, Movement expanded, over the 1.12 default bindings and
/// GlobalStrings; the command registry registers in-fixture.
KeyBindings,
/// An overhead name with the river behind it: a named unit 25 yd out in the Elwynn river (the
/// `water-noon` camera). Deep water is opaque (`WATER_DEEP_ALPHA` 1.0), so a name sorted before
/// the liquid is painted out; it must read at full strength.
NameWater,
/// The ranked player's overhead name line: a level-60 human holding honor rank 7 (internal;
/// the visual rank 3, "Sergeant") at the fixture's dry subject spot, framed close off the
/// `vplates` camera's bearing.
NameRank,
/// One cell of the lighting matrix: a creature or GameObject spawned through the live path at
/// `at` ([`SubjectKind`], the note above [`SUBJECT_SUN`]).
Subject {
@@ -311,55 +287,6 @@ pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9];
/// Every other named viewpoint, capturable by name (`WOW_CAPTURE=<name>`) but not in the sweep.
pub(super) const ON_DEMAND: &[Scenario] = &[
// MONKEY (wmo surface lights): the portcullis/archery yard and lamp-lined gate ramp.
Scenario {
name: "stormwind-training-yard-day",
map: Some(MAP_AZEROTH),
eye: [-8728.0, 341.0, 104.0],
look: [-8517.4951, 792.4295, 147.57787],
minute: 720,
ui: None,
},
Scenario {
name: "stormwind-training-yard-dusk",
map: Some(MAP_AZEROTH),
eye: [-8728.0, 341.0, 104.0],
look: [-8517.4951, 792.4295, 147.57787],
minute: 1110,
ui: None,
},
Scenario {
name: "stormwind-training-yard-night",
map: Some(MAP_AZEROTH),
eye: [-8728.0, 341.0, 104.0],
look: [-8517.4951, 792.4295, 147.57787],
minute: 0,
ui: None,
},
Scenario {
name: "stormwind-gate-ramp-day",
map: Some(MAP_AZEROTH),
eye: [-9095.0, 412.0, 124.0],
look: [-8818.3066, 632.09216, -229.55338],
minute: 720,
ui: None,
},
Scenario {
name: "stormwind-gate-ramp-dusk",
map: Some(MAP_AZEROTH),
eye: [-9095.0, 412.0, 124.0],
look: [-8818.3066, 632.09216, -229.55338],
minute: 1110,
ui: None,
},
Scenario {
name: "stormwind-gate-ramp-night",
map: Some(MAP_AZEROTH),
eye: [-9095.0, 412.0, 124.0],
look: [-8818.3066, 632.09216, -229.55338],
minute: 0,
ui: None,
},
// MONKEY (volumetric fog): UI proof uses the same live page selection as a player.
Scenario {
name: "ui-options-advanced",
@@ -416,17 +343,6 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: None,
},
// MONKEY (planar water): Lakeshire's small dock from the boardwalk, looking down at the deck
// and its reflection (the owner's stretched-reflection screenshot); the `vista` instrument at
// feet -9297,-2170,67, facing 205, pitch -30.
Scenario {
name: "water-lakeshire-dock",
map: Some(MAP_AZEROTH),
eye: [-9297.0, -2170.0, 69.0],
look: [-9689.4, -2353.0, -181.0],
minute: 720,
ui: None,
},
// Westfall western coast: sand in the foreground, open sea to the west.
Scenario {
name: "water-ocean",
@@ -964,15 +880,6 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::TooltipWorld),
},
// The same anchor over a ranked player: the tooltip title carries the rank, "Sergeant Bob".
Scenario {
name: "ui-tooltip-rank",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::TooltipRank),
},
Scenario {
name: "ui-char",
map: Some(MAP_AZEROTH),
@@ -1366,29 +1273,6 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: None,
},
// MONKEY (gfx-1009 unlit): Lakeshire's report sites. The sunken `lionstatue.mdx` (MDDF uid
// 83242, (-9355.5, -2246.1, 47.9), ~8 yd under the 56.16 surface) seen from the water surface
// and from under it, and the gryphon master's two `gryphonroost01.mdx` (uids 325784/325785).
Scenario {
name: "lakeshire-statue", map: Some(MAP_AZEROTH),
eye: [-9318.0, -2238.0, 59.0], look: [-9356.0, -2247.0, 49.0], minute: 720, ui: None,
},
Scenario {
name: "lakeshire-statue-dusk", map: Some(MAP_AZEROTH),
eye: [-9318.0, -2238.0, 59.0], look: [-9356.0, -2247.0, 49.0], minute: 1170, ui: None,
},
Scenario {
name: "lakeshire-statue-under", map: Some(MAP_AZEROTH),
eye: [-9343.0, -2254.0, 52.0], look: [-9356.0, -2246.0, 48.5], minute: 720, ui: None,
},
Scenario {
name: "lakeshire-gryphons", map: Some(MAP_AZEROTH),
eye: [-9407.0, -2250.0, 72.5], look: [-9422.5, -2237.0, 69.5], minute: 720, ui: None,
},
Scenario {
name: "lakeshire-gryphons-dusk", map: Some(MAP_AZEROTH),
eye: [-9407.0, -2250.0, 72.5], look: [-9422.5, -2237.0, 69.5], minute: 1170, ui: None,
},
// Duskwood: the road into Darkshire, the zone's short dark fog.
Scenario {
name: "duskwood-road",
@@ -1816,45 +1700,6 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 1380,
ui: None,
},
// The ranked player's name line at the fixture's subject spot, 5 yd out level with it on the
// `vplates` camera's horizontal bearing, so the body and its name line fill the frame.
Scenario {
name: "name-rank",
map: Some(MAP_AZEROTH),
eye: [-8953.92, -135.53, 85.5],
look: [-8949.95, -132.49, 85.5],
minute: 720,
ui: Some(UiFixture::NameRank),
},
// The warrior trainer's window over the noon ground view, a learnable service selected.
Scenario {
name: "ui-trainer",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::Trainer(TrainerList::Warrior)),
},
// A shaman trainer's window: the one shipped service whose description carries `$z`, over a
// bind point, so the shot pins the token against `AreaTable`.
Scenario {
name: "ui-trainer-astral",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::Trainer(TrainerList::Shaman)),
},
// A blacksmithing trainer's window: the profession-learn row, whose text is the taught
// profession's, with a recipe row beneath it.
Scenario {
name: "ui-trainer-blacksmith",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::Trainer(TrainerList::Blacksmith)),
},
];
/// GFX (volumetric light): the Goldshire lake bank among the trees (eye 2 yd over the feet).
@@ -1,98 +0,0 @@
//! MONKEY (gfx): `WOW_CAPTURE_SPELL_LIGHT=x,y,z` adds a held spell-light fixture at WoW coords.
//! Exercises the real envelope, room claims and shadow budget without a server or spell assets.
//! The trace compares the legacy first-within-one-yard shadow lookup with the nearest match.
use crate::entities::{
spell_fx::{SpellLight, SpellLightMode},
HeldLight,
};
use benilla_assets::coords::wow_to_bevy;
use benilla_world::{
lighting::{ResolvedPointLights, SpellFxLight, SyntheticFireLight},
static_gx::{LightOwner, TorchShadowViews},
terrain_stream::point_light,
};
use bevy::prelude::*;
pub(super) fn seed(mut commands: Commands, mut seeded: Local<bool>) {
if *seeded {
return;
}
*seeded = true;
let Ok(value) = std::env::var("WOW_CAPTURE_SPELL_LIGHT") else {
return;
};
let coords: Vec<f32> = value
.split(',')
.filter_map(|v| v.trim().parse().ok())
.collect();
let [x, y, z] = coords.as_slice() else {
panic!("WOW_CAPTURE_SPELL_LIGHT needs x,y,z in WoW coordinates");
};
let at = wow_to_bevy([*x, *y, *z]);
let parent = commands
.spawn((Transform::from_translation(at), Visibility::default()))
.id();
let mut light = point_light([1.0, 0.3, 0.05], 1.0);
let base = light.intensity;
light.intensity = 0.0;
let entity = commands
.spawn((
light,
Transform::IDENTITY,
Visibility::default(),
SyntheticFireLight,
SpellFxLight,
HeldLight,
SpellLight::new(base, 0.0, SpellLightMode::Kit),
LightOwner::Instance(parent),
))
.id();
commands.entity(parent).add_child(entity);
info!("capture-spell-light: spawned {entity} wow [{x},{y},{z}] bevy {at:?}");
}
pub(super) fn trace(
points: Res<ResolvedPointLights>,
shadows: Res<TorchShadowViews>,
mut previous: Local<Vec<(usize, usize, usize)>>,
) {
if std::env::var_os("WOW_CAPTURE_SPELL_LIGHT").is_none() {
return;
}
let mut mismatches = Vec::new();
for (point, light) in points.as_slice().iter().enumerate() {
if light.lane < 0.5 {
continue;
}
let candidates: Vec<_> = shadows
.positions
.iter()
.enumerate()
.take(shadows.count as usize)
.filter(|(i, pos)| pos.w > 0.0 && shadows.caster_meshes[*i].is_some())
.map(|(i, pos)| (i, pos.truncate().distance_squared(light.position)))
.filter(|(_, d)| *d < 1.0)
.collect();
let Some(&(first, _)) = candidates.first() else {
continue;
};
let &(nearest, distance) = candidates
.iter()
.min_by(|a, b| a.1.total_cmp(&b.1))
.unwrap();
if first != nearest {
mismatches.push((point, first, nearest));
if !previous.contains(&(point, first, nearest)) {
info!("capture-spell-light: point {point} at {:?}: first slot {first}, nearest slot {nearest}, nearest distance {distance:.6} yd squared (CPU publication; GPU readiness not asserted)", light.position);
}
}
}
if *previous != mismatches {
info!(
"capture-spell-light: {} ambiguous first-match rows",
mismatches.len()
);
*previous = mismatches;
}
}
+388
View File
@@ -0,0 +1,388 @@
//! The create-time challenge modes a modified realm offers, read off the player's own glue chain.
//!
//! Not a 1.12 feature: `GlueXML.toc` on OctoWoW lists a `ChallengeList.xml` after
//! `CreditsFrame.xml`, and the `ChallengeList.lua` beside it builds the whole offer as one Lua
//! table —
//!
//! ```lua
//! Challenges[1] = { selected = false, id = 2, name = CHALLENGE_SLOW_AND_STEADY,
//! text = CHALLENGE_SLOW_AND_STEADY_TEXT, icon = "Spell_Nature_TimeStop", }
//! ```
//!
//! — where `id` is the bit the create packet carries, `name`/`text` are GlueStrings keys and
//! `icon` is an `Interface\Icons` file. That table is the realm's own statement of what it offers,
//! so it is what benilla reads: no list is carried here, and a chain without the file gets no
//! Challenges button and no challenge bytes on the wire.
//!
//! Read through the chain, never from one archive: this install carries the file twice, and the
//! copy that wins (`patch-A.mpq`, eleven entries) is not the newest-looking one (`patch-5.mpq`
//! adds a twelfth, True Hardcore, but loses the mount order to `A`). A reader that opened the
//! "obvious" archive would offer a challenge the running client does not.
//!
//! Parsed without a Lua VM, as `glue_strings.rs` parses `GlueStrings.lua`: the file's own shape is
//! one entry per line and the glue VM does not exist at this point in the boot.
use bevy::prelude::*;
use benilla_assets::{LockRecover, WorldAssets};
const CHALLENGE_LIST: &str = "Interface\\GlueXML\\ChallengeList.lua";
/// The `name` key of the entries whose selection the realm's own Lua gates. Keyed on the key
/// rather than the English text, because that is the identifier `ChallengeList.lua` itself
/// compares (`Challenges[i].name == CHALLENGE_SAMURAI`) and it survives a localized GlueStrings.
pub(crate) const SAMURAI: &str = "CHALLENGE_SAMURAI";
pub(crate) const TOGETHER_FOREVER: &str = "CHALLENGE_TOGETHER_FOREVER";
pub(crate) const LUNATIC: &str = "CHALLENGE_LUNATIC";
pub(crate) const TRUE_HARDCORE: &str = "CHALLENGE_TRUE_HARDCORE";
/// `SAMURAI_ALLOWED_CLASSES` (`ChallengeList.lua`), mirroring the server's own refusal: its
/// `CharacterHandler.cpp` rejects a create packet carrying the samurai bit for any other class, so
/// the picker refuses the click first and says why.
const SAMURAI_CLASSES: [&str; 4] = ["WARRIOR", "PALADIN", "HUNTER", "ROGUE"];
/// One row of the realm's `Challenges` table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Challenge {
/// `id`: the bit this challenge sets in `CMSG_CHAR_CREATE`'s trailing mask.
pub(crate) bit: u32,
/// `name`: a GlueStrings key, resolved for display and compared for the gates above.
pub(crate) name_key: String,
/// `text`: a GlueStrings key holding the tooltip paragraph.
pub(crate) text_key: String,
/// `icon`: a file under `Interface\Icons`, without extension.
pub(crate) icon: String,
}
/// The realm's challenge offer, in `ChallengeList.lua`'s own index order — which is the order
/// `ChallengeList_OnLoad` lays its buttons out in. Absent entirely on a chain with no such file,
/// which is every stock 1.12 install.
#[derive(Resource, Debug, Clone)]
pub(crate) struct Challenges(pub(crate) Vec<Challenge>);
impl Challenges {
/// Why entry `i` may not be selected right now, as a GlueStrings key for the red line the
/// reference appends to the tooltip, or `None` when it may.
///
/// Both rules are `ChallengeList.lua`'s: `ChallengeListEntry_OnMouseUp` refuses the Samurai
/// click off a Warrior/Paladin/Hunter/Rogue, and refuses *selecting* either of Together
/// Forever and Level One Lunatic while the other is held.
pub(crate) fn blocked(&self, i: usize, class_file: &str, staged: u32) -> Option<&'static str> {
let entry = self.0.get(i)?;
if entry.name_key == SAMURAI && !SAMURAI_CLASSES.contains(&class_file) {
return Some("CHALLENGE_SAMURAI_RESTRICTED");
}
let held = |key: &str| {
self.0
.iter()
.any(|c| c.name_key == key && staged & c.bit != 0)
};
let conflict = match entry.name_key.as_str() {
TOGETHER_FOREVER => held(LUNATIC),
LUNATIC => held(TOGETHER_FOREVER),
_ => false,
};
conflict.then_some("CHALLENGE_TF_LUNATIC_EXCLUSIVE")
}
/// Whether entry `i` is offered on this realm at all. True Hardcore is a Hardcore-realm vow —
/// `ChallengeList_ApplyRealmGate` hides it unless `GetServerName()`'s `isRP` is set, because
/// the realm routing puts Hardcore on the RP-flagged realm and the server strips the bit
/// anywhere else.
pub(crate) fn offered(&self, i: usize, realm_is_rp: bool) -> bool {
self.0
.get(i)
.is_some_and(|c| realm_is_rp || c.name_key != TRUE_HARDCORE)
}
/// The mask with every bit this realm cannot honour cleared: `ChallengeList_Save`'s
/// belt-and-suspenders pass, run again at submit so a class switch after a pick cannot send a
/// packet the server would reject.
pub(crate) fn sanitize(&self, mut mask: u32, class_file: &str, realm_is_rp: bool) -> u32 {
for (i, c) in self.0.iter().enumerate() {
if mask & c.bit == 0 {
continue;
}
// The conflict pair is tested against the mask without this entry, so a mask holding
// both loses the earlier of the two rather than both. In the realm's own order that
// is Level One Lunatic, which is exactly the one `ChallengeList_Save` hides.
if !self.offered(i, realm_is_rp) || self.blocked(i, class_file, mask & !c.bit).is_some()
{
mask &= !c.bit;
}
}
mask
}
}
/// Startup: read the realm's table, or leave the resource absent.
pub(crate) fn load_challenges(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let bytes = {
let mut chain = assets.chain.lock_recover();
chain.read_file(CHALLENGE_LIST)
};
let Ok(bytes) = bytes else {
// Not a warning: a stock 1.12 chain has no such file and wants no Challenges button.
debug!("challenges: no {CHALLENGE_LIST} in the chain — the screen shows no Challenges");
return;
};
let list = parse_challenges(&String::from_utf8_lossy(&bytes));
if list.is_empty() {
warn!("challenges: {CHALLENGE_LIST} parsed to no entries — the screen shows no Challenges");
return;
}
info!("challenges: {} offered by the realm's glue", list.len());
commands.insert_resource(Challenges(list));
}
/// The `Challenges[<n>] = { … }` assignments, ascending by `<n>`. A line the realm commented out
/// is skipped, as Lua skips it; a row missing any of the four fields is skipped rather than
/// half-built, because a challenge with no bit cannot be sent and one with no key has no caption.
fn parse_challenges(src: &str) -> Vec<Challenge> {
let mut rows: Vec<(u32, Challenge)> = Vec::new();
for line in src.lines() {
let line = line.trim_start();
let Some(rest) = line.strip_prefix("Challenges[") else {
continue;
};
let Some((index, rest)) = rest.split_once(']') else {
continue;
};
let Ok(index) = index.trim().parse::<u32>() else {
continue;
};
let Some(body) = rest.split_once('{').and_then(|(_, b)| b.split_once('}')) else {
continue;
};
let fields = body.0;
let bit = field(fields, "id").and_then(|v| v.parse::<u32>().ok());
let (Some(bit), Some(name_key), Some(text_key), Some(icon)) = (
bit,
field(fields, "name"),
field(fields, "text"),
field(fields, "icon").map(|v| v.trim_matches('"').to_string()),
) else {
continue;
};
rows.push((
index,
Challenge {
bit,
name_key: name_key.to_string(),
text_key: text_key.to_string(),
icon,
},
));
}
rows.sort_by_key(|(index, _)| *index);
rows.into_iter().map(|(_, c)| c).collect()
}
/// One `<key> = <value>` out of a table body, the value ending at the next comma. Matches the key
/// as a whole word, so `name` does not match inside another key.
fn field<'a>(body: &'a str, key: &str) -> Option<&'a str> {
body.split(',').find_map(|part| {
let (k, v) = part.split_once('=')?;
(k.trim() == key).then(|| v.trim())
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The realm's own file, abridged to the shapes it actually uses: a comment line, the
/// out-of-order tail (`Challenges[7]` is listed before `[8]` but carries the bigger bit), and
/// the `= {` spacing with a trailing comma.
const SRC: &str = r#"
local Challenges = {}
-- Challenges[0] = { selected = false, id = 1, name = CHALLENGE_HARDCORE, text = CHALLENGE_HARDCORE_TEXT, icon = "Ability_Rogue_FeignDeath", }
Challenges[1] = { selected = false, id = 2, name = CHALLENGE_SLOW_AND_STEADY, text = CHALLENGE_SLOW_AND_STEADY_TEXT, icon = "Spell_Nature_TimeStop", }
Challenges[2] = { selected = false, id = 4, name = CHALLENGE_WAR_MODE, text = CHALLENGE_WAR_MODE_TEXT, icon = "Ability_DualWield", }
Challenges[7] = { selected = false, id = 512, name = CHALLENGE_HEROIC, text = CHALLENGE_HEROIC_TEXT, icon = "Ability_Warlock_DemonicEmpowerment", }
Challenges[8] = { selected = false, id = 256, name = CHALLENGE_BREWMASTER, text = CHALLENGE_BREWMASTER_TEXT, icon = "INV_Cask_03", }
local NUM_CHALLENGES = getn(Challenges)
"#;
#[test]
fn the_realms_table_is_read_in_its_own_index_order_with_the_commented_row_skipped() {
let list = parse_challenges(SRC);
assert_eq!(
list.iter().map(|c| c.bit).collect::<Vec<_>>(),
[2, 4, 512, 256],
"index order, not bit order; Hardcore's row is commented out"
);
assert_eq!(list[0].name_key, "CHALLENGE_SLOW_AND_STEADY");
assert_eq!(list[0].text_key, "CHALLENGE_SLOW_AND_STEADY_TEXT");
assert_eq!(list[0].icon, "Spell_Nature_TimeStop", "quotes stripped");
assert_eq!(list[3].icon, "INV_Cask_03");
}
/// Against the player's own chain: the offer is whatever the winning `ChallengeList.lua`
/// says, every bit is a distinct single bit of the mask the create packet carries, and every
/// key it names resolves in the winning `GlueStrings.lua` with an icon on disk. This is the
/// test that says "all the challenges this realm offers" rather than a list we chose. Skips
/// without an install.
#[test]
fn the_offer_is_the_chains_own_list_with_every_caption_and_icon_present() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let bytes = chain.read_file(CHALLENGE_LIST).expect("ChallengeList.lua");
let list = parse_challenges(&String::from_utf8_lossy(&bytes));
assert!(!list.is_empty(), "the chain carries a challenge list");
let mut seen = 0u32;
for c in &list {
assert!(
c.bit.is_power_of_two(),
"{} is {:#x}, not one bit of the mask",
c.name_key,
c.bit
);
assert_eq!(seen & c.bit, 0, "{} repeats a bit", c.name_key);
seen |= c.bit;
}
let strings = crate::glue_strings::table_from_chain(&mut chain);
for c in &list {
assert!(
strings.get(&c.name_key).is_some(),
"no name for {}",
c.name_key
);
assert!(
strings.get(&c.text_key).is_some(),
"no text for {}",
c.text_key
);
let icon = format!("Interface\\Icons\\{}.blp", c.icon);
assert!(chain.contains(&icon), "{} has no {icon}", c.name_key);
}
// The picker's own chrome strings, and the two refusal lines the gates show.
for key in [
"CHALLENGES",
"CHALLENGES_TEXT",
"CHALLENGE_SAMURAI_RESTRICTED",
"CHALLENGE_TF_LUNATIC_EXCLUSIVE",
] {
assert!(strings.get(key).is_some(), "the chain has no {key}");
}
eprintln!(
"challenges this chain offers: {}",
list.iter()
.map(|c| format!("{} ({:#x})", c.name_key, c.bit))
.collect::<Vec<_>>()
.join(", ")
);
}
#[test]
fn a_row_missing_a_field_is_no_row() {
let list = parse_challenges(
"Challenges[1] = { selected = false, name = C_A, text = C_A_TEXT, icon = \"i\", }\n\
Challenges[2] = { selected = false, id = 4, text = C_B_TEXT, icon = \"i\", }\n\
Challenges[3] = { selected = false, id = 8, name = C_C, text = C_C_TEXT, icon = \"j\", }\n",
);
assert_eq!(list.len(), 1, "only the whole row survives");
assert_eq!(list[0].bit, 8);
}
fn catalog() -> Challenges {
Challenges(vec![
Challenge {
bit: 32,
name_key: LUNATIC.into(),
text_key: "T".into(),
icon: "i".into(),
},
Challenge {
bit: 1024,
name_key: SAMURAI.into(),
text_key: "T".into(),
icon: "i".into(),
},
Challenge {
bit: 2048,
name_key: TOGETHER_FOREVER.into(),
text_key: "T".into(),
icon: "i".into(),
},
Challenge {
bit: 4096,
name_key: TRUE_HARDCORE.into(),
text_key: "T".into(),
icon: "i".into(),
},
])
}
/// The Samurai gate is the server's own class list; the realm's Lua mirrors it so the refusal
/// happens before a packet the server would reject.
#[test]
fn samurai_is_refused_off_the_four_classes_the_server_allows() {
let c = catalog();
for class in ["WARRIOR", "PALADIN", "HUNTER", "ROGUE"] {
assert_eq!(c.blocked(1, class, 0), None, "{class} may take Samurai");
}
for class in ["MAGE", "PRIEST", "DRUID", "WARLOCK", "SHAMAN"] {
assert_eq!(
c.blocked(1, class, 0),
Some("CHALLENGE_SAMURAI_RESTRICTED"),
"{class} may not"
);
}
}
/// Together Forever and Level One Lunatic block each other, in both directions, and only
/// while the other is actually held.
#[test]
fn together_forever_and_lunatic_block_each_other_both_ways() {
let c = catalog();
assert_eq!(c.blocked(0, "MAGE", 0), None, "nothing held");
assert_eq!(c.blocked(2, "MAGE", 0), None, "nothing held");
assert_eq!(
c.blocked(0, "MAGE", 2048),
Some("CHALLENGE_TF_LUNATIC_EXCLUSIVE"),
"Lunatic while Together Forever is held"
);
assert_eq!(
c.blocked(2, "MAGE", 32),
Some("CHALLENGE_TF_LUNATIC_EXCLUSIVE"),
"Together Forever while Lunatic is held"
);
}
/// True Hardcore is offered only on the RP-flagged realm the Hardcore routing points at.
#[test]
fn true_hardcore_is_offered_only_on_the_rp_flagged_realm() {
let c = catalog();
assert!(!c.offered(3, false), "hidden off the Hardcore realm");
assert!(c.offered(3, true));
assert!(c.offered(0, false), "every other entry is always offered");
}
/// Submit-time cleanup drops a bit the live class or realm no longer allows, and keeps the
/// first of a conflicting pair rather than throwing both away.
#[test]
fn sanitize_drops_only_the_bits_the_realm_would_reject() {
let c = catalog();
assert_eq!(
c.sanitize(1024, "WARRIOR", true),
1024,
"a legal pick rides"
);
assert_eq!(
c.sanitize(1024, "MAGE", true),
0,
"the class changed under it"
);
assert_eq!(c.sanitize(4096, "MAGE", false), 0, "not the Hardcore realm");
assert_eq!(
c.sanitize(32 | 2048, "MAGE", true),
2048,
"the earlier of the pair goes: Lunatic, the one `ChallengeList_Save` hides"
);
assert_eq!(c.sanitize(0, "MAGE", false), 0);
}
}
@@ -0,0 +1,645 @@
//! The create screen's Challenges header and its picker, the realm's own `ChallengeList.xml` in
//! Bevy UI. Spawned only where [`crate::challenges::Challenges`] exists, which is only on a chain
//! that ships the file; a stock 1.12 install sees none of it.
//!
//! Every offset below is that file's, resolved against `CharacterCreate.xml`'s own frames:
//! `ChallengeIconsHeader` is TOPRIGHT-anchored to `CharacterCreateFactionIconFrame`'s TOPLEFT
//! (-15, 0), and that icon frame hangs at (-3, +8) off `CharacterCreateCharacterFaction`, the
//! 240-wide panel at the screen's TOPRIGHT (-20, -20) — so the button's right edge is 278 in from
//! the right and its top is 12 down.
//!
//! Deviations, both for want of a floating tooltip inside a scrolling list: the entry description
//! is a panel pinned beside the frame rather than `ChallengesTooltip` following the row, and the
//! `UI-DialogBox-Gold-Dragon2/3` ornaments are not drawn.
use bevy::prelude::*;
use bevy::ui_render::ui_material::MaterialNode;
use crate::challenges::Challenges;
use crate::glue::art::{GlueArt, DIM, GOLD, INFO_TEXT};
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
use crate::glue::widgets::{
abs, glue_button, outlined_text, outlined_text_centered, overlay, ArtSwap, GlueBtnKind,
GlueText, Hilight,
};
use crate::glue_strings::GlueStrings;
use super::parts::{ChallengeFrame, ChallengeSelected, ChallengeTip, DynIcon, DynText};
use super::CreateAction;
/// `ChallengesTooltip_Update`'s `|cffFF0000` run, the colour a refused entry's reason is in.
const REFUSAL: Color = Color::srgb(1.0, 0.0, 0.0);
/// `ChallengeIconsHeader`'s right inset and top, resolved above; the stacked `ChallengeIcon<n>`
/// repeat down at the template's 48 + the 5 its anchor leaves.
const HEADER_RIGHT: f32 = 20.0 + 240.0 + 3.0 + 15.0;
const HEADER_TOP: f32 = 12.0;
const ICON: f32 = 48.0;
const ICON_PITCH: f32 = ICON + 5.0;
/// `ChallengeListFrame`, 640×512 at CENTER (+24, 0), and its `ChallengeListScrollFrame`
/// (540×407 at TOPLEFT (20, -58)).
const FRAME: (f32, f32, f32) = (640.0, 512.0, 24.0);
const SCROLL: (f32, f32, f32, f32) = (20.0, 58.0, 540.0, 407.0);
/// `ChallengeEntryTemplate`, two to a row inside the 540-wide scroll child; entry 1 hangs off
/// `ChallengesText`'s BOTTOMLEFT (-6, -5), and that `FontString` sits at the child's TOP (0, -3)
/// 10 in from its left — so the grid starts at x 4, five under the blurb.
const ENTRY: (f32, f32) = (270.0, 80.0);
const ENTRY_LEFT: f32 = 4.0;
/// The modal sits over the create screen's own 1100, like the select screen's dialogs.
const PICKER_Z: i32 = 1200;
/// The Challenges button and the icon column under it, drawn over the create screen's chrome.
pub(super) fn header(
ui: &mut ChildSpawnerCommands,
art: &GlueArt,
font: &Handle<Font>,
strings: &GlueStrings,
list: &Challenges,
s: f32,
) {
let px = |v: f32| Val::Px(v * s);
// The header plus one slot per challenge: `ChallengeList_OnLoad` creates exactly that many
// `ChallengeIcon<n>` and `ChallengeList_OnHide` shows the taken ones from the top.
for slot in 0..=list.0.len() as u8 {
let mut b = ui.spawn((
CreateAction::Challenges(slot),
Button,
Node {
position_type: PositionType::Absolute,
right: px(HEADER_RIGHT),
top: px(HEADER_TOP + f32::from(slot) * ICON_PITCH),
width: px(ICON),
height: px(ICON),
// Every slot but the header starts hidden; the refresh shows the taken ones.
display: if slot == 0 {
Display::Flex
} else {
Display::None
},
..default()
},
));
match (slot, &art.challenge_plus) {
(0, Some(plus)) => {
b.insert(ImageNode::new(plus.clone()));
}
(0, None) => {
b.insert(BackgroundColor(GOLD));
}
// A taken challenge's own icon, which the refresh assigns; transparent until then,
// since a default `ImageNode` is a white square.
_ => {
b.insert((
DynIcon::Challenge(slot - 1),
ImageNode {
color: Color::NONE,
..default()
},
));
}
}
if let Some(hi) = &art.hilight {
b.with_children(|b| {
b.spawn((
Hilight,
Visibility::Hidden,
MaterialNode(hi.clone()),
overlay(),
));
});
}
}
// `<FontString … text="CHALLENGES"/>`, RIGHT-anchored to the button's LEFT (-6), so it is
// right-justified into the gap and centred on the 48-tall button.
outlined_text(
ui,
Node {
position_type: PositionType::Absolute,
right: px(HEADER_RIGHT + ICON + 6.0),
top: px(HEADER_TOP),
width: px(220.0),
height: px(ICON),
justify_content: JustifyContent::End,
align_items: AlignItems::Center,
..default()
},
(),
(),
GlueText {
text: strings.text("CHALLENGES", "Challenges"),
size: 18.0, // GlueFontNormalLarge
color: GOLD,
wrap: false,
},
font,
s,
);
}
/// The `ChallengeList` modal: the `HelpFrame-*` plate, the blurb, one row per challenge and the
/// Okay/Cancel pair. Spawned hidden; the refresh shows it while the picker is open.
pub(super) fn picker(
commands: &mut Commands,
root: Entity,
art: &GlueArt,
font: &Handle<Font>,
strings: &GlueStrings,
list: &Challenges,
s: f32,
) {
let px = |v: f32| Val::Px(v * s);
let (fw, fh, off_x) = FRAME;
commands
.spawn((
ChallengeFrame,
ChildOf(root),
GlobalZIndex(PICKER_Z),
// The reference's full-screen BACKGROUND layer, black at 0.75; with no `FocusPolicy`
// it also keeps every click off the screen behind, as a `toplevel` frame does.
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.75)),
Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
display: Display::None,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
))
.with_children(|overlay_ui| {
overlay_ui
.spawn(Node {
width: px(fw),
height: px(fh),
left: px(off_x),
..default()
})
.with_children(|b| {
plate(b, art, font, strings, s);
tooltip(b, art, font, s);
entries(b, art, font, strings, list, s);
// `ChallengeListCancelButton` 125×35 at BOTTOMRIGHT (-46, 13), Okay 8 left of
// its LEFT edge.
for (action, key, fallback, right) in [
(CreateAction::ChallengeCancel, "CANCEL", "Cancel", 46.0),
(
CreateAction::ChallengeOkay,
"OKAY",
"Okay",
46.0 + 125.0 + 8.0,
),
] {
b.spawn(Node {
position_type: PositionType::Absolute,
right: px(right),
bottom: px(13.0),
..default()
})
.with_children(|slot| {
glue_button(
slot,
art,
font,
action,
strings.text(key, fallback),
125.0,
35.0,
GlueBtnKind::Normal,
s,
);
});
}
});
});
}
/// The six `HelpFrame-*` pieces, the `UI-DialogBox-Header` plate with `CHALLENGES`, and the
/// `GlueCloseButton` that cancels.
fn plate(
b: &mut ChildSpawnerCommands,
art: &GlueArt,
font: &Handle<Font>,
strings: &GlueStrings,
s: f32,
) {
let px = |v: f32| Val::Px(v * s);
let (fw, _, _) = FRAME;
match &art.help_frame {
Some(hf) => {
for (img, l, t, w, h) in [
(&hf.tl, 0.0, 0.0, 256.0, 256.0),
(&hf.top, 256.0, 0.0, 256.0, 256.0),
(&hf.tr, 512.0, 0.0, 128.0, 256.0),
(&hf.bl, 0.0, 256.0, 256.0, 256.0),
(&hf.bottom, 256.0, 256.0, 256.0, 256.0),
(&hf.br, 512.0, 256.0, 128.0, 256.0),
] {
b.spawn((ImageNode::new(img.clone()), abs(s, l, t, w, h)));
}
}
None => {
b.spawn((
BackgroundColor(Color::srgba(0.05, 0.05, 0.08, 0.95)),
overlay(),
));
}
}
if let Some((header, _)) = &art.dialog_header {
b.spawn((
ImageNode::new(header.clone()),
abs(s, (fw - 256.0) / 2.0 - 12.0, -12.0, 256.0, 64.0),
));
}
outlined_text_centered(
b,
Node {
position_type: PositionType::Absolute,
left: px((fw - 256.0) / 2.0 - 12.0),
top: px(2.0),
width: px(256.0),
justify_content: JustifyContent::Center,
..default()
},
(),
(),
GlueText {
text: strings.text("CHALLENGES", "Challenges"),
size: 12.0, // GlueFontNormalSmall
color: GOLD,
wrap: false,
},
font,
s,
);
let mut x = b.spawn((
CreateAction::ChallengeCancel,
Button,
abs(s, fw - 42.0 - 32.0, 3.0, 32.0, 32.0),
));
match &art.close_btn {
Some(cb) => {
x.insert((
ImageNode::new(cb.up.clone()),
ArtSwap {
up: cb.up.clone(),
down: cb.down.clone(),
},
));
if let Some(hi) = &cb.hi {
x.with_children(|x| {
x.spawn((
Hilight,
Visibility::Hidden,
MaterialNode(hi.clone()),
overlay(),
));
});
}
}
None => {
x.with_children(|x| {
outlined_text(
x,
Node {
left: px(10.0),
top: px(6.0),
..default()
},
(),
(),
GlueText {
text: "X",
size: 14.0,
color: GOLD,
wrap: false,
},
font,
s,
);
});
}
}
}
/// `ChallengesText` and one `ChallengeEntryTemplate` per challenge, two to a row, inside the
/// scrolling box and beside its `GlueScrollFrameTemplate` bar.
///
/// Both are flex children of the box rather than absolutely placed: the content height is what
/// `max_scroll` measures, and an absolute child contributes none of it, so the list would not
/// scroll at all.
fn entries(
b: &mut ChildSpawnerCommands,
art: &GlueArt,
font: &Handle<Font>,
strings: &GlueStrings,
list: &Challenges,
s: f32,
) {
let px = |v: f32| Val::Px(v * s);
let (sl, st, sw, sh) = SCROLL;
let scroll = b
.spawn((
// Wheel-scrolled by the create screen's own `scroll_info`, which drives every
// `InfoScroll` box the pointer is over.
super::parts::InfoScroll,
Interaction::default(),
bevy::ui::FocusPolicy::Block,
ScrollPosition::default(),
Node {
position_type: PositionType::Absolute,
left: px(sl),
top: px(st),
width: px(sw),
height: px(sh),
flex_direction: FlexDirection::Column,
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|scroll| {
outlined_text_centered(
scroll,
Node {
width: px(520.0),
margin: UiRect::new(px(10.0), px(0.0), px(3.0), px(5.0)),
..default()
},
(),
(),
GlueText {
text: strings.text("CHALLENGES_TEXT", ""),
size: 12.0, // GlueFontHighlightSmall
color: INFO_TEXT,
wrap: true,
},
font,
s,
);
scroll
.spawn(Node {
width: px(2.0 * ENTRY.0),
margin: UiRect::left(px(ENTRY_LEFT)),
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
..default()
})
.with_children(|grid| {
for (i, challenge) in list.0.iter().enumerate() {
entry(grid, art, font, strings, i, challenge, s);
}
});
})
.id();
if let Some(sc) = &art.scroll {
// `ChallengeListScrollFrame`'s own three `UI-Character-ScrollBar` cuts, all 31 wide and
// all hung 2 left of the frame's right edge: `$parentTop` 256 tall from 5 above the
// frame's top, `$parentBottom` 106 tall ending 2 below its bottom, and `$parentMiddle`
// stretched between them. The middle is the piece the CharacterCreate track has no
// answer for — without it a bar this tall shows bare backdrop between the two ends.
if let Some((track, size)) = &sc.challenge_track {
for (y, h, tc) in track_pieces(st, sh) {
b.spawn((
super::parts::ScrollHides { scroll },
Visibility::Hidden,
ImageNode {
image: track.clone(),
rect: Some(crate::glue::art::tc_rect(*size, tc)),
..default()
},
abs(s, sl + sw - 2.0, y, 31.0, h),
));
}
}
// `GlueScrollFrameTemplate` hangs its slider off the frame's TOPRIGHT (6, -16) and
// BOTTOMRIGHT (6, 16): 16 wide, inset by an arrow's height at each end.
super::panels::scroll_slider(b, sc, scroll, (sl + sw + 6.0, st + 16.0, sh - 32.0), s);
}
}
/// The three `UI-Character-ScrollBar` cuts behind the challenge list's slider, as
/// `(top, height, texcoords)` in the modal's own coordinates, for a scroll frame at `st` with
/// height `sh`.
///
/// The pieces tile: `$parentMiddle` is anchored TOP-to-`$parentTop`'s BOTTOM and
/// BOTTOM-to-`$parentBottom`'s TOP, so its authored 60 is a placeholder the anchors stretch —
/// the gap is whatever the other two leave, and the reference's 407-tall frame leaves 52.
fn track_pieces(st: f32, sh: f32) -> [(f32, f32, [f32; 4]); 3] {
let top_y = st - 5.0;
let mid_y = top_y + 256.0;
let bottom_y = st + sh + 2.0 - 106.0;
[
(top_y, 256.0, [0.0, 0.484_375, 0.0, 1.0]),
(mid_y, bottom_y - mid_y, [0.0, 0.484_375, 0.75, 1.0]),
(bottom_y, 106.0, [0.515_625, 1.0, 0.0, 0.414_062_5]),
]
}
/// One `ChallengeEntry<n>`: the selected band, the 40² icon at LEFT (20, 2), the name beside it,
/// and the hover sheen the shared pass lights.
fn entry(
grid: &mut ChildSpawnerCommands,
art: &GlueArt,
font: &Handle<Font>,
strings: &GlueStrings,
i: usize,
challenge: &crate::challenges::Challenge,
s: f32,
) {
let px = |v: f32| Val::Px(v * s);
grid.spawn((
CreateAction::ChallengeEntry(i as u8),
Button,
// No `LockHighlight`: the reference draws the taken state with its own `$parentSelected`
// band, not by locking the hover sheen on.
Node {
width: px(ENTRY.0),
height: px(ENTRY.1),
..default()
},
))
.with_children(|e| {
if let Some(sel) = &art.select_highlight {
e.spawn((
ChallengeSelected(i as u8),
MaterialNode(sel.clone()),
Node {
display: Display::None, // shown by the refresh while this one is staged
..overlay()
},
));
}
match art.challenge_icons.get(i).and_then(Option::as_ref) {
Some(icon) => {
e.spawn((
ImageNode::new(icon.clone()),
abs(s, 20.0, (ENTRY.1 - 40.0) / 2.0 - 2.0, 40.0, 40.0),
));
}
None => {
e.spawn((
BackgroundColor(DIM),
abs(s, 20.0, (ENTRY.1 - 40.0) / 2.0 - 2.0, 40.0, 40.0),
));
}
}
outlined_text(
e,
Node {
position_type: PositionType::Absolute,
left: px(62.0),
top: px(0.0),
width: px(200.0),
height: px(ENTRY.1),
align_items: AlignItems::Center,
..default()
},
(),
(),
GlueText {
text: strings.text(&challenge.name_key, &challenge.name_key),
size: 18.0, // GlueFontNormalLarge
color: GOLD,
wrap: true,
},
font,
s,
);
if let Some(hi) = &art.challenge_hilight {
e.spawn((
Hilight,
Visibility::Hidden,
MaterialNode(hi.clone()),
overlay(),
));
}
});
}
/// `ChallengesTooltip`: 220 wide, its own `UI-Tooltip-Border` over a (0.09, 0.09, 0.19) fill.
///
/// Deviation: pinned beside the frame rather than floating at the hovered row's TOPLEFT (-14, 0),
/// because the rows live inside a clipping scroll box that a floating panel cannot leave.
fn tooltip(b: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Font>, s: f32) {
let px = |v: f32| Val::Px(v * s);
const TIP_W: f32 = 220.0;
const TIP_FILL: Color = Color::srgb(0.09, 0.09, 0.19);
let mut tip = b.spawn((
ChallengeTip,
Visibility::Hidden,
Node {
position_type: PositionType::Absolute,
right: px(FRAME.0 + 14.0),
top: px(SCROLL.1),
width: px(TIP_W),
padding: UiRect::all(px(10.0)),
flex_direction: FlexDirection::Column,
row_gap: px(2.0),
..default()
},
));
let framed = art.tooltip_border.is_some() && art.tooltip_bg.is_some();
if !framed {
tip.insert(BackgroundColor(TIP_FILL));
}
tip.with_children(|tip| {
if framed {
tip.spawn((
tiled_bg_node(art.tooltip_bg.clone().unwrap(), 16.0, s, TIP_FILL),
overlay(),
));
backdrop_border(
tip,
art.tooltip_border.as_ref().unwrap(),
16.0,
Color::WHITE,
);
}
for (marker, size, color) in [
(DynText::ChallengeTipTitle, 15.0, GOLD), // GlueFontNormal
(DynText::ChallengeTipNotes, 12.0, INFO_TEXT), // GlueFontNormalSmall, white
// The reference appends this to the notes as a `|cffFF0000` run; a refresh writes a
// `Text` whole and never re-decodes markup, so it is its own red string instead.
(DynText::ChallengeTipRefusal, 12.0, REFUSAL),
] {
outlined_text(
tip,
Node {
width: px(200.0),
..default()
},
(),
marker,
GlueText {
text: "",
size,
color,
wrap: true,
},
font,
s,
);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// `ChallengeList.xml`'s own numbers, read off the realm's file: the frame is 540×407 at
/// (20, -58), `$parentTop` is 256 tall from 5 above it and `$parentBottom` 106 tall ending
/// 2 below it, leaving `$parentMiddle` the 52 between.
#[test]
fn the_track_matches_the_realms_own_scroll_frame() {
let (_, st, _, sh) = SCROLL;
let [top, mid, bottom] = track_pieces(st, sh);
assert_eq!((top.0, top.1), (53.0, 256.0), "$parentTop");
assert_eq!((mid.0, mid.1), (309.0, 52.0), "$parentMiddle");
assert_eq!((bottom.0, bottom.1), (361.0, 106.0), "$parentBottom");
}
/// The whole point of carrying a middle piece: the track is continuous at every height, so
/// no bar shows bare backdrop between its ends.
#[test]
fn the_three_pieces_tile_without_a_gap() {
for sh in [SCROLL.3, 407.0, 500.0, 800.0] {
let [top, mid, bottom] = track_pieces(SCROLL.1, sh);
assert_eq!(top.0 + top.1, mid.0, "top meets middle at height {sh}");
assert_eq!(
mid.0 + mid.1,
bottom.0,
"middle meets bottom at height {sh}"
);
assert!(
mid.1 > 0.0,
"the middle has to stretch, not invert, at {sh}"
);
}
}
/// The sheet is 64×256 and the file cuts it in two columns: the top and middle come off the
/// left (0→31), the bottom off the right (33→64).
#[test]
fn the_cuts_are_the_files_texcoords() {
let [top, mid, bottom] = track_pieces(SCROLL.1, SCROLL.3);
assert_eq!(top.2, [0.0, 0.484_375, 0.0, 1.0]);
assert_eq!(mid.2, [0.0, 0.484_375, 0.75, 1.0]);
assert_eq!(bottom.2, [0.515_625, 1.0, 0.0, 0.414_062_5]);
let size = Vec2::new(64.0, 256.0);
for (piece, want) in [(top, 31.0), (mid, 31.0), (bottom, 31.0)] {
let r = crate::glue::art::tc_rect(size, piece.2);
assert_eq!(
r.width(),
want,
"every cut is the 31 the file's <Size> declares"
);
}
assert_eq!(
crate::glue::art::tc_rect(size, bottom.2).height(),
106.0,
"the bottom cut is exactly its own 106 tall, so it is drawn unscaled"
);
}
}
+257 -21
View File
@@ -2,6 +2,7 @@
//! driving the `"create"` preview booth, with art, captions and sounds read from the player's
//! install. This file holds the selection state, input and the wire.
mod challenge_list;
mod panels;
mod parts;
mod refresh;
@@ -16,11 +17,12 @@ use bevy::input::mouse::AccumulatedMouseMotion;
use bevy::input::ButtonState;
use bevy::prelude::*;
use crate::challenges::Challenges;
use crate::char_select::{ClientState, Roster};
use crate::entities::CharCreate;
use crate::glue_strings::GlueStrings;
use crate::net::{CharActionResultMessage, CharPick, CharRequest};
use crate::portrait::{CreateLook, GluePreview};
use crate::portrait::{CreateLook, GlueLook, GluePreview};
use crate::sound::GlueSound;
/// Alliance and Horde race columns, top to bottom: `CharacterCreateEnumerateRaces` fills buttons
@@ -28,13 +30,14 @@ use crate::sound::GlueSound;
/// second, so Alliance then Horde, ascending race id. `RACE_ICON_TCOORDS`'s table order is a
/// name-to-UV lookup and never the layout order.
///
/// The stock 1.12 chain shows the first four of each; TurtleWoW appends its Blood Elf (10) to the
/// Alliance and its Goblin (9) to the Horde, and its XML chains five rows per column. Which of
/// these actually get a button is [`race_columns`]' call, against the catalog's creatable set.
/// The stock 1.12 chain shows the first four of each; TurtleWoW appends its race 10 (art token
/// `BloodElf`; the realm's `ChrRaces.dbc` names it, "High Elf" on OctoWoW) to the Alliance and its
/// Goblin (9) to the Horde, and its XML chains five rows per column. Which of these actually get a
/// button is [`race_columns`]' call, against the catalog's creatable set.
///
/// The Alliance half is also the race-to-side split `ui_unit::race_faction_group` answers
/// `UnitFactionGroup("player")` with.
pub(crate) const ALLIANCE: [u8; 5] = [1, 3, 4, 7, 10]; // Human, Dwarf, Night Elf, Gnome, Blood Elf
pub(crate) const ALLIANCE: [u8; 5] = [1, 3, 4, 7, 10]; // Human, Dwarf, Night Elf, Gnome, BloodElf
const HORDE: [u8; 5] = [2, 5, 6, 8, 9]; // Orc, Scourge, Tauren, Troll, Goblin
/// The race buttons the create screen shows, split into its two columns, in `GetAvailableRaces`
@@ -71,11 +74,11 @@ impl Plugin for CharCreatePlugin {
Update,
(
screen::rescale_screen,
create_input.run_if(crate::ui_script::glue::native_glue),
create_input,
debug_auto_create,
debug_pick,
debug_shot,
rotate_model.run_if(crate::ui_script::glue::native_glue),
rotate_model,
refresh::refresh_dynamic,
refresh_name_box,
refresh::refresh_hover,
@@ -86,8 +89,6 @@ impl Plugin for CharCreatePlugin {
)
.chain()
.before(crate::glue::GlueVisuals)
// The create screen writes the glue booth's preview.
.in_set(crate::portrait::GluePreviewFeed)
.run_if(in_state(ClientState::CharCreate))
.after(benilla_world::schedule::WorldStage::Net),
);
@@ -144,6 +145,7 @@ fn debug_enter(
fn debug_pick(
state: Res<State<ClientState>>,
catalog: Option<Res<CharCreate>>,
challenges: Option<Res<Challenges>>,
mut sel: ResMut<CreateSelection>,
mut preview: ResMut<GluePreview>,
mut done: Local<bool>,
@@ -151,13 +153,16 @@ fn debug_pick(
if *done {
return;
}
let Ok(spec) = std::env::var("WOW_CHARCREATE_PICK") else {
let pick = std::env::var("WOW_CHARCREATE_PICK");
let open = std::env::var("WOW_CHARCREATE_CHALLENGES");
if pick.is_err() && open.is_err() {
*done = true;
return;
};
}
if *state.get() != ClientState::CharCreate {
return;
}
let spec = pick.unwrap_or_default();
let mut it = spec.split(',').map(|s| s.trim().parse::<u8>().ok());
sel.race = it.next().flatten().unwrap_or(1).max(1);
sel.sex = it.next().flatten().unwrap_or(0).min(1);
@@ -190,7 +195,21 @@ fn debug_pick(
sel.dials[i] = want.min(n - 1);
}
}
preview.show_create(sel.race, sel.look());
// `WOW_CHARCREATE_CHALLENGES=<mask>`: raise `ChallengeList` with that mask staged (an empty
// value or a word stages nothing), so a capture can photograph the picker itself.
if let Ok(mask) = open {
if challenges.is_some() {
sel.picker = Some(mask.trim().parse::<u32>().unwrap_or(0));
info!(
"char create: challenge instrument opened the picker with {:#x} staged",
sel.picker.unwrap_or(0)
);
} else {
warn!("char create: challenge instrument set, but this chain offers no challenges");
}
}
preview.scene = Some(crate::portrait::GlueScene::Race(sel.race));
preview.look = Some(GlueLook::Create(sel.look()));
info!(
"char create: pick instrument set race {} sex {} class {} dials {:?}",
sel.race, sel.sex, sel.class, sel.dials
@@ -240,6 +259,25 @@ pub(crate) struct CreateSelection {
name: EditBoxState,
/// Waiting on `SMSG_CHAR_CREATE`: Create is disarmed.
creating: bool,
/// The committed challenge mask, `None` on a realm whose glue offers no challenges — which is
/// also what keeps the trailing `u32` off the wire there. `Some(0)` is "offered, none taken",
/// which the realm's own client still sends.
challenges: Option<u32>,
/// The picker's staged mask while `ChallengeList` is up; Okay commits it, Cancel drops it.
/// `ChallengeList_Reset` is this `None`.
picker: Option<u32>,
/// Whether this roster's realm is the RP-flagged one, read once at show as the reference's
/// `ChallengeList_ApplyRealmGate` reads `GetServerName()` — and kept here rather than read
/// per frame so the two per-frame systems never touch [`Roster`], which `char_select` writes
/// in the same schedule.
realm_is_rp: bool,
}
impl CreateSelection {
/// Whether challenge entry `i` is staged in the open picker (or committed, with it closed).
fn challenge_taken(&self, bit: u32) -> bool {
self.picker.or(self.challenges).unwrap_or(0) & bit != 0
}
}
/// The five dial counts for a (race, sex), or `[1; 5]` without the catalog.
@@ -251,8 +289,14 @@ fn dial_counts(catalog: Option<&CharCreate>, race: u8, sex: u8) -> [u8; 5] {
}
impl CreateSelection {
/// Reset to Human, male, its first class, dials 0.
fn reset(&mut self, catalog: Option<&CharCreate>) {
/// Reset to Human, male, its first class, dials 0. `CharacterCreate_OnShow` is also where the
/// realm's `ChallengeList.lua` zeroes its selection and re-applies the realm gate.
fn reset(
&mut self,
catalog: Option<&CharCreate>,
challenges: Option<&Challenges>,
roster: Option<&Roster>,
) {
self.race = 1;
self.sex = 0;
self.class = catalog
@@ -261,6 +305,9 @@ impl CreateSelection {
self.dials = [0; 5];
self.name.set_text("");
self.creating = false;
self.challenges = challenges.map(|_| 0);
self.picker = None;
self.realm_is_rp = realm_is_rp(roster);
}
/// Re-clamp class and dials into the current (race, sex)'s valid ranges.
@@ -296,6 +343,7 @@ impl CreateSelection {
fn request(&self) -> CharCreateReq {
CharCreateReq {
challenge_mask: self.challenges,
name: self.name.text.clone(),
race: self.race,
class: self.class,
@@ -322,6 +370,13 @@ enum CreateAction {
Randomize,
Create,
Back,
/// `ChallengeIconsHeader`, and each `ChallengeIcon<n>` stacked under it: all of them open
/// `ChallengeList` (`ChallengeIconTemplate`'s `OnClick`). The index is the icon slot.
Challenges(u8),
/// One `ChallengeEntry<n>` in the open picker, by catalog index.
ChallengeEntry(u8),
ChallengeOkay,
ChallengeCancel,
/// Hold to rotate, like the reference's rotate buttons.
RotateLeft,
RotateRight,
@@ -363,10 +418,10 @@ fn create_input(
mut keyboard: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
catalog: Option<Res<CharCreate>>,
challenges: Option<Res<Challenges>>,
mut sel: ResMut<CreateSelection>,
// The host pasteboard and the window handle its Wayland backend needs, and the layout that
// names the clipboard chords' letters.
(mut clipboard, layout): (NonSendMut<HostClipboard>, Res<crate::bindings::LayoutNames>),
// The host pasteboard and the window handle its Wayland backend needs.
mut clipboard: NonSendMut<HostClipboard>,
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
time: Res<Time>,
mut preview: ResMut<GluePreview>,
@@ -434,10 +489,45 @@ fn create_input(
sounds.write(GlueSound("gsCharacterCreationCancel"));
next.set(ClientState::CharSelect);
}
// `ChallengeIconTemplate`'s `OnClick`: every icon opens the list, staged from the
// committed mask (`ChallengeList_Reset`).
CreateAction::Challenges(_) => {
if sel.challenges.is_some() && sel.picker.is_none() {
sel.picker = sel.challenges;
}
}
CreateAction::ChallengeEntry(i) => toggle_challenge(&mut sel, challenges.as_deref(), i),
CreateAction::ChallengeOkay => {
// `ChallengeList_OnOk`: Save then Hide, with the realm's own click sound.
if let Some(staged) = sel.picker.take() {
sel.challenges = Some(staged);
sounds.write(GlueSound("gsLoginChangeRealmOK"));
}
}
CreateAction::ChallengeCancel => {
if sel.picker.take().is_some() {
sounds.write(GlueSound("gsLoginChangeRealmCancel"));
}
}
CreateAction::RotateLeft | CreateAction::RotateRight | CreateAction::Model => {}
}
}
// `ChallengeList` is `toplevel` with `enableKeyboard`, so while it is up its own `OnKeyDown`
// takes ENTER (Save and hide) and ESCAPE (Reset and hide) and the screen beneath sees neither
// — nor any typing, which would otherwise land in the hidden name box.
if sel.picker.is_some() {
keyboard.clear();
if keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter) {
if let Some(staged) = sel.picker.take() {
sel.challenges = Some(staged);
}
}
if keys.just_pressed(KeyCode::Escape) {
sel.picker = None;
}
return;
}
for ev in keyboard.read() {
if ev.state != ButtonState::Pressed {
continue;
@@ -446,7 +536,6 @@ fn create_input(
&mut sel.name,
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Letters,
@@ -463,13 +552,55 @@ fn create_input(
next.set(ClientState::CharSelect);
}
if do_create && !sel.creating {
if do_create && !sel.creating && sel.picker.is_none() {
sel.creating = true;
// `ChallengeList_Save`'s belt-and-suspenders pass, run at submit: a class or realm that
// moved after a pick cannot send a bit the server would reject the whole packet for.
if let (Some(list), Some(mask)) = (challenges.as_deref(), sel.challenges) {
let clean = list.sanitize(mask, class_file(sel.class), sel.realm_is_rp);
if clean != mask {
info!("char create: dropped challenge bits {:#x} the realm would refuse for a {} — sending {clean:#x}", mask & !clean, class_file(sel.class));
}
sel.challenges = Some(clean);
}
sounds.write(GlueSound("gsCharacterCreationCreateChar"));
let _ = pick.0.send(CharRequest::Create(sel.request()));
}
if changed_look {
preview.show_create(sel.race, sel.look());
preview.scene = Some(crate::portrait::GlueScene::Race(sel.race));
preview.look = Some(GlueLook::Create(sel.look()));
}
}
/// Whether the realm this roster came from is the RP-flagged one, which is where the Hardcore
/// routing lands and therefore the only realm True Hardcore is offered on
/// (`ChallengeList_ApplyRealmGate` reads the same `GetServerName()` triple). No realm in hand —
/// a capture with no login — reads as not it, so the vow is never offered unasked.
fn realm_is_rp(roster: Option<&Roster>) -> bool {
roster
.and_then(|r| r.realm.as_ref())
.is_some_and(|realm| crate::realm_select::pvp_rp(realm.realm_type).1)
}
/// `ChallengeListEntry_OnMouseUp`: flip entry `i`, except that a *selection* the realm's own Lua
/// would refuse is simply not taken. Deselecting is never refused — the reference only gates the
/// `not selected:IsShown()` branch — so a stale pick can always be undone.
fn toggle_challenge(sel: &mut CreateSelection, challenges: Option<&Challenges>, i: u8) {
let (Some(list), Some(staged)) = (challenges, sel.picker) else {
return;
};
let Some(entry) = list.0.get(i as usize) else {
return;
};
if staged & entry.bit != 0 {
sel.picker = Some(staged & !entry.bit);
return;
}
let class = class_file(sel.class);
if list.offered(i as usize, sel.realm_is_rp)
&& list.blocked(i as usize, class, staged).is_none()
{
sel.picker = Some(staged | entry.bit);
}
}
@@ -701,7 +832,7 @@ mod tests {
assert_eq!(
ALLIANCE,
[1, 3, 4, 7, 10],
"Human, Dwarf, Night Elf, Gnome, Blood Elf"
"Human, Dwarf, Night Elf, Gnome and race 10, whose name the realm's ChrRaces.dbc gives"
);
assert_eq!(
HORDE,
@@ -715,6 +846,108 @@ mod tests {
assert!(HORDE.windows(2).all(|w| w[0] < w[1]));
}
fn offer() -> Challenges {
use crate::challenges::{Challenge, LUNATIC, SAMURAI, TOGETHER_FOREVER};
Challenges(vec![
Challenge {
bit: 0x20,
name_key: LUNATIC.into(),
text_key: "T".into(),
icon: "i".into(),
},
Challenge {
bit: 0x400,
name_key: SAMURAI.into(),
text_key: "T".into(),
icon: "i".into(),
},
Challenge {
bit: 0x800,
name_key: TOGETHER_FOREVER.into(),
text_key: "T".into(),
icon: "i".into(),
},
])
}
/// The picker stages, commits and discards the way `ChallengeList`'s Okay, Cancel and
/// `ChallengeList_Reset` do: a click toggles the staged mask, Okay copies it onto the
/// committed one, Cancel throws the staging away, and a *selection* the realm's own gate
/// refuses is simply not taken — while deselecting is never refused.
#[test]
fn the_picker_stages_commits_and_discards_like_the_reference() {
let list = offer();
let mut sel = CreateSelection {
class: 8, // mage: the one class that may not take Samurai
challenges: Some(0),
picker: Some(0),
..default()
};
toggle_challenge(&mut sel, Some(&list), 0);
assert_eq!(sel.picker, Some(0x20), "Lunatic staged");
assert_eq!(sel.challenges, Some(0), "nothing committed until Okay");
toggle_challenge(&mut sel, Some(&list), 1);
assert_eq!(sel.picker, Some(0x20), "a mage may not take Samurai");
toggle_challenge(&mut sel, Some(&list), 2);
assert_eq!(
sel.picker,
Some(0x20),
"Together Forever is held off by Lunatic"
);
toggle_challenge(&mut sel, Some(&list), 0);
assert_eq!(sel.picker, Some(0), "deselecting is never refused");
toggle_challenge(&mut sel, Some(&list), 2);
assert_eq!(sel.picker, Some(0x800), "with Lunatic dropped it goes in");
// Okay commits; a second open re-stages from the committed mask.
let staged = sel.picker.take().expect("open");
sel.challenges = Some(staged);
assert_eq!(sel.request().challenge_mask, Some(0x800));
sel.picker = sel.challenges;
toggle_challenge(&mut sel, Some(&list), 0);
assert_eq!(
sel.picker,
Some(0x800),
"re-opened on the committed mask, so Lunatic is still held off by it"
);
toggle_challenge(&mut sel, Some(&list), 2);
assert_eq!(
sel.picker,
Some(0),
"and the committed pick can be taken back"
);
sel.picker = None; // Cancel
assert_eq!(
sel.challenges,
Some(0x800),
"Cancel leaves the committed mask where Okay left it"
);
}
/// A realm whose glue carries no challenge list gets the stock request, with no trailing
/// `u32` at all, and showing the screen zeroes the mask (`CharacterCreate_OnShow`).
#[test]
fn a_realm_with_no_challenge_list_sends_no_challenge_bytes() {
let mut sel = CreateSelection::default();
sel.reset(None, None, None);
assert_eq!(sel.challenges, None);
assert_eq!(sel.request().challenge_mask, None);
let list = offer();
sel.challenges = Some(0x820);
sel.reset(None, Some(&list), None);
assert_eq!(
sel.challenges,
Some(0),
"every show starts from nothing taken, as the realm's own OnShow does"
);
assert_eq!(sel.request().challenge_mask, Some(0));
}
#[test]
fn look_carries_the_class() {
let mut sel = CreateSelection {
@@ -756,6 +989,7 @@ mod tests {
None::<parts::DynText>,
"",
&art,
(48.0, 64.0),
1.0,
);
}
@@ -769,6 +1003,7 @@ mod tests {
None::<parts::DynText>,
"",
&art,
(48.0, 64.0),
1.0,
);
}
@@ -782,6 +1017,7 @@ mod tests {
None::<parts::DynText>,
"",
&art,
(48.0, 64.0),
1.0,
);
}
+30 -12
View File
@@ -163,33 +163,36 @@ pub(super) fn right_stack(
})
.id();
if let Some(sc) = &art.scroll {
scrollbar(panel, sc, scroll, height, s);
// `GlueScrollFrameTemplate` in panel coordinates: the frame is inset 10
// top and bottom, and the bar is 9 right of the CharacterCreate track art.
scrollbar(panel, sc, scroll, (213.0, 10.0, height - 20.0), s);
}
});
}
});
}
/// One panel's scrollbar, `GlueScrollFrameTemplate` in panel coordinates; the whole bar hides
/// while the panel fits its text, like the reference's `scrollBarHideable`.
fn scrollbar(
/// A `GlueScrollFrameTemplate` scrollbar beside a scrolling box, in the parent's coordinates;
/// `bar` is the 16-wide slider column's `(left, top, height)`, and the decorative
/// `UI-CharacterCreate-ScrollBar` track rides 9 to its left. The whole bar hides while the box
/// fits its content, like the reference's `scrollBarHideable`.
pub(super) fn scrollbar(
panel: &mut ChildSpawnerCommands,
sc: &ScrollArt,
scroll: Entity,
panel_h: f32,
bar: (f32, f32, f32),
s: f32,
) {
// The frame is inset 10 top and bottom; the slider 16 more for the buttons.
let bar_h = panel_h - 20.0;
let track_h = bar_h - 32.0;
let (bar_x, bar_y, bar_h) = bar;
// `UI-CharacterCreate-ScrollBar-Top` (32×128 at frame TOPRIGHT (-3,4)) and the ClassTrainer
// strip's authored sub-rect (30×123 at BOTTOMRIGHT (-3,-2)), both at panel x 204.
// strip's authored sub-rect (30×123 at BOTTOMRIGHT (-3,-2)), both 9 left of the slider.
let track_x = bar_x - 9.0;
if let Some((top, _)) = &sc.track_top {
panel.spawn((
ScrollHides { scroll },
Visibility::Hidden,
ImageNode::new(top.clone()),
abs(s, 204.0, 6.0, 32.0, 128.0),
abs(s, track_x, bar_y - 4.0, 32.0, 128.0),
));
}
if let Some((bottom, size)) = &sc.track_bottom {
@@ -201,14 +204,29 @@ fn scrollbar(
rect: Some(tc_rect(*size, [0.53125, 1.0, 0.03125, 1.0])),
..default()
},
abs(s, 204.0, panel_h - 8.0 - 123.0, 30.0, 123.0),
abs(s, track_x, bar_y + bar_h - 121.0, 30.0, 123.0),
));
}
scroll_slider(panel, sc, scroll, bar, s);
}
/// The slider column alone (`GlueScrollBarTemplate`): an arrow at each end of a 16-wide column
/// and the knob between them. Split from [`scrollbar`] because the track behind it is per-frame
/// art — the info panels use the CharacterCreate pieces, `ChallengeList.xml` its own three.
pub(super) fn scroll_slider(
panel: &mut ChildSpawnerCommands,
sc: &ScrollArt,
scroll: Entity,
bar: (f32, f32, f32),
s: f32,
) {
let (bar_x, bar_y, bar_h) = bar;
let track_h = bar_h - 32.0;
panel
.spawn((
ScrollHides { scroll },
Visibility::Hidden,
abs(s, 213.0, 10.0, 16.0, bar_h),
abs(s, bar_x, bar_y, 16.0, bar_h),
))
.with_children(|bar| {
for (up, top, art) in [(true, 0.0, &sc.up_btn), (false, bar_h - 16.0, &sc.down_btn)] {
@@ -28,6 +28,10 @@ pub(super) enum DynIcon {
ClassSlot(u8),
/// An info-panel header icon.
Info(InfoKind),
/// One `ChallengeIcon<n>` under the Challenges header: the `n`-th *taken* challenge's icon,
/// which is how `ChallengeList_OnHide` packs them (it walks the list and fills the icons in
/// order, hiding the rest).
Challenge(u8),
}
/// A text whose content follows the selection.
@@ -42,6 +46,13 @@ pub(super) enum DynText {
InfoAbilities,
/// A class slot's hover label.
ClassSlotLabel(u8),
/// The picker's description panel: `ChallengesTooltipTitle` and `ChallengesTooltipNotes` for
/// whichever entry the pointer is over, or empty with the pointer elsewhere.
ChallengeTipTitle,
ChallengeTipNotes,
/// The red line `ChallengesTooltip_Update` appends when the hovered entry's gate would refuse
/// the click; empty otherwise.
ChallengeTipRefusal,
}
/// A tint that follows the selected faction, the reference's `SetBackdropColor`: an `ImageNode`
@@ -80,3 +91,16 @@ pub(super) struct ScrollThumb {
pub(super) struct ScrollHides {
pub(super) scroll: Entity,
}
/// The `ChallengeList` modal, shown while the picker is open.
#[derive(Component)]
pub(super) struct ChallengeFrame;
/// One entry's `$parentSelected` band (`Glue-CharacterSelect-Highlight`, ADD), lit while that
/// challenge is staged.
#[derive(Component)]
pub(super) struct ChallengeSelected(pub(super) u8);
/// The picker's description panel, shown while the pointer is over an entry.
#[derive(Component)]
pub(super) struct ChallengeTip;
+241 -14
View File
@@ -1,16 +1,18 @@
//! The create screen's live refresh: everything that follows the selection or the pointer once
//! [`super::screen`] has spawned the tree.
use bevy::ecs::entity::EntityHashSet;
use bevy::input::mouse::{AccumulatedMouseMotion, MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use crate::challenges::Challenges;
use crate::char_select::{class_name, race_name};
use crate::entities::CharCreate;
use crate::glue_strings::GlueStrings;
use super::parts::{
CharCreateUi, DialRow, DynIcon, DynText, DynTint, InfoKind, InfoScroll, ScrollArrow,
ScrollHides, ScrollThumb,
ChallengeFrame, ChallengeSelected, ChallengeTip, CharCreateUi, DialRow, DynIcon, DynText,
DynTint, InfoKind, InfoScroll, ScrollArrow, ScrollHides, ScrollThumb,
};
use super::{class_file, race_classes, CreateAction, CreateSelection, ALLIANCE};
use crate::glue::art::{
@@ -25,6 +27,7 @@ use crate::glue::widgets::{FallbackFace, GlueDisabled, Hilight, HoverLabel, Lock
pub(super) fn refresh_dynamic(
sel: Res<CreateSelection>,
catalog: Option<Res<CharCreate>>,
challenges: Option<Res<Challenges>>,
art: Res<GlueArt>,
strings: Option<Res<GlueStrings>>,
root: Query<Ref<CharCreateUi>>,
@@ -40,6 +43,13 @@ pub(super) fn refresh_dynamic(
>,
mut dial_rows: Query<(&DialRow, &mut Node), Without<Button>>,
mut slots: Query<(&CreateAction, &mut Node), With<Button>>,
mut picker: Query<&mut Node, (With<ChallengeFrame>, Without<Button>, Without<DialRow>)>,
// Shown by `display`, not `Visibility`: this system already writes `Node`, and a second
// component access would pair it against every `Visibility` writer in `Update` for nothing.
mut taken: Query<
(&ChallengeSelected, &mut Node),
(Without<Button>, Without<DialRow>, Without<ChallengeFrame>),
>,
) {
let force = root.iter().any(|r| r.is_added());
if !force && !sel.is_changed() {
@@ -50,6 +60,19 @@ pub(super) fn refresh_dynamic(
let strings = strings.as_deref().unwrap_or(&empty);
let classes = race_classes(cat, sel.race);
let alliance = ALLIANCE.contains(&sel.race);
// The picker's own view of the offer: `ChallengeList_OnHide` packs the taken entries into the
// icon column, and `ChallengeList_ApplyRealmGate` hides what this realm does not offer.
let list = challenges.as_deref();
let shown: Vec<usize> = list.map_or_else(Vec::new, |l| {
(0..l.0.len())
.filter(|&i| l.offered(i, sel.realm_is_rp))
.collect()
});
let picked: Vec<usize> = list.map_or_else(Vec::new, |l| {
(0..l.0.len())
.filter(|&i| sel.challenge_taken(l.0[i].bit))
.collect()
});
let file = cat
.and_then(|c| c.0.race_file(sel.race))
.unwrap_or("Human")
@@ -92,10 +115,17 @@ pub(super) fn refresh_dynamic(
.classes
.as_ref()
.and_then(|(h, size)| Some((h.clone(), tc_rect(*size, class_tc(sel.class)?)))),
// The slot's icon is whichever taken challenge landed in it; a slot past the taken
// count keeps its last face and is hidden by the `slots` pass below.
DynIcon::Challenge(slot) => picked
.get(*slot as usize)
.and_then(|&i| art.challenge_icons.get(i)?.as_ref())
.map(|h| (h.clone(), Rect::default())),
};
if let Some((image, rect)) = tc {
node.image = image;
node.rect = Some(rect);
// A zero rect is the whole texture (a challenge icon has no sub-rect to crop).
node.rect = (rect != Rect::default()).then_some(rect);
node.color = Color::WHITE; // spawned transparent until the art lands
}
}
@@ -159,6 +189,10 @@ pub(super) fn refresh_dynamic(
.get(*i as usize)
.map(|&c| class_name(c).to_string())
.unwrap_or_default(),
// Painted by `refresh_hover`, which is the only thing that knows what is hovered.
DynText::ChallengeTipTitle
| DynText::ChallengeTipNotes
| DynText::ChallengeTipRefusal => continue,
};
if t.0 != new {
t.0 = new;
@@ -199,18 +233,53 @@ pub(super) fn refresh_dynamic(
}
}
// The reference fills the valid classes into the first buttons and hides the rest.
// The reference fills the valid classes into the first buttons and hides the rest, and does
// the same for the challenge icon column and the realm-gated entries.
for (action, mut node) in &mut slots {
if let CreateAction::ClassSlot(i) = action {
node.display = if (*i as usize) < classes.len() {
Display::Flex
} else {
Display::None
};
let show = match action {
CreateAction::ClassSlot(i) => (*i as usize) < classes.len(),
// Slot 0 is `ChallengeIconsHeader` itself, always up.
CreateAction::Challenges(slot) => *slot == 0 || (*slot as usize) <= picked.len(),
CreateAction::ChallengeEntry(i) => shown.contains(&(*i as usize)),
_ => continue,
};
let want = if show { Display::Flex } else { Display::None };
if node.display != want {
node.display = want;
}
}
// `ChallengeList:Show()`/`Hide()`, and each entry's `$parentSelected` band.
for mut node in &mut picker {
let want = if sel.picker.is_some() {
Display::Flex
} else {
Display::None
};
if node.display != want {
node.display = want;
}
}
for (band, mut node) in &mut taken {
let lit = list.is_some_and(|l| {
l.0.get(band.0 as usize)
.is_some_and(|c| sel.challenge_taken(c.bit))
});
let want = if lit { Display::Flex } else { Display::None };
if node.display != want {
node.display = want;
}
}
}
/// `WOW_CHARCREATE_CHALLENGES_HOVER=<index>`: hold the picker's description panel on that entry,
/// so a capture can photograph the tooltip a pointer would raise. Read once.
static CHALLENGE_HOVER: std::sync::LazyLock<Option<usize>> = std::sync::LazyLock::new(|| {
std::env::var("WOW_CHARCREATE_CHALLENGES_HOVER")
.ok()
.and_then(|v| v.trim().parse().ok())
});
/// The create screen's own per-frame visuals: the chosen icon's `LockHighlight`, hover labels,
/// the no-art fallback shade and Create's disabled latch; the shared passes are [`crate::glue`]'s.
/// The lock write keeps its own query, so a button without a `LockHighlight` still gets hover.
@@ -218,6 +287,8 @@ pub(super) fn refresh_dynamic(
pub(super) fn refresh_hover(
sel: Res<CreateSelection>,
catalog: Option<Res<CharCreate>>,
challenges: Option<Res<Challenges>>,
strings: Option<Res<GlueStrings>>,
art: Res<GlueArt>,
// Accept, Back and Randomize are wholly the shared visuals pass's.
mut buttons: Query<
@@ -231,8 +302,10 @@ pub(super) fn refresh_hover(
(With<Button>, Without<crate::glue::widgets::GlueBtn>),
>,
mut locks: Query<(&CreateAction, &mut LockHighlight)>,
mut labels: Query<&mut Visibility, (With<HoverLabel>, Without<Hilight>)>,
mut labels: Query<&mut Visibility, (With<HoverLabel>, Without<Hilight>, Without<ChallengeTip>)>,
mut disables: Query<(&CreateAction, &mut GlueDisabled)>,
mut tip: Query<&mut Visibility, (With<ChallengeTip>, Without<HoverLabel>, Without<Hilight>)>,
mut tip_texts: Query<(&DynText, &mut Text)>,
) {
let cat = catalog.as_deref();
let classes = race_classes(cat, sel.race);
@@ -273,6 +346,49 @@ pub(super) fn refresh_hover(
}
}
// `ChallengesTooltip_Update`: the hovered entry's name and paragraph, with the realm's own
// red refusal line appended when its gate would turn the click down.
let hovered_entry = (*CHALLENGE_HOVER).or_else(|| {
buttons.iter().find_map(|(action, interaction, ..)| {
match (action, *interaction != Interaction::None) {
(CreateAction::ChallengeEntry(i), true) => Some(*i as usize),
_ => None,
}
})
});
if let Ok(mut vis) = tip.single_mut() {
let want = if hovered_entry.is_some() && sel.picker.is_some() {
Visibility::Inherited
} else {
Visibility::Hidden
};
if *vis != want {
*vis = want;
}
}
if let (Some(i), Some(list)) = (hovered_entry, challenges.as_deref()) {
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
if let Some(entry) = list.0.get(i) {
let title = strings.text(&entry.name_key, &entry.name_key);
let notes = strings.text(&entry.text_key, "");
let refusal = list
.blocked(i, class_file(sel.class), sel.picker.unwrap_or_default())
.map_or("", |why| strings.text(why, why));
for (marker, mut text) in &mut tip_texts {
let new = match marker {
DynText::ChallengeTipTitle => title,
DynText::ChallengeTipNotes => notes,
DynText::ChallengeTipRefusal => refusal,
_ => continue,
};
if text.0 != new {
text.0 = new.to_string();
}
}
}
}
for (action, mut disabled) in &mut disables {
if matches!(action, CreateAction::Create) {
let want = sel.creating;
@@ -284,17 +400,38 @@ pub(super) fn refresh_hover(
}
/// Wheel-scroll a hovered info panel, the reference's GlueScrollFrame `OnMouseWheel`.
///
/// The notch counts for a box when the pointer is over the box *or anything inside it*: a
/// wheel event the frame under the pointer does not handle walks up its parent chain in the
/// reference (`0x5a0f30`), so a child that is not itself wheel-enabled scrolls its scroll
/// frame rather than swallowing the notch. Bevy gives [`Interaction`] to the topmost node
/// alone, so the ancestor walk has to be done here — without it the challenge picker, whose
/// box is paved with entry buttons, never sees a notch, while the info panels, which hold
/// bare text, do.
pub(super) fn scroll_info(
mut wheel: MessageReader<MouseWheel>,
mut boxes: Query<(&Interaction, &ComputedNode, &mut ScrollPosition), With<InfoScroll>>,
pointed: Query<(Entity, &Interaction)>,
parents: Query<&ChildOf>,
mut boxes: Query<(Entity, &ComputedNode, &mut ScrollPosition), With<InfoScroll>>,
) {
if wheel.is_empty() {
return;
}
let mut under = EntityHashSet::default();
for (entity, interaction) in &pointed {
if *interaction == Interaction::None {
continue;
}
under.insert(entity);
under.extend(parents.iter_ancestors(entity));
}
for ev in wheel.read() {
let dy = match ev.unit {
MouseScrollUnit::Line => ev.y * 24.0,
MouseScrollUnit::Pixel => ev.y,
};
for (interaction, node, mut pos) in &mut boxes {
if *interaction != Interaction::None {
for (entity, node, mut pos) in &mut boxes {
if under.contains(&entity) {
pos.0.y = (pos.0.y - dy).clamp(0.0, max_scroll(node));
}
}
@@ -404,3 +541,93 @@ pub(super) fn scroll_visuals(
};
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A box 100 tall holding 400 of content: 300 logical px of travel.
fn scrollable() -> ComputedNode {
ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 400.0),
..default()
}
}
/// The picker's shape: an `InfoScroll` box paved with entry buttons, as
/// `challenge_list::picker` builds it.
fn wheel_app() -> (App, Entity, Entity) {
let mut app = App::new();
app.add_message::<MouseWheel>()
.add_systems(Update, scroll_info);
let scroll = app
.world_mut()
.spawn((
InfoScroll,
Interaction::None,
ScrollPosition::default(),
scrollable(),
))
.id();
let entry = app
.world_mut()
.spawn((Interaction::None, ChildOf(scroll)))
.id();
(app, scroll, entry)
}
/// Set one widget's [`Interaction`] as `ui_focus_system` would, roll a notch, read the offset.
fn notch(app: &mut App, pointed: Entity, scroll: Entity) -> f32 {
*app.world_mut().get_mut::<Interaction>(pointed).unwrap() = Interaction::Hovered;
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -1.0,
window: Entity::PLACEHOLDER,
});
app.update();
app.world().get::<ScrollPosition>(scroll).unwrap().0.y
}
/// The bug the challenge picker hit: Bevy gives `Interaction` to the topmost node alone, so
/// the entry button under the pointer held it and the box never saw a notch.
#[test]
fn a_notch_over_an_entry_button_scrolls_the_box_it_sits_in() {
let (mut app, scroll, entry) = wheel_app();
assert_eq!(
notch(&mut app, entry, scroll),
24.0,
"the entry is not wheel-enabled, so the notch belongs to its scroll frame"
);
}
/// The info panels, whose boxes hold bare text, keep taking the notch directly.
#[test]
fn a_notch_over_the_box_itself_still_scrolls_it() {
let (mut app, scroll, _) = wheel_app();
assert_eq!(notch(&mut app, scroll, scroll), 24.0);
}
/// Ancestry is what carries the notch, so a box the pointer is nowhere near stays put.
#[test]
fn a_notch_elsewhere_leaves_the_box_alone() {
let (mut app, scroll, _) = wheel_app();
let outside = app.world_mut().spawn(Interaction::None).id();
assert_eq!(notch(&mut app, outside, scroll), 0.0);
}
/// Clamped at both stops, as `GlueScrollFrameTemplate`'s own range is.
#[test]
fn the_box_stops_at_the_end_of_its_travel() {
let (mut app, scroll, entry) = wheel_app();
for _ in 0..40 {
notch(&mut app, entry, scroll);
}
assert_eq!(
app.world().get::<ScrollPosition>(scroll).unwrap().0.y,
300.0,
"400 of content in a 100 box is 300 of travel"
);
}
}
+335 -47
View File
@@ -6,10 +6,11 @@ use bevy::prelude::*;
use bevy::ui_render::ui_material::MaterialNode;
use bevy::window::PrimaryWindow;
use crate::challenges::Challenges;
use crate::char_select::{race_name, wow_font};
use crate::entities::CharCreate;
use crate::glue_strings::GlueStrings;
use crate::portrait::{GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
use crate::portrait::{GlueLook, GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
use benilla_assets::WorldAssets;
use super::parts::{CharCreateUi, DialRow, DynIcon, DynText, DynTint, StatusLine};
@@ -24,6 +25,112 @@ use crate::glue::widgets::{
const SCREEN_Z: i32 = 1100;
// ── The configuration tower's authored geometry ──────────────────────────────────────────────────
/// `CharacterCreateConfigurationFrame` and its children, in the frame's own top-down coordinates.
///
/// Two sets, because the player's chain decides which `CharacterCreate.xml` the screen is: stock
/// 1.12's (`interface.MPQ`) and the TurtleWoW-derived one OctoWoW ships (its `patch-4.mpq` copy
/// wins the chain). The derived file does not merely append a fifth race row — it re-lays the
/// whole tower out around smaller buttons (45² races, 40² gender and class), a four-wide class
/// grid and a banner twice as tall, and lifts the frame 19 px to pay for it. Laying the Turtle
/// chain out with stock's numbers runs the tower 18 px past the 768-unit screen, which is the
/// Randomize button hanging off the bottom edge.
///
/// Which set is chosen is [`Tower::for_rows`]: five race buttons in a column is the derived
/// chain's own signature, and it is already what the frame's y offset keyed on.
struct Tower {
/// The frame's TOPLEFT y (x is 28 in both).
top: f32,
/// The three stacked `UI-CharacterCreate-OuterBorder` pieces: height, texcoords and whether
/// the authored v range runs bottom-to-top (the file's way of mirroring the piece).
border: [(f32, [f32; 4], bool); 3],
/// `CharacterCreateBackground`, inset into the border stack: top and height.
bg: (f32, f32),
/// `CharacterCreateBanners`' drawn height; 256 wide at TOP (-2,-60) in both.
banner_h: f32,
/// The `ALLIANCE` and `HORDE` captions' centre x (BOTTOM anchored to the banner's TOP, so
/// their baseline does not follow `banner_h`).
label_cx: [f32; 2],
/// A race button's edge, its row pitch, and the second column's left.
race: (f32, f32, f32),
/// A gender/class button's edge and the `UI-CharacterCreate-IconShadow` square behind it.
icon: (f32, f32),
/// The gender pair's TOPLEFT.
gender: (f32, f32),
/// The class grid's TOPLEFT, its buttons per row, and the gaps between them (column, row).
class: (f32, f32, usize, f32, f32),
/// `CharacterCustomizationButtonFrame1`'s TOPLEFT; the five rows stack 32 apart.
dials: (f32, f32),
/// `CharCreateRandomizeButton`'s TOPLEFT (`CharacterCreate_UpdateFacialHairCustomization`
/// re-anchors it to Frame5's BOTTOM -5 on every race set, in both chains).
randomize: (f32, f32),
/// `CharacterCreateLogo`'s top. Stock hangs the `CharacterCreateWoWLogo` frame 7 below the
/// screen top with the texture flush; the derived file lifts the frame 7 *above* it and drops
/// the texture 8 inside, netting 1 — six pixels that decide whether the upscale pack's
/// wordmark, baked into the bottom third of `Glues-WoW-Logo`, clears the faction captions.
logo_top: f32,
}
impl Tower {
/// The metric set for a chain whose race columns are `rows` tall.
fn for_rows(rows: usize) -> Self {
if rows > 4 {
Self::TURTLE
} else {
Self::STOCK
}
}
/// 1.12.1's `CharacterCreate.xml` (`interface.MPQ`).
const STOCK: Self = Self {
top: 74.0,
border: [
(236.0, [0.0, 0.875, 0.0, 0.9375], false),
(240.0, [0.0, 0.875, 0.0, 0.9375], false),
(210.0, [0.0, 0.875, 0.179_687_5, 1.0], false),
],
bg: (0.0, 680.0),
banner_h: 259.0,
label_cx: [51.0, 151.0],
race: (48.0, 53.0, 127.0),
icon: (48.0, 64.0),
gender: (53.0, 303.0),
class: (27.0, 369.0, 3, 4.0, 0.0),
dials: (4.0, 480.0),
randomize: (30.0, 645.0),
logo_top: 7.0,
};
/// The chain's own `CharacterCreate.xml` where it carries a tenth race (TurtleWoW's, as
/// OctoWoW ships it in `patch-4.mpq`). Every number is that file's, resolved top-down:
/// `RaceButton1` TOPLEFT (33,-68) with a 45+5 pitch, so row 5 bottoms at 313; the gender pair
/// hangs off it at (+27,-25); `ClassButton1` off the gender at (-44,-15) and the grid wraps
/// after four; `CustomizationButtonFrame1` TOP-anchored to `ClassButton6`'s BOTTOM at (+20,
/// -15), i.e. centred on x 101.
const TURTLE: Self = Self {
top: 55.0,
border: [
// The derived file writes the top piece's v range backwards (top 1.0, bottom
// 0.1796875), which mirrors it, and gives the middle a degenerate one (0.9375 to
// 0.9375): one texel row stretched over 240, the frame's plain side rails.
(236.0, [0.0, 0.875, 0.179_687_5, 1.0], true),
(240.0, [0.0, 0.875, 0.9375, 0.9375], false),
(228.0, [0.0, 0.875, 0.179_687_5, 1.0], false),
],
bg: (6.0, 692.0),
banner_h: 500.0,
label_cx: [57.0, 151.0],
race: (45.0, 50.0, 127.0),
icon: (40.0, 55.0),
gender: (60.0, 338.0),
class: (16.0, 393.0, 4, 5.0, 3.0),
dials: (2.0, 491.0),
randomize: (28.0, 656.0),
logo_top: 1.0,
};
}
// ── Spawn ────────────────────────────────────────────────────────────────────────────────────────
pub(super) fn enter_create(
@@ -38,13 +145,19 @@ pub(super) fn enter_create(
mut images: ResMut<Assets<Image>>,
mut add_mats: ResMut<Assets<crate::glue::add_material::AddUiMaterial>>,
strings: Option<Res<GlueStrings>>,
challenges: Option<Res<Challenges>>,
roster: Option<Res<crate::char_select::Roster>>,
window: Query<&Window, With<PrimaryWindow>>,
) {
sel.reset(catalog.as_deref());
preview.show_create(sel.race, sel.look());
sel.reset(catalog.as_deref(), challenges.as_deref(), roster.as_deref());
preview.scene = Some(crate::portrait::GlueScene::Race(sel.race));
preview.look = Some(GlueLook::Create(sel.look()));
preview.yaw = INITIAL_FACING;
if let Some(mut wa) = world_assets {
art.ensure_loaded(&mut wa, &mut images, &mut add_mats);
if let Some(list) = challenges.as_deref() {
art.ensure_challenge_icons(&list.0, &mut wa, &mut images);
}
}
spawn_screen(
&mut commands,
@@ -53,6 +166,7 @@ pub(super) fn enter_create(
&art,
strings.as_deref(),
catalog.as_deref(),
challenges.as_deref(),
&window,
);
}
@@ -67,6 +181,7 @@ pub(super) fn rescale_screen(
art: Res<GlueArt>,
strings: Option<Res<GlueStrings>>,
catalog: Option<Res<CharCreate>>,
challenges: Option<Res<Challenges>>,
window: Query<&Window, With<PrimaryWindow>>,
) {
let s = crate::glue::screen_scale(window.single().ok());
@@ -80,12 +195,14 @@ pub(super) fn rescale_screen(
&art,
strings.as_deref(),
catalog.as_deref(),
challenges.as_deref(),
&window,
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_screen(
commands: &mut Commands,
assets: &AssetServer,
@@ -93,6 +210,7 @@ fn spawn_screen(
art: &GlueArt,
strings: Option<&GlueStrings>,
catalog: Option<&CharCreate>,
challenges: Option<&Challenges>,
window: &Query<&Window, With<PrimaryWindow>>,
) {
let font = wow_font(assets);
@@ -109,7 +227,6 @@ fn spawn_screen(
let root = commands
.spawn((
crate::glue::NativeGlue,
CharCreateUi { s },
DynTint::Backdrop,
GlobalZIndex(SCREEN_Z),
@@ -143,19 +260,20 @@ fn spawn_screen(
.id();
let (alliance, horde) = super::race_columns(catalog);
// A fifth race row (TurtleWoW) pushes the gender, class, dial and Randomize clusters down by
// one 48² + 5 button pitch; the stock four rows leave every authored offset as-is.
let shift = (alliance.len().max(horde.len()).max(4) - 4) as f32 * 53.0;
let m = Tower::for_rows(alliance.len().max(horde.len()));
// The chrome hangs off the canvas, the boxed scene's rect, never the window.
let mut canvas = commands.spawn((crate::glue::glue_canvas(), ChildOf(root)));
canvas.with_children(|ui| {
left_tower(ui, art, &font, s, strings, &alliance, &horde, shift);
left_tower(ui, art, &font, s, strings, &alliance, &horde, &m);
// `CharacterCreateWoWLogo` (256×128 at (3,-7)), after the tower, as the reference's
// frame order draws it over the border art.
// `CharacterCreateWoWLogo` (256×128 at x 3), after the tower, as the reference's frame
// order draws it over the border art.
if let Some(logo) = &art.logo {
ui.spawn((ImageNode::new(logo.clone()), abs(s, 3.0, 7.0, 256.0, 128.0)));
ui.spawn((
ImageNode::new(logo.clone()),
abs(s, 3.0, m.logo_top, 256.0, 128.0),
));
}
super::panels::right_stack(ui, art, &font, s);
@@ -196,12 +314,24 @@ fn spawn_screen(
s,
);
});
// The realm's Challenges header, last on the canvas so it draws over the info panels'
// edges, as `ChallengeIconsHeader`'s later frame level does.
if let Some(list) = challenges {
super::challenge_list::header(ui, art, &font, strings, list, s);
}
});
// `ChallengeList` is `parent="GlueParent"` and `toplevel`, not a child of the create frame,
// so it hangs off the screen root and covers the whole window rather than the glue canvas.
if let Some(list) = challenges {
super::challenge_list::picker(commands, root, art, &font, strings, list, s);
}
}
/// The configuration tower, `CharacterCreateConfigurationFrame` (206×600 at TOPLEFT (28,-74) in
/// stock, (28,-55) on TurtleWoW), every child at its authored offset. `alliance`/`horde` are the
/// catalog's creatable races per column, and `shift` the fifth-row push the lower clusters take.
/// stock, (28,-55) on the TurtleWoW-derived chain), every child at its authored offset.
/// `alliance`/`horde` are the catalog's creatable races per column; `m` is the chain's metric set.
#[allow(clippy::too_many_arguments)]
fn left_tower(
ui: &mut ChildSpawnerCommands,
@@ -211,17 +341,13 @@ fn left_tower(
strings: &GlueStrings,
alliance: &[u8],
horde: &[u8],
shift: f32,
m: &Tower,
) {
let px = |v: f32| Val::Px(v * s);
// TurtleWoW's `CharacterCreateConfigurationFrame` sits 19 px higher than stock's (TOPLEFT
// y -55 against -74) to make room for its fifth race row; the stock four keep the authored 74.
let rows = alliance.len().max(horde.len());
let tower_top = if rows > 4 { 55.0 } else { 74.0 };
ui.spawn((Node {
position_type: PositionType::Absolute,
left: px(28.0),
top: px(tower_top),
top: px(m.top),
width: px(206.0),
height: px(600.0),
..default()
@@ -230,34 +356,47 @@ fn left_tower(
// `UI-CharacterCreate-Background` behind three stacked `OuterBorder` pieces, 224 wide
// centered on the 206 frame (x -9).
if let Some(bg) = &art.tower_bg {
tower.spawn((ImageNode::new(bg.clone()), abs(s, -3.0, 0.0, 218.0, 680.0)));
tower.spawn((
ImageNode::new(bg.clone()),
abs(s, -3.0, m.bg.0, 218.0, m.bg.1),
));
}
if let Some((border, size)) = &art.tower_border {
for (top, height, tc) in [
(0.0, 236.0, [0.0, 0.875, 0.0, 0.9375]),
(236.0, 240.0, [0.0, 0.875, 0.0, 0.9375]),
(476.0, 210.0, [0.0, 0.875, 0.1796875, 1.0]),
] {
let mut top = 0.0;
for (height, tc, flip) in m.border {
// A degenerate v range is one texel row stretched over the piece; `tc_rect`
// would hand the sampler an empty rect, so widen it to that single row.
let tc = if tc[3] <= tc[2] {
[tc[0], tc[1], tc[2], tc[2] + 1.0 / size.y.max(1.0)]
} else {
tc
};
tower.spawn((
ImageNode {
image: border.clone(),
rect: Some(tc_rect(*size, tc)),
flip_y: flip,
..default()
},
abs(s, -9.0, top, 224.0, height),
));
top += height;
}
}
// `CharacterCreateBanners`, 256×259 at TOP (-2,-60), behind the race grid.
// `CharacterCreateBanners`, 256 wide at TOP (-2,-60), behind the race grid. The
// derived chain's sheet is twice as tall as stock's and is drawn 500 rather than 259,
// which is what puts colour behind all five race rows.
if let Some(banners) = &art.banners {
tower.spawn((
ImageNode::new(banners.clone()),
abs(s, -27.0, 60.0, 256.0, 259.0),
abs(s, -27.0, 60.0, 256.0, m.banner_h),
));
}
// The XML's `text="ALLIANCE"` is a GlueStrings key, shown as "Alliance".
for (key, fallback, center) in
[("ALLIANCE", "Alliance", 51.0), ("HORDE", "Horde", 151.0)]
// The XML's `text="ALLIANCE"` is a GlueStrings key, shown as "Alliance". Both are
// BOTTOM-anchored to the banner's TOP +3, so their box bottoms at 57.
for ((key, fallback), center) in [("ALLIANCE", "Alliance"), ("HORDE", "Horde")]
.into_iter()
.zip(m.label_cx)
{
outlined_text(
tower,
@@ -278,15 +417,16 @@ fn left_tower(
);
}
// Two columns of 48² buttons at (33,68) and (127,68), row pitch 48+5.
for (faction, left) in [(alliance, 33.0), (horde, 127.0)] {
// Two columns of race buttons at (33,68) and (`race.2`,68).
let (race_edge, race_pitch, race_col2) = m.race;
for (faction, left) in [(alliance, 33.0), (horde, race_col2)] {
tower
.spawn((Node {
position_type: PositionType::Absolute,
left: px(left),
top: px(68.0),
flex_direction: FlexDirection::Column,
row_gap: px(5.0),
row_gap: px(race_pitch - race_edge),
..default()
},))
.with_children(|col| {
@@ -300,19 +440,19 @@ fn left_tower(
None::<DynText>,
race_name(race),
art,
(race_edge, m.icon.1),
s,
);
}
});
}
// The gender pair at the last race button's BOTTOMLEFT + (20,-28); `shift` is the
// fifth TurtleWoW row.
// The gender pair, hung off the last race button's BOTTOMLEFT.
tower
.spawn((Node {
position_type: PositionType::Absolute,
left: px(53.0),
top: px(303.0 + shift),
left: px(m.gender.0),
top: px(m.gender.1),
flex_direction: FlexDirection::Row,
column_gap: px(5.0),
..default()
@@ -336,21 +476,24 @@ fn left_tower(
None::<DynText>,
strings.text(key, fallback),
art,
m.icon,
s,
);
}
});
// Three wide, columns at x 27/79/131; 8 slots, unused ones collapse.
// The class grid; 8 slots wrapping after `class.2`, unused ones collapse.
let (class_left, class_top, class_cols, class_gap, class_row_gap) = m.class;
tower
.spawn((Node {
position_type: PositionType::Absolute,
left: px(27.0),
top: px(369.0 + shift),
width: px(3.0 * 48.0 + 2.0 * 4.0),
left: px(class_left),
top: px(class_top),
width: px(class_cols as f32 * m.icon.0 + (class_cols as f32 - 1.0) * class_gap),
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: px(4.0),
column_gap: px(class_gap),
row_gap: px(class_row_gap),
..default()
},))
.with_children(|grid| {
@@ -364,17 +507,18 @@ fn left_tower(
Some(DynText::ClassSlotLabel(slot)),
"",
art,
m.icon,
s,
);
}
});
// `CharacterCustomizationFrameTemplate`, 198×32 rows stacked from (4,480).
// `CharacterCustomizationFrameTemplate`, 198×32 rows stacked from the frame's TOPLEFT.
tower
.spawn((Node {
position_type: PositionType::Absolute,
left: px(4.0),
top: px(480.0 + shift),
left: px(m.dials.0),
top: px(m.dials.1),
width: px(198.0),
flex_direction: FlexDirection::Column,
..default()
@@ -391,8 +535,8 @@ fn left_tower(
tower
.spawn((Node {
position_type: PositionType::Absolute,
left: px(30.0),
top: px(645.0 + shift),
left: px(m.randomize.0),
top: px(m.randomize.1),
..default()
},))
.with_children(|r| {
@@ -636,3 +780,147 @@ pub(super) fn exit_create(
preview.look = None;
preview.scene = None;
}
#[cfg(test)]
mod tests {
use super::*;
/// The glue screen is 768 units tall and nothing may hang off it. The bug this guards: a
/// five-row chain laid out with stock's own offsets plus one of stock's row pitches
/// (`55 + 645 + 53 + 30`) ran `CharCreateRandomizeButton`'s bottom edge to **783**, so the
/// button was cut in half by the window.
#[test]
fn every_towers_last_control_lands_inside_the_glue_screen() {
for (what, m) in [("stock", Tower::STOCK), ("derived", Tower::TURTLE)] {
let bottom = m.top + m.randomize.1 + 30.0;
assert!(bottom <= 768.0, "{what}: Randomize bottoms at {bottom}");
}
assert!(
Tower::TURTLE.top + Tower::STOCK.randomize.1 + 53.0 + 30.0 > 768.0,
"the shape of the old bug must still be off-screen, or this test proves nothing"
);
}
/// Every derived-chain offset is its `CharacterCreate.xml` anchor chain resolved, written
/// twice: once as the constant the screen lays out from, once as the arithmetic the file
/// states. A constant that drifts from the file fails here rather than on a player's screen.
#[test]
fn the_derived_towers_offsets_are_its_anchor_chain_resolved() {
let m = Tower::TURTLE;
let (edge, pitch, col2) = m.race;
assert_eq!((edge, pitch), (45.0, 50.0), "45² races, rows 5 apart");
assert_eq!(
col2,
33.0 + edge + 49.0,
"RaceButton6: RaceButton1's RIGHT +49"
);
// `RaceButton1` TOPLEFT (33,-68), five rows.
let race_bottom = 68.0 + 4.0 * pitch + edge;
assert_eq!(
m.gender,
(33.0 + 27.0, race_bottom + 25.0),
"GenderButtonMale: RaceButton5's BOTTOMLEFT (+27,-25)"
);
let (icon, _) = m.icon;
let (class_left, class_top, cols, gap, row_gap) = m.class;
assert_eq!(
cols, 4,
"ClassButton5 wraps under ClassButton1, so four to a row"
);
assert_eq!(
(class_left, class_top),
(m.gender.0 - 44.0, m.gender.1 + icon + 15.0),
"ClassButton1: GenderButtonMale's BOTTOMLEFT (-44,-15)"
);
let class_bottom = class_top + icon + row_gap + icon;
// `CharacterCustomizationButtonFrame1` is TOP-anchored to ClassButton6's BOTTOM (+20,-15),
// and ClassButton6 is the second of the second row.
let col6_centre = class_left + (icon + gap) + icon / 2.0;
assert_eq!(
m.dials,
(col6_centre + 20.0 - 198.0 / 2.0, class_bottom + 15.0)
);
assert_eq!(
m.randomize,
(
col6_centre + 20.0 - 146.0 / 2.0,
m.dials.1 + 5.0 * 32.0 + 5.0
),
"Randomize is re-anchored to CustomizationButtonFrame5's BOTTOM -5 on every race set"
);
}
/// Each chain draws `UI-CharacterCreate-Banners` at its own sheet's aspect: stock ships a
/// 256-square and draws it 259, the derived chain ships a 256×512 and draws it 500. Drawing
/// the tall sheet at stock's 259 halves it, and the banner's colour then stops above the
/// fourth race row instead of running past the fifth — the symptom that made the derived
/// chain's five-row column look like it had lost its banner.
#[test]
fn each_chain_draws_its_banner_sheet_at_the_sheets_own_aspect() {
assert!(
(Tower::STOCK.banner_h / 256.0 - 1.0).abs() < 0.02,
"stock's sheet is a 256 square"
);
assert!(
(Tower::TURTLE.banner_h / 512.0 - 1.0).abs() < 0.03,
"the derived chain's sheet is 256x512"
);
assert!(
(Tower::TURTLE.banner_h / Tower::STOCK.banner_h - 2.0).abs() < 0.1,
"one is twice the other, which is what the two sheets are"
);
}
/// The same against the player's own art: whatever resolution the chain's
/// `UI-CharacterCreate-Banners` is at (this install's upscale pack makes it 512×1024), the
/// height it is drawn at must be the sheet's aspect, or the banner is squashed. Skips without
/// an install.
#[test]
fn the_installed_banner_sheet_matches_the_height_its_chain_draws_it_at() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let bytes = chain
.read_file("Interface\\Glues\\CharacterCreate\\UI-CharacterCreate-Banners.blp")
.expect("the banner sheet");
let sheet = benilla_formats::blp_bytes_to_native_chain(&bytes).expect("decode the sheet");
let aspect = sheet.height as f32 / sheet.width as f32;
// The one metric set whose 256-wide draw has that aspect; 1:1 is stock's, 1:2 the
// TurtleWoW-derived chain's.
let want = if aspect > 1.5 {
Tower::TURTLE
} else {
Tower::STOCK
};
let drawn = want.banner_h / 256.0;
assert!(
(drawn - aspect).abs() < 0.08,
"a {}x{} sheet drawn 256x{} is {drawn:.2} against the sheet's {aspect:.2}",
sheet.width,
sheet.height,
want.banner_h
);
}
/// `CharacterCreateWoWLogo`: the derived chain lifts the frame 7 above the screen top and
/// drops the texture 8 inside it, six pixels higher than stock's flush 7 — the six that let
/// an upscale pack's wordmark, baked into the bottom third of `Glues-WoW-Logo`, clear the
/// faction captions instead of sitting on them.
#[test]
fn the_derived_chain_hangs_its_logo_six_pixels_higher_than_stock() {
assert_eq!(Tower::STOCK.logo_top, 7.0);
assert_eq!(Tower::TURTLE.logo_top, -7.0 + 8.0);
}
/// Five race buttons in a column is the derived chain's signature, and the only thing the
/// screen has to tell the two `CharacterCreate.xml` files apart by.
#[test]
fn the_fifth_race_row_is_what_picks_the_metric_set() {
assert_eq!(Tower::for_rows(4).top, Tower::STOCK.top);
assert_eq!(Tower::for_rows(5).top, Tower::TURTLE.top);
assert_eq!(
Tower::for_rows(0).top,
Tower::STOCK.top,
"no catalog, stock"
);
}
}
+135 -149
View File
@@ -104,20 +104,21 @@ enum Hover {
Check(usize),
}
/// The screen's state; a click writes the in-memory store and only Okay writes the files.
/// The screen's state; edits stage per character and only Okay writes the files.
#[derive(Resource)]
pub(super) struct AddonsPanel {
pub(super) open: bool,
/// The installed list read at open, so a row's index is stable.
/// The installed list read at open, so a row's index is stable; the enable state is `staged`'s.
list: Vec<InstalledAddOn>,
/// The roster's realm, half of every enable file's `(realm, name)` key.
realm: String,
/// The roster's character names, the dropdown's entries after "All".
chars: Vec<String>,
/// Every roster character's enable hash, which a click sets and Okay saves where dirty.
store: addons::EnableStore,
/// `bits[c][i]` is the query's answer for character `c` and `list[i]`, re-read from the store
/// after every change, so a character with no row follows the others' edits at once; with no
/// characters, one anonymous column of `## DefaultState`s.
bits: Vec<Vec<bool>>,
/// `staged[c][i]` is character `c`'s bit for `list[i]`; with no characters, one anonymous
/// column that Okay does not write.
staged: Vec<Vec<bool>>,
/// What the files said at open; Okay writes only the columns that moved off it.
baseline: Vec<Vec<bool>>,
/// The dropdown's selection: `None` is "All", `Some(c)` is `chars[c]`.
view: Option<usize>,
dropdown_open: bool,
@@ -144,9 +145,10 @@ impl Default for AddonsPanel {
Self {
open: false,
list: Vec::new(),
realm: String::new(),
chars: Vec::new(),
store: addons::EnableStore::default(),
bits: Vec::new(),
staged: Vec::new(),
baseline: Vec::new(),
view: None,
dropdown_open: false,
// The registered default, "1"; a `false` would paint out-of-date rows loadable for a
@@ -163,14 +165,29 @@ impl Default for AddonsPanel {
}
impl AddonsPanel {
/// Open for this realm's roster: read the folder and every character's enable file, as
/// `AddonList_OnShow` re-reads. An addon a character has no row for takes what the other
/// characters agree on, else the manifest's `## DefaultState`.
/// Open for this realm's roster: read the folder, and one staged column per character, as
/// `AddonList_OnShow` re-reads. The store loads for the whole roster: an addon a character has
/// no row for takes what the other characters agree on, else the manifest's `## DefaultState`.
pub(super) fn open_for(&mut self, realm: String, chars: Vec<String>) {
self.list = addons::installed_rows();
self.store = addons::EnableStore::load(&realm, &chars);
self.staged.clear();
let store = addons::EnableStore::load(&realm, &chars);
let columns: Vec<Option<&str>> = if chars.is_empty() {
vec![None]
} else {
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;
self.chars = chars;
self.reread();
// "All" is the reference's default selection.
self.view = None;
self.dropdown_open = false;
@@ -184,53 +201,31 @@ impl AddonsPanel {
self.open = false;
self.list.clear();
self.chars.clear();
self.store = addons::EnableStore::default();
self.bits.clear();
self.staged.clear();
self.baseline.clear();
self.view = None;
self.dropdown_open = false;
self.tip = None;
self.dirty = true;
}
/// Re-read every checkbox through the query `0x51e470`, as `AddonList_Update` does after each
/// `EnableAddOn`/`DisableAddOn`.
fn reread(&mut self) {
let columns: Vec<Option<&str>> = if self.chars.is_empty() {
vec![None]
} else {
self.chars.iter().map(|c| Some(c.as_str())).collect()
};
self.bits = columns
.into_iter()
.map(|character| {
self.list
.iter()
.map(|a| self.store.enabled_for(&a.name, a.default_state, character))
.collect()
})
.collect();
self.dirty = true;
}
/// The glue `EnableAddOn`/`DisableAddOn` (`0x46d7b0`, `0x46d8a0`) for the current view: the
/// setter on the viewed character, or on every character in the All view (`nil`). With no
/// characters there is no node to set, so a click changes nothing.
fn set(&mut self, i: usize, on: bool) {
let character = self
.view
.and_then(|c| self.chars.get(c))
.map(String::as_str);
if let Some(a) = self.list.get(i) {
self.store.set(&a.name, character, on);
/// Okay, `SaveAddOns`: write the enable file of each character whose column changed.
fn save_staged(&self) {
for (c, name) in self.chars.iter().enumerate() {
if self.staged.get(c) == self.baseline.get(c) {
continue;
}
let id = (self.realm.clone(), name.clone());
let states: Vec<(String, bool)> = self
.list
.iter()
.zip(self.staged[c].iter())
.map(|(a, &on)| (a.name.clone(), on))
.collect();
addons::write_enable_state(Some(&id), &states);
}
}
/// Okay, `SaveAddOns` (`0x46d990` into the writer `0x51ef20`): each character a click changed
/// gets its enable hash written; the rest are left as they are.
fn save(&mut self) {
self.store.save();
}
/// Whether any addon is installed (`UpdateAddonButton` hides the button otherwise). Cached for
/// the process: the screen respawns every frame of a drag-resize.
pub(super) fn any_installed() -> bool {
@@ -274,7 +269,7 @@ impl AddonsPanel {
match self.view {
Some(c) => {
if self
.bits
.staged
.get(c)
.is_some_and(|col| col.get(i) == Some(&true))
{
@@ -285,13 +280,13 @@ impl AddonsPanel {
}
None => {
let on = self
.bits
.staged
.iter()
.filter(|col| col.get(i) == Some(&true))
.count();
if on == 0 {
BoxState::Off
} else if on == self.bits.len() {
} else if on == self.staged.len() {
BoxState::On
} else {
BoxState::Mixed
@@ -344,17 +339,40 @@ impl AddonsPanel {
/// A checkbox click. In the All view a mixed box counts as checked, so clicking On or Mixed
/// disables for every character and only an unchecked box enables for all.
fn click_row(&mut self, i: usize) {
let on = self.box_state(i) == BoxState::Off;
self.set(i, on);
self.reread();
match self.view {
Some(c) => {
if let Some(slot) = self.staged.get_mut(c).and_then(|col| col.get_mut(i)) {
*slot = !*slot;
self.dirty = true;
}
}
None => {
let target = self.box_state(i) == BoxState::Off;
for col in &mut self.staged {
if let Some(slot) = col.get_mut(i) {
*slot = target;
}
}
self.dirty = true;
}
}
}
/// Enable All and Disable All: the setter for every listed addon, on the current view.
/// Enable All and Disable All sweep the current view's columns.
fn set_all(&mut self, on: bool) {
for i in 0..self.list.len() {
self.set(i, on);
match self.view {
Some(c) => {
if let Some(col) = self.staged.get_mut(c) {
col.iter_mut().for_each(|s| *s = on);
}
}
None => {
for col in &mut self.staged {
col.iter_mut().for_each(|s| *s = on);
}
}
}
self.reread();
self.dirty = true;
}
fn view_name<'a>(&'a self, strings: &'a GlueStrings) -> &'a str {
@@ -546,7 +564,7 @@ pub(super) fn drive_addons_panel(
if close_and_save {
// `AddonList_OnOk` and `AddonList_OnCancel` play the realm dialog's pair.
sounds.write(GlueSound("gsLoginChangeRealmOK"));
panel.save();
panel.save_staged();
panel.close();
return;
}
@@ -720,7 +738,6 @@ fn spawn_panel(
commands
.spawn((
AddonsUi,
crate::glue::NativeGlue,
GlobalZIndex(1200), // over the select screen's 1100, like the delete dialog
// The reference's full-screen BACKGROUND layer, black at 0.75; it also keeps a stray
// click off the screen behind.
@@ -1514,36 +1531,27 @@ mod tests {
}
}
/// A panel over `chars` characters with no enable rows, so every bit starts at the manifest
/// default, viewing All.
/// A panel over `chars` staged columns, each starting at the manifest defaults, viewing All.
fn panel_for(chars: usize, list: Vec<InstalledAddOn>) -> AddonsPanel {
let chars: Vec<String> = (0..chars).map(|c| format!("Char{c}")).collect();
let mut p = AddonsPanel {
let staged: Vec<Vec<bool>> = (0..chars.max(1))
.map(|_| list.iter().map(|a| a.default_state).collect())
.collect();
AddonsPanel {
open: true,
store: addons::EnableStore::blank("TestRealm", &chars),
chars,
realm: "TestRealm".into(),
chars: (0..chars).map(|c| format!("Char{c}")).collect(),
baseline: staged.clone(),
staged,
list,
..Default::default()
};
p.reread();
p.dirty = false; // the tests spawn no tree
p
}
}
/// One character, whose All view is that character.
/// One column, whose All view is that column, so `p.staged[0][i]` drives everything.
fn panel(list: Vec<InstalledAddOn>) -> AddonsPanel {
panel_for(1, list)
}
/// The glue setter for character `c` (`None`: every character), then the re-read
/// `AddonList_Enable` runs.
fn set(p: &mut AddonsPanel, c: Option<usize>, i: usize, on: bool) {
let view = std::mem::replace(&mut p.view, c);
p.set(i, on);
p.view = view;
p.reread();
}
/// Every fallback label is its token's shipped value (`GlueStrings.lua:44-56`).
#[test]
fn the_status_labels_are_the_reference_globalstrings_values() {
@@ -1603,11 +1611,11 @@ mod tests {
assert_eq!(p.verdict(5).token(), None);
p.version_check = true;
set(&mut p, Some(0), 1, false);
p.staged[0][1] = false;
assert_eq!(p.verdict(1).token().as_deref(), Some("DISABLED"));
assert_eq!(p.verdict(2).token().as_deref(), Some("DEP_DISABLED"));
set(&mut p, Some(0), 3, false);
p.staged[0][3] = false;
assert_eq!(p.verdict(3).token().as_deref(), Some("DISABLED"));
}
@@ -1622,7 +1630,7 @@ mod tests {
assert_eq!(p.title_colour(0), GOLD, "loadable = gold");
assert_eq!(p.title_colour(2), BROKEN, "enabled + out of date = red");
set(&mut p, Some(0), 0, false);
p.staged[0][0] = false;
assert_eq!(p.title_colour(0), DIM, "player-disabled = grey");
assert_eq!(
p.title_colour(1),
@@ -1638,9 +1646,7 @@ mod tests {
2,
vec![addon("Lib", &[], 11200), addon("Needs", &["Lib"], 11200)],
);
// Char0 turned Lib off and Char1 on; with no row Char1 would follow Char0.
set(&mut p, Some(0), 0, false);
set(&mut p, Some(1), 0, true);
p.staged[0][0] = false; // Char0 turned Lib off, Char1 has not
assert_eq!(
p.verdict(0).token(),
@@ -1662,21 +1668,20 @@ mod tests {
let mut p = panel_for(3, vec![addon("A", &[], 11200)]);
assert_eq!(p.box_state(0), BoxState::On, "enabled for all three");
set(&mut p, None, 0, true); // a row for each, so one can differ
set(&mut p, Some(1), 0, false);
p.staged[1][0] = false;
assert_eq!(
p.box_state(0),
BoxState::Mixed,
"enabled for SOME (state 1)"
);
set(&mut p, Some(0), 0, false);
set(&mut p, Some(2), 0, false);
p.staged[0][0] = false;
p.staged[2][0] = false;
assert_eq!(p.box_state(0), BoxState::Off, "disabled for all");
p.view = Some(1);
assert_eq!(p.box_state(0), BoxState::Off);
set(&mut p, Some(1), 0, true);
p.staged[1][0] = true;
assert_eq!(p.box_state(0), BoxState::On);
}
@@ -1684,51 +1689,47 @@ mod tests {
#[test]
fn an_all_view_click_follows_the_references_checkbutton() {
let mut p = panel_for(3, vec![addon("A", &[], 11200)]);
set(&mut p, None, 0, true);
set(&mut p, Some(1), 0, false); // mixed
p.staged[1][0] = false; // mixed
p.click_row(0);
assert!(
p.bits.iter().all(|col| !col[0]),
p.staged.iter().all(|col| !col[0]),
"mixed → disabled for ALL characters"
);
p.click_row(0);
assert!(
p.bits.iter().all(|col| col[0]),
p.staged.iter().all(|col| col[0]),
"unchecked → enabled for ALL characters"
);
p.click_row(0);
assert!(p.bits.iter().all(|col| !col[0]), "checked → back off");
assert!(p.staged.iter().all(|col| !col[0]), "checked → back off");
p.view = Some(2);
p.click_row(0);
assert!(p.bits[2][0] && !p.bits[0][0] && !p.bits[1][0]);
assert!(p.staged[2][0] && !p.staged[0][0] && !p.staged[1][0]);
}
/// No enable file is touched until Okay; Cancel (`ResetAddOns`) is `close`.
#[test]
fn edits_are_staged_and_cancel_discards_them() {
let mut p = panel(vec![addon("A", &[], 11200), addon("B", &[], 11200)]);
assert_eq!(p.bits, vec![vec![true, true]]);
set(&mut p, Some(0), 0, false);
assert_eq!(p.bits, vec![vec![false, true]]);
assert_eq!(p.staged, vec![vec![true, true]]);
p.staged[0][0] = false;
assert!(
p.list[0].default_state,
"the read-in list is never mutated — only the store is, which is what makes Cancel free"
"the read-in list is never mutated — only `staged` is, which is what makes Cancel free"
);
p.close();
assert!(!p.open);
assert!(
p.bits.is_empty() && p.store.node("Char0").is_none(),
p.staged.is_empty(),
"a cancelled edit leaves nothing behind"
);
}
/// Okay (`SaveAddOns`) writes only the characters whose hash a click changed, and each one's
/// own rows only: the issue's Alice and Bob, with Carol never touched. Bob's disable reaches
/// Alice and Carol through the query, on screen at once and after the files are read back.
/// Okay writes each changed character's file and creates none for an untouched one.
#[test]
fn okay_writes_every_changed_characters_file_and_only_those() {
let _l = crate::local_state::test_env::ENV_LOCK
@@ -1746,13 +1747,6 @@ mod tests {
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 path = |who: &str| {
addons::enable_state_path(Some(&("TestRealm".to_string(), who.to_string()))).unwrap()
};
// Alice's file as some other writer left it: a rewrite would drop the stray line.
let alice_file = "Gone: disabled\nstray line\nAlpha: enabled\n";
std::fs::create_dir_all(path("Alice").parent().unwrap()).unwrap();
std::fs::write(path("Alice"), alice_file).unwrap();
let mut p = AddonsPanel::default();
p.open_for(
@@ -1761,31 +1755,12 @@ mod tests {
);
assert!(p.view.is_none(), "the reference's default selection is All");
assert_eq!(p.list.len(), 2, "discovery found the two hermetic addons");
assert_eq!(p.bits.len(), 3, "one column per roster character");
assert_eq!(p.staged.len(), 3, "one staged column per roster character");
assert_eq!(p.staged, p.baseline);
set(&mut p, Some(0), 0, true); // Alice re-ticks what her row already says
set(&mut p, Some(1), 1, false); // Bob unticks Beta
assert_eq!(
p.bits,
vec![vec![true, false]; 3],
"Bob is the only one with a Beta row, so Alice and Carol follow him at once"
);
p.save();
assert_eq!(
std::fs::read_to_string(path("Alice")).unwrap(),
alice_file,
"a setter call that changed nothing leaves the node clean and the file unwritten"
);
assert_eq!(
std::fs::read_to_string(path("Bob")).unwrap(),
"Beta: disabled\n",
"Bob's file carries his one toggle, not a row per listed addon"
);
assert!(
!path("Carol").exists(),
"an untouched character writes no file"
);
p.staged[0][0] = false;
p.staged[1][1] = false;
p.save_staged();
// Read back through the store world entry resolves against.
let roster = ["Alice".to_string(), "Bob".to_string(), "Carol".to_string()];
@@ -1796,9 +1771,20 @@ mod tests {
.map(|a| store.enabled_for(&a.name, a.default_state, Some(who)))
.collect()
};
for who in ["Alice", "Bob", "Carol"] {
assert_eq!(read(who), vec![true, false], "{who}");
}
assert_eq!(
read("Alice"),
vec![false, true],
"Alice's file carries HER edit"
);
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"
);
// Carol, with no file, takes what the others agree on; they split on both, so each falls
// to its manifest's `## DefaultState`.
assert_eq!(read("Carol"), vec![true, true]);
let _ = std::fs::remove_dir_all(&tmp);
}
@@ -1829,7 +1815,7 @@ mod tests {
vec!["Onemage".into(), "Onerogue".into()],
);
p.set_all(false);
p.save();
p.save_staged();
p.close();
// A created character, with no enable file.
@@ -1838,9 +1824,9 @@ mod tests {
vec!["Onemage".into(), "Onerogue".into(), "Freshling".into()],
);
assert!(p.view.is_none(), "reopens on All, the reference's default");
assert_eq!(p.bits.len(), 3);
assert_eq!(p.staged.len(), 3);
assert_eq!(
p.bits[2],
p.staged[2],
vec![false, false],
"the new character inherits the unanimous disable, not a blank slate"
);
@@ -1893,7 +1879,7 @@ mod tests {
p.set_all(false); // the mage turns both off; the rogue keeps the defaults
p.view = Some(1);
p.set_all(true);
p.save();
p.save_staged();
p.close();
p.open_for(
@@ -1901,7 +1887,7 @@ mod tests {
vec!["Onemage".into(), "Onerogue".into(), "Freshling".into()],
);
assert_eq!(
p.bits[2],
p.staged[2],
vec![true, false],
"split roster → each row falls to its own `## DefaultState`"
);
+11 -9
View File
@@ -92,8 +92,7 @@ pub(super) fn drive_delete_dialog(
Option<&mut Text>,
&mut Visibility,
)>,
// The pasteboard, and the layout that names the clipboard chords' letters.
(mut clipboard, layout): (NonSendMut<HostClipboard>, Res<crate::bindings::LayoutNames>),
mut clipboard: NonSendMut<HostClipboard>,
// The window drives the glue scale; its raw handle carries the Wayland clipboard's display.
window: Query<
(&Window, Option<&bevy::window::RawHandleWrapper>),
@@ -140,7 +139,6 @@ pub(super) fn drive_delete_dialog(
&mut dialog.typed,
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Any,
@@ -223,7 +221,6 @@ fn spawn_dialog(
commands
.spawn((
DialogUi,
crate::glue::NativeGlue,
GlobalZIndex(1200), // over the select screen's 1100
Node {
width: Val::Percent(100.0),
@@ -388,8 +385,8 @@ fn spawn_dialog(
..default()
},))
.with_children(|f| {
// The five-part field row: the text either side of the
// selection, which splits at the caret.
// The five-part field row: segments either side of the
// selection, a caret slot at each edge.
let segment = |f: &mut ChildSpawnerCommands, part| {
outlined_text(
f,
@@ -407,9 +404,14 @@ fn spawn_dialog(
);
};
segment(f, GlueFieldPart::Before);
segment(f, GlueFieldPart::SelectedHead);
caret_bar(f, (DeleteCaret, GlueFieldPart::Caret), 15.0, s);
segment(f, GlueFieldPart::SelectedTail);
caret_bar(
f,
(DeleteCaret, GlueFieldPart::CaretAtStart),
15.0,
s,
);
segment(f, GlueFieldPart::Selected);
caret_bar(f, (DeleteCaret, GlueFieldPart::CaretAtEnd), 15.0, s);
segment(f, GlueFieldPart::After);
});
});
+48 -87
View File
@@ -35,13 +35,14 @@ pub(super) fn select_input(
mut next: ResMut<NextState<ClientState>>,
mut sounds: MessageWriter<GlueSound>,
mut intent: ResMut<crate::login::LoginIntent>,
mut auto: ResMut<crate::autologin::AutoLogin>,
glue_dialog: Res<crate::glue::dialog::GlueDialog>,
time: Res<Time>,
mut last_click: Local<Option<(usize, f32)>>,
) {
// A modal owns the input while it is up: the delete confirm, the AddOns list, the realm list
// over this screen, or the glue dialog (whose Okay must not double as Enter World or Escape).
if dialog.open || panel.open || realms.owns_input() || glue_dialog.is_open() {
if dialog.open || panel.open || realms.shown || glue_dialog.is_open() {
return;
}
let now = time.elapsed_secs();
@@ -78,6 +79,42 @@ pub(super) fn select_input(
sounds.write(GlueSound("gsCharacterSelectionCreateNew"));
next.set(ClientState::CharCreate);
}
SelectAction::ToggleCharLock => {
sounds.write(GlueSound("gsTitleOptionOK"));
if let Some(realm) = roster.realm.as_ref().map(|r| r.name.clone()) {
let mem = auto.realm_mut(&realm);
mem.characters_locked = !mem.characters_locked;
auto.save();
}
}
SelectAction::MoveRow { row, up } => {
sounds.write(GlueSound("gsTitleOptionOK"));
let (row, up) = (row, up);
// The swap partner; an end stop is a no-op, and the arrow is hidden there anyway.
let other = if up {
row.checked_sub(1)
} else {
Some(row + 1)
};
let Some(other) =
other.filter(|&o| o < roster.chars.len() && row < roster.chars.len())
else {
continue;
};
roster.chars.swap(row, other);
// The selection follows the character, not the row: swapping the rows under a
// highlighted name would otherwise silently re-aim Enter World at its neighbour.
match roster.selected() {
Some(sel) if sel == row => roster.select(Some(other)),
Some(sel) if sel == other => roster.select(Some(row)),
_ => {}
}
if let Some(realm) = roster.realm.as_ref().map(|r| r.name.clone()) {
let order: Vec<String> = roster.chars.iter().map(|c| c.name.clone()).collect();
auto.realm_mut(&realm).order = order;
auto.save();
}
}
SelectAction::Addons => {
// The stock `OnClick` plays no sound (`CharacterSelect.xml:231-233`); this plays
// the create-screen one.
@@ -139,6 +176,16 @@ pub(super) fn select_input(
let target = roster.selected_char().map(|c| (c.guid, c.name.clone()));
if let Some((guid, name)) = target {
info!("char select: entering world as {name}");
// Remembered here rather than in `send_pick`, which every path funnels through:
// this is the one that means *the player chose*. A reconnect's re-pick and the env
// fast paths must not rewrite what the player last played on purpose.
if let Some(realm) = roster.realm.as_ref().map(|r| r.name.clone()) {
let mem = auto.realm_mut(&realm);
if mem.last_character.as_deref() != Some(name.as_str()) {
mem.last_character = Some(name.clone());
auto.save();
}
}
sounds.write(GlueSound("gsCharacterSelectionEnterWorld"));
send_pick(&mut roster, &pick, guid);
}
@@ -169,89 +216,3 @@ pub(super) fn rotate_model(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::{RealmChoice, RealmRequest};
use crate::realm_select::{list_keys, take_input, Realms};
/// Escape on the realm list raised by Change Realm closes the list and nothing else: the
/// press is the list's (`Realms::owns_input`), so this screen does not also go back to
/// login, in either order the two run.
#[test]
fn escape_on_the_realm_list_does_not_also_leave_for_login() {
for list_first in [true, false] {
let (pick_tx, pick_rx) = crossbeam_channel::unbounded();
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::CharSelect)
.init_resource::<crate::glue::GlueClicks>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<Roster>()
.init_resource::<Realms>()
.init_resource::<DeleteDialog>()
.init_resource::<super::super::addons::AddonsPanel>()
.init_resource::<crate::login::LoginIntent>()
.init_resource::<crate::glue::dialog::GlueDialog>()
.insert_resource(CharPick(pick_tx.into()))
.insert_resource(RealmChoice(realm_tx.into()))
.add_message::<GlueSound>()
.add_message::<crate::login::GlueLogin>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(PreUpdate, take_input);
if list_first {
app.add_systems(Update, (list_keys, select_input).chain());
} else {
app.add_systems(Update, (select_input, list_keys).chain());
}
app.world_mut()
.resource_scope(|world, mut realms: Mut<Realms>| {
crate::realm_select::open_over_char_select(
&mut realms,
world.resource::<RealmChoice>(),
);
});
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
app.update(); // `StateTransition` applies a pending state at the next frame
let order = if list_first {
"list first"
} else {
"screen first"
};
assert!(
!app.world().resource::<Realms>().shown,
"{order}: the list's Cancel ran"
);
assert!(
realm_rx
.try_iter()
.all(|r| matches!(r, RealmRequest::Refresh)),
"{order}: and over character select it closes nothing"
);
assert!(
app.world()
.resource::<bevy::ecs::message::Messages<crate::login::GlueLogin>>()
.is_empty(),
"{order}: nor ends the login"
);
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::CharSelect,
"{order}: the press was the list's, so the screen under it stays"
);
assert!(
pick_rx.try_recv().is_err(),
"{order}: and the session is not dropped"
);
}
}
}
+216 -165
View File
@@ -73,51 +73,48 @@ impl Plugin for CharSelectPlugin {
)
.init_resource::<dialog::DeleteDialog>()
.init_resource::<addons::AddonsPanel>()
.init_resource::<refresh::SelectBackground>()
.add_systems(
OnEnter(ClientState::CharSelect),
(
screen::enter_select.run_if(crate::ui_script::glue::native_glue),
stamp_select_entry,
),
(screen::enter_select, stamp_select_entry),
)
.add_systems(
OnExit(ClientState::CharSelect),
(
screen::exit_select,
screen::clear_select_scene.run_if(crate::ui_script::glue::native_glue),
clear_select_entry,
),
)
.add_systems(
OnEnter(ClientState::InWorld),
refresh::reset_select_background,
(screen::exit_select, clear_select_entry),
)
.add_systems(Update, (debug_glue_roundtrip, debug_logout_smoke))
.add_systems(
Update,
(
session_edges(),
// Runs in every state: the reconnect auto-answer and the logout edge arrive
// `InWorld`. `back_on_disconnect` runs after the entry: a session that died
// during entry queues both edges into one frame, and the dead one must win.
(
apply_roster_policy,
enter_on_connected,
back_on_logout,
back_on_login_refused,
back_on_disconnect,
// Last and ungated: it mirrors the pick the frame ended with, and world
// entry is reached from the create screen too.
persist_last_character,
)
.chain(),
(
screen::materialize_screen,
input::select_input.run_if(crate::ui_script::glue::native_glue),
input::rotate_model.run_if(crate::ui_script::glue::native_glue),
input::select_input,
input::rotate_model,
debug_select_dialog,
dialog::drive_delete_dialog.run_if(crate::ui_script::glue::native_glue),
dialog::drive_delete_dialog,
// Ahead of the dialog driver, so a refused delete shows the frame it lands.
delete_result,
// The one `GlueDialog`: here, the refused login and the refused delete.
crate::glue::dialog::drive_glue_dialog
.run_if(crate::ui_script::glue::native_glue),
crate::glue::dialog::drive_glue_dialog,
debug_select_addons,
debug_select_walk,
addons::drive_addons_panel.run_if(crate::ui_script::glue::native_glue),
addons::drive_addons_panel,
refresh::refresh_list,
refresh::refresh_banner_and_buttons,
// The stock screens drive the booth off the glue VM instead.
refresh::feed_glue_preview
.run_if(crate::ui_script::glue::native_glue)
.in_set(crate::portrait::GluePreviewFeed),
refresh::feed_glue_preview,
debug_select_shot,
)
.chain()
@@ -140,26 +137,6 @@ impl Plugin for CharSelectPlugin {
}
}
/// The session's edges, in every state: the reconnect auto-answer and the logout edge arrive
/// `InWorld`. A logout or a refused login clears the pick before the roster policy reads it: the
/// roster comes right behind them on the kept link, often in the same drain, and must be shown,
/// not answered with the pick just left. `back_on_disconnect` runs after the entry: a session that
/// died during entry queues both edges into one frame, and the dead one must win.
fn session_edges() -> bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::ScheduleSystem> {
(
back_on_logout,
back_on_login_refused,
apply_roster_policy,
enter_on_connected,
back_on_disconnect,
// Last and ungated: it mirrors the pick the frame ended with, and world entry is reached
// from the create screen too.
persist_last_character,
)
.chain()
.into_configs()
}
// ── The roster and pick policy ──────────────────────────────────────────────────────────────────
/// The account roster and the pick policy's memory.
@@ -338,6 +315,7 @@ pub(crate) fn send_pick(roster: &mut Roster, pick: &CharPick, guid: u64) {
fn apply_roster_policy(
mut msgs: MessageReader<CharListMessage>,
mut roster: ResMut<Roster>,
auto: Res<crate::autologin::AutoLogin>,
pick: Res<CharPick>,
// The remembered row, read off the registry, the store that outlives every VM.
cvars: Res<crate::cvars::Cvars>,
@@ -367,6 +345,20 @@ fn apply_roster_policy(
for msg in msgs.read() {
roster.chars = msg.characters.clone();
roster.realm = msg.realm.clone();
// The player's own row order (the AutoLogin mod's per-realm `order`), applied here
// because everything below indexes a row: the remembered-row CVar, `select_created_by_name`
// and `WOW_CHARSELECT_PICK` all mean rows as the player sees them.
if let Some(mem) = roster
.realm
.as_ref()
.and_then(|r| auto.realm(&r.name))
.filter(|m| !m.order.is_empty())
{
let names: Vec<String> = roster.chars.iter().map(|c| c.name.clone()).collect();
let perm = crate::autologin::apply_order(&mem.order, &names);
let reordered: Vec<_> = perm.iter().map(|&i| roster.chars[i].clone()).collect();
roster.chars = reordered;
}
// A still-armed create selects by name, else the last row: the reference's
// `SELECT_LAST_CHARACTER` is `SelectCharacter(numChars)`, and vmangos enumerates
// `ORDER BY create_time, guid`. It outranks the remembered row: the reference pushes the
@@ -387,9 +379,25 @@ fn apply_roster_policy(
} else if !created {
// Re-applied on every roster: the reference reads the CVar in the list rebuild
// (`0x4724d0` to `0x472740`), not once at startup.
let row = remembered_row(&cvars, roster.chars.len());
//
// The store's remembered name outranks the CVar when it has one, and that is not a
// preference — the CVar is a row *index*, and this screen now lets the player
// reorder rows, so after a reorder the index names whoever moved into that slot.
// A name survives the reordering the index cannot.
let by_name = roster
.realm
.as_ref()
.and_then(|r| auto.realm(&r.name))
.and_then(|m| m.last_character.as_deref())
.and_then(|want| {
roster
.chars
.iter()
.position(|c| c.name.eq_ignore_ascii_case(want))
});
let row = by_name.unwrap_or_else(|| remembered_row(&cvars, roster.chars.len()));
let who = roster.chars[row].name.clone();
info!("char select: {CVAR_LAST_CHARACTER} selects row {row} ({who})");
info!("char select: selecting row {row} ({who})");
roster.select(Some(row));
}
// `WOW_CHARSELECT_PICK=<name>` selects that row and stays on the screen.
@@ -464,7 +472,7 @@ fn back_on_disconnect(
/// `SMSG_CHARACTER_LOGIN_FAILED`: back to select with the refusal in the glue dialog. The IO
/// thread announces the entry with the pick, so this undoes a half-built one. The pick must be
/// cleared, or the roster behind the refusal is auto-answered and the refused login loops.
/// cleared, or the relist behind the refusal is auto-answered and the refused login loops.
fn back_on_login_refused(
mut msgs: MessageReader<CharacterLoginFailedMessage>,
mut roster: ResMut<Roster>,
@@ -611,8 +619,7 @@ fn debug_glue_roundtrip(
}
/// The logout smoke (`WOW_LOGOUT_SMOKE=1`, with `WOW_CHAR`): once in the world, `/logout`, return
/// to CharSelect, wait for the roster the logout brings on the link the world was entered by,
/// enter the world again and close the window. The second entry streams a map
/// to CharSelect, enter the world again and close the window. The second entry streams a map
/// `terrain_stream::release_world` tore down. It ends by a window close, not an `AppExit`, since
/// only the close reaches the shutdown tail that saves variables and addon files.
fn debug_logout_smoke(
@@ -626,35 +633,12 @@ fn debug_logout_smoke(
mut exit: MessageWriter<AppExit>,
mut close: MessageWriter<bevy::window::WindowCloseRequested>,
windows: Query<Entity, With<bevy::window::PrimaryWindow>>,
mut lists: MessageReader<CharListMessage>,
// The world session's number at the entry, and whether a roster has come since the logout.
mut link: Local<Option<u64>>,
mut relisted: Local<bool>,
mut phase: Local<u8>,
mut mark: Local<f32>,
) {
if std::env::var("WOW_LOGOUT_SMOKE").is_err() {
return;
}
for list in lists.read() {
if *phase < 2 {
*link = Some(list.epoch);
} else if !*relisted {
*relisted = true;
if Some(list.epoch) == *link {
info!(
"logout-smoke: roster after the logout on the same link — {} char(s)",
list.characters.len()
);
} else {
error!(
"logout-smoke: FAILED — the roster after the logout came on world session {} \
where the world was entered on {:?}",
list.epoch, *link
);
}
}
}
let now = time.elapsed_secs();
match *phase {
0 if *state.get() == ClientState::InWorld && player.active => {
@@ -672,24 +656,21 @@ fn debug_logout_smoke(
}
// Residency is read here, not on arrival: `release_world` runs about 150 ms after the
// state edge, on the world-live falling edge.
// The roster the logout brings, not the one that survived it, decides the re-entry.
3 if now - *mark > 4.0 && *relisted => {
match roster.chars.first().map(|c| (c.guid, c.name.clone())) {
Some((guid, name)) => {
info!(
"logout-smoke: {} tiles resident after the release (must be 0)",
streamer.residency().1
);
info!("logout-smoke: re-entering the world as {name}");
send_pick(&mut roster, &pick, guid);
(*phase, *mark) = (4, now);
}
None => {
warn!("logout-smoke: empty roster — cannot test re-entry");
*phase = 5;
}
3 if now - *mark > 4.0 => match roster.chars.first().map(|c| (c.guid, c.name.clone())) {
Some((guid, name)) => {
info!(
"logout-smoke: {} tiles resident after the release (must be 0)",
streamer.residency().1
);
info!("logout-smoke: re-entering the world as {name}");
send_pick(&mut roster, &pick, guid);
(*phase, *mark) = (4, now);
}
}
None => {
warn!("logout-smoke: empty roster — cannot test re-entry");
*phase = 5;
}
},
4 if *state.get() == ClientState::InWorld && player.active && now - *mark > 3.0 => {
// The suppressor reading catches a character that re-entered unable to move;
// `scripts/smoke.sh` fails on anything but `none`. A GM account's logout is instant,
@@ -906,21 +887,15 @@ pub(crate) fn wow_font(assets: &AssetServer) -> Handle<Font> {
// ── 1.12 display names ──────────────────────────────────────────────────────────────────────────
/// A race's caption on the glue screens: the race button's hover label and the create screen's
/// race-panel title.
///
/// One table, [`crate::ui_unit::race_names`], which reads the chain's own `ChrRaces.dbc` first —
/// the realm is the authority on what a race is called, and a second frozen table here used to
/// disagree with the world's (OctoWoW row 10 is "High Elf", not "Blood Elf"). `"?"` only for a
/// race id the chain and the frozen fallback both have no row for.
pub(crate) fn race_name(race: u8) -> &'static str {
match race {
1 => "Human",
2 => "Orc",
3 => "Dwarf",
4 => "Night Elf",
5 => "Undead",
6 => "Tauren",
7 => "Gnome",
8 => "Troll",
// TurtleWoW's additions, absent from stock 1.12 (see `ui_unit::race_names`).
9 => "Goblin",
10 => "Blood Elf",
_ => "?",
}
crate::ui_unit::race_names(race).map_or("?", |(display, _)| display)
}
pub(crate) fn class_name(class: u8) -> &'static str {
@@ -973,7 +948,8 @@ pub(crate) fn test_character(guid: u64, name: &str) -> Character {
pub(crate) fn add_test_systems(app: &mut App, pick: crossbeam_channel::Sender<CharRequest>) {
app.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.insert_resource(CharPick(pick.into()))
.init_resource::<crate::autologin::AutoLogin>()
.insert_resource(CharPick(pick))
.add_message::<CharListMessage>()
.add_systems(
Update,
@@ -1108,56 +1084,6 @@ mod tests {
);
}
/// A logout, or a refused login, and the roster the kept link sends right behind it can land
/// in one drain: the pick the world was entered with goes first, so the roster is shown and
/// not answered with that pick again.
#[test]
fn a_return_and_its_roster_in_one_drain_show_the_roster() {
for refused in [false, true] {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::InWorld)
.insert_resource(Roster::with_pending_pick(vec![character(7, "Seven")], 7))
.insert_resource(CharPick(tx.into()))
.init_resource::<crate::cvars::Cvars>()
.init_resource::<crate::glue::dialog::GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<EnteredWorldMessage>()
.add_message::<LoggedOutMessage>()
.add_message::<CharacterLoginFailedMessage>()
.add_message::<crate::net::DisconnectedMessage>()
.add_systems(Update, session_edges());
app.world_mut().resource_mut::<Roster>().env_read = true;
if refused {
app.world_mut()
.write_message(CharacterLoginFailedMessage { result: 0x01 });
} else {
app.world_mut().write_message(LoggedOutMessage);
}
app.world_mut().write_message(CharListMessage {
characters: vec![character(7, "Seven")],
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 1,
generation: 0,
});
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::CharSelect,
"refused: {refused}"
);
assert!(
rx.try_recv().is_err(),
"refused: {refused} — the roster was answered with the pick just left"
);
assert_eq!(app.world().resource::<Roster>().pending_pick, None);
}
}
/// Every refusal byte resolves to the sentence 1.12 ships, off the player's chain. `4` is
/// `CHAR_LOGIN_DISABLED`: the reference's table skips `CHAR_LOGIN_FAILED` (0x41).
#[test]
@@ -1299,13 +1225,14 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx.into()))
.insert_resource(CharPick(tx))
.insert_resource(match stored {
Some(v) => crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, v),
None => crate::cvars::Cvars::default(),
})
.add_message::<CharListMessage>()
.add_message::<AppExit>()
.init_resource::<crate::autologin::AutoLogin>()
.add_systems(Update, apply_roster_policy);
if let Some(row) = preselected {
app.world_mut().resource_mut::<Roster>().select(Some(row));
@@ -1317,15 +1244,141 @@ mod tests {
.map(|(i, n)| character(i as u64 + 1, n))
.collect(),
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 0,
generation: 0,
});
app.update();
app.world().resource::<Roster>().selected()
}
/// The screen opens on the character last logged in as; the stored value is 0-based.
/// A realm the auto-login store has memory for, as the wire names it.
fn realm_named(name: &str) -> benilla_protocol::RealmInfo {
benilla_protocol::RealmInfo {
name: name.to_string(),
address: "127.0.0.1:8085".into(),
population: 1.0,
characters: 0,
realm_type: 0,
flags: 0,
category: 0,
id: 1,
}
}
/// Run `apply_roster_policy` over `names` with the given store, and read back the row
/// order, whether a pick went out, and which row ended up selected.
fn policy_with_store(
auto: crate::autologin::AutoLogin,
names: &[&str],
) -> (Vec<String>, Option<u64>, Option<usize>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin)
.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_resource(crate::cvars::Cvars::default())
.insert_resource(auto)
.add_message::<CharListMessage>()
.add_message::<AppExit>()
.add_systems(Update, apply_roster_policy);
app.world_mut().resource_mut::<Roster>().env_read = true;
app.world_mut().write_message(CharListMessage {
characters: names
.iter()
.enumerate()
.map(|(i, n)| test_character(i as u64 + 1, n))
.collect(),
realm: Some(realm_named("C'Thun")),
});
app.update();
let order = app
.world()
.resource::<Roster>()
.chars
.iter()
.map(|c| c.name.clone())
.collect();
let picked = match rx.try_recv() {
Ok(crate::net::CharRequest::Enter(guid)) => Some(guid),
_ => None,
};
(order, picked, app.world().resource::<Roster>().selected())
}
fn store_with(
order: &[&str],
last: Option<&str>,
enabled: bool,
) -> crate::autologin::AutoLogin {
let mut auto = crate::autologin::AutoLogin {
enabled,
account: "ACCT".into(),
password: if enabled { "pw".into() } else { String::new() },
..Default::default()
};
let mem = auto.realm_mut("C'Thun");
mem.order = order.iter().map(|s| (*s).to_string()).collect();
mem.last_character = last.map(str::to_string);
auto
}
/// The player's own row order is what the screen shows, not the server's enumeration.
#[test]
fn the_saved_row_order_is_applied_to_the_roster() {
let (order, ..) = policy_with_store(
store_with(&["Zzbullone", "Kerwind", "Xero"], None, false),
&["Kerwind", "Xero", "Zzbullone"],
);
assert_eq!(order, vec!["Zzbullone", "Kerwind", "Xero"]);
}
/// A character made since the order was saved still appears — at the bottom, never dropped.
#[test]
fn a_character_missing_from_the_saved_order_is_still_shown() {
let (order, ..) = policy_with_store(
store_with(&["Xero"], None, false),
&["Kerwind", "Xero", "Newbie"],
);
assert_eq!(order, vec!["Xero", "Kerwind", "Newbie"]);
}
/// The point of the change: the roster is shown, never entered for the player.
#[test]
fn a_remembered_character_is_selected_and_the_world_is_never_entered() {
let (_, picked, _) = policy_with_store(
store_with(&[], Some("Xero"), true),
&["Kerwind", "Xero", "Zzbullone"],
);
assert_eq!(
picked, None,
"the store fills a password and remembers a row; entering the world stays the \
player's own click"
);
}
/// Remembered by **name**, which is why it outranks the stock CVar: that CVar is a row
/// index, and reordering moves whoever is in a given row.
#[test]
fn the_remembered_name_outranks_the_row_index_after_a_reorder() {
// The CVar says row 0; the store says Zzbullone, who the saved order puts last.
let auto = store_with(&["Kerwind", "Xero", "Zzbullone"], Some("Zzbullone"), false);
let (order, _, selected) = policy_with_store(auto, &["Zzbullone", "Kerwind", "Xero"]);
assert_eq!(order, vec!["Kerwind", "Xero", "Zzbullone"]);
assert_eq!(
selected,
Some(2),
"the name followed its character to row 2; the index would have kept row 0"
);
}
/// Deleted, or on another realm: fall back to the stock remembered row rather than nothing.
#[test]
fn a_remembered_character_who_is_gone_falls_back_to_the_stock_row() {
let (_, _, selected) =
policy_with_store(store_with(&[], Some("Ghost"), false), &["Kerwind", "Xero"]);
assert_eq!(selected, Some(0));
}
/// The screen opens on the character last logged in as; the stored value is 0-based. /// The screen opens on the character last logged in as; the stored value is 0-based.
#[test]
fn the_roster_opens_on_the_remembered_character() {
assert_eq!(
@@ -1414,10 +1467,11 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx.into()))
.insert_resource(CharPick(tx))
.insert_resource(crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, "0"))
.add_message::<CharListMessage>()
.add_message::<AppExit>()
.init_resource::<crate::autologin::AutoLogin>()
.add_systems(Update, apply_roster_policy);
// The create result lands before the fresh roster, the order `apply_roster_policy` answers.
app.world_mut()
@@ -1430,9 +1484,6 @@ mod tests {
character(3, "Zzbullone"),
],
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 0,
generation: 0,
});
app.update();
assert_eq!(
@@ -1469,7 +1520,7 @@ mod tests {
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.init_resource::<crate::cvars::Cvars>()
.insert_resource(CharPick(tx.into()))
.insert_resource(CharPick(tx))
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
+42 -142
View File
@@ -7,7 +7,7 @@ use crate::area::AreaTableRes;
use crate::glue::widgets::{GlueDisabled, Hilight, LockHighlight};
use crate::glue_strings::GlueStrings;
use crate::net::NetStatus;
use crate::portrait::{GlueLook, GluePreview, GlueScene, SelectLook};
use crate::portrait::{GlueLook, GluePreview, SelectLook};
use super::screen::{RealmBanner, RowText, SelectAction, SelectedName, MAX_ROWS};
use super::{class_name, Roster};
@@ -17,8 +17,8 @@ use super::{class_name, Roster};
#[allow(clippy::type_complexity)]
pub(super) fn refresh_list(
roster: Res<Roster>,
auto: Res<crate::autologin::AutoLogin>,
areas: Option<Res<AreaTableRes>>,
realms: Option<Res<crate::realm_select::Realms>>,
strings: Option<Res<GlueStrings>>,
status: Res<NetStatus>,
mut rows: Query<(&SelectAction, &mut Visibility), (With<Button>, Without<Hilight>)>,
@@ -28,13 +28,43 @@ pub(super) fn refresh_list(
spawned: Query<Ref<SelectAction>>,
) {
let fresh = spawned.iter().any(|r| r.is_added());
if !roster.is_changed() && !fresh && !status.is_changed() {
// `auto` joins the guard because the reorder lock lives there: flipping it changes which
// rows show arrows without touching the roster.
if !roster.is_changed() && !fresh && !status.is_changed() && !auto.is_changed() {
return;
}
// The AutoLogin mod's per-realm `char_buttons_locked`, locked until the player says so.
let locked = roster
.realm
.as_ref()
.and_then(|r| auto.realm(&r.name))
.is_none_or(|m| m.characters_locked);
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
for (action, mut vis) in &mut rows {
// The reorder arrows are rows' children and `Button`s too, so they come through this
// same query — handled here rather than in a system of their own, which would have
// bought a fresh row of undeclared orders against every other `Visibility` writer in
// `Update` for work this loop was already walking past.
if let SelectAction::MoveRow { row, up } = action {
// Hidden at the ends, as every end-stopped control on these screens is: the first
// row cannot go up and the last cannot go down.
let movable = if *up {
*row > 0
} else {
*row + 1 < roster.chars.len()
};
let want = if !locked && *row < roster.chars.len() && movable {
Visibility::Inherited
} else {
Visibility::Hidden
};
if *vis != want {
*vis = want;
}
continue;
}
if let SelectAction::Row(i) = action {
*vis = if *i < roster.chars.len() {
Visibility::Inherited
@@ -91,10 +121,7 @@ pub(super) fn refresh_list(
Some(realm) => {
// `GetServerName`'s `isPVP, isRP` pair. A normal realm has no suffix, unlike
// the realm list's `Normal`: the reference leaves `serverType = ""`.
let pvp_rp = realms
.as_deref()
.map_or((false, false), |r| r.pvp_rp(realm.realm_type));
let suffix = match pvp_rp {
let suffix = match crate::realm_select::pvp_rp(realm.realm_type) {
(true, true) => strings.text("RPPVP_PARENTHESES", "(RPPVP)"),
(false, true) => strings.text("RP_PARENTHESES", "(RP)"),
(true, false) => strings.text("PVP_PARENTHESES", "(PVP)"),
@@ -170,32 +197,10 @@ pub(super) fn refresh_banner_and_buttons(
}
}
/// The select scene's background as `CharacterSelect` holds it for the glue session: `race` is
/// `None` while only its OnLoad has set the scene (`Model:SetModel(UI_Orc)` under Orc's fog,
/// `CharacterSelect.lua:26-31`), else the race whose file string `SetBackgroundModel` last set
/// (`CharacterSelect.lua:341-344`), drawn unfogged (`0x472110` replaces the fog callback). The
/// call is keyed on the race's file string, not its stage, so a gnome after a dwarf loads the
/// same stage again, a new instance whose scene starts over (`0x76cce0`): `stage` counts them. An
/// emptied roster selects nothing and calls neither, so the scene stays. The glue rebuild at world
/// entry restores OnLoad's.
#[derive(Resource, Default)]
pub(crate) struct SelectBackground {
pub(super) race: Option<u8>,
pub(super) stage: u64,
}
/// The glue rebuild (`0x46a7b0`) runs `CharacterSelect`'s OnLoad again, a new instance.
pub(super) fn reset_select_background(mut background: ResMut<SelectBackground>) {
background.race = None;
background.stage = background.stage.wrapping_add(1);
}
/// Feed the glue booth: the scene is the selected character's stage (`SetBackgroundModel`),
/// unfogged; with no character the last stage stays, or the OnLoad Orc scene under its fog stands
/// ([`SelectBackground`]); the look is the selected character's geared enum record.
/// Feed the glue booth: the scene is the selected character's race (`SetBackgroundModel`; Orc
/// with no character, the reference's OnLoad default), the look its geared enum record.
pub(super) fn feed_glue_preview(
roster: Res<Roster>,
mut background: ResMut<SelectBackground>,
mut preview: ResMut<GluePreview>,
mut showing: Local<Option<u64>>,
) {
@@ -206,25 +211,13 @@ pub(super) fn feed_glue_preview(
*showing = Some(roster.select_seq);
preview.yaw = 0.0;
}
let look = roster.selected_char().map(|c| {
if background.race != Some(c.race) {
background.race = Some(c.race);
background.stage = background.stage.wrapping_add(1);
}
GlueLook::Select(SelectLook::from(c))
});
let (scene, fog) = match background.race {
Some(race) => (GlueScene::of_race(race), None),
None => (GlueScene::Orc, Some(GlueScene::Orc.stock_fog())),
let (race, look) = match roster.selected_char() {
Some(c) => (c.race, Some(GlueLook::Select(SelectLook::from(c)))),
None => (2, None), // UI_Orc, the reference's empty-account scene
};
if preview.scene != Some(scene) {
preview.scene = Some(scene);
}
if preview.stage != background.stage {
preview.stage = background.stage;
}
if preview.fog != fog {
preview.fog = fog;
let scene = Some(crate::portrait::GlueScene::Race(race));
if preview.scene != scene {
preview.scene = scene;
}
if preview.look != look {
preview.look = look;
@@ -235,99 +228,6 @@ pub(super) fn feed_glue_preview(
mod tests {
use super::*;
/// #978: an empty roster's select scene is OnLoad's Orc under Orc's fog; a selected character
/// sets its stage, unfogged, and that stage stays when the roster empties; the glue rebuild at
/// world entry restores OnLoad's.
fn tauren_again() -> benilla_protocol::Character {
let mut c = crate::char_select::test_character(1, "Moo");
c.race = 6;
c
}
#[test]
fn the_select_scene_fogs_until_a_background_is_set_and_then_stays() {
let mut app = App::new();
app.init_resource::<Roster>()
.init_resource::<SelectBackground>()
.init_resource::<GluePreview>()
.add_systems(Update, feed_glue_preview);
let shown = |app: &App| {
let p = app.world().resource::<GluePreview>();
(p.scene, p.fog.map(|f| f.far), p.look.is_some())
};
app.update();
assert_eq!(shown(&app), (Some(GlueScene::Orc), Some(270.0), false));
let mut tauren = crate::char_select::test_character(1, "Moo");
tauren.race = 6;
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars = vec![tauren];
roster.select(Some(0));
}
app.update();
assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, true));
// A gnome after a dwarf: the same stage, set again, starts over (`0x76cce0`).
let stage = |app: &App| app.world().resource::<GluePreview>().stage;
let mut dwarf = crate::char_select::test_character(2, "Stone");
dwarf.race = 3;
let mut gnome = crate::char_select::test_character(3, "Cog");
gnome.race = 7;
let first = stage(&app);
for (c, row) in [(dwarf, 0), (gnome, 1)] {
let before = stage(&app);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars.insert(row, c);
roster.select(Some(row));
}
app.update();
assert_eq!(shown(&app).0, Some(GlueScene::Dwarf));
assert_ne!(
stage(&app),
before,
"a new race's background is a new instance"
);
}
// Back to the dwarf, then the same row again: only a race change makes one.
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.select(Some(0));
}
app.update();
let dwarf_again = stage(&app);
assert!(dwarf_again != first);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.select(Some(0));
}
app.update();
assert_eq!(stage(&app), dwarf_again);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars = vec![tauren_again()];
roster.select(Some(0));
}
app.update();
assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, true));
// The last character deleted: nothing is selected, and no background call replaces it.
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars.clear();
roster.select(None);
}
app.update();
assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, false));
app.world_mut()
.run_system_cached(reset_select_background)
.unwrap();
app.update();
assert_eq!(shown(&app), (Some(GlueScene::Orc), Some(270.0), false));
}
/// The composition order of `CharacterSelect.lua:48-64`.
#[test]
fn the_down_note_hangs_off_the_name_and_the_type_suffix_follows_it() {
+76 -24
View File
@@ -8,7 +8,8 @@ use bevy::window::PrimaryWindow;
use crate::glue::art::{GlueArt, BACKDROP, DIM, GOLD, NAME_EDGE};
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
use crate::glue::widgets::{
abs, glue_button, outlined_text, overlay, GlueBtnKind, GlueText, Hilight, LockHighlight,
abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight,
LockHighlight,
};
use crate::glue_strings::GlueStrings;
use crate::portrait::{GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
@@ -34,6 +35,13 @@ pub(super) enum SelectAction {
Addons,
RotateLeft,
RotateRight,
/// Toggle the rows' reorder lock (the AutoLogin mod's Unlock Characters).
ToggleCharLock,
/// Move a row one place, by its 0-based index.
MoveRow {
row: usize,
up: bool,
},
}
/// Root of the select screen. `with_art` and the glue scale `s` it was built at let an artless
@@ -70,13 +78,10 @@ const ROW_HIT_H: f32 = 55.0;
const ROW_PITCH: f32 = 57.0;
/// Entry only resets state: the initial state's `OnEnter` fires at startup, before the MPQ chain
/// and booth slots exist, so the tree spawns in [`materialize_screen`]. The native screen's alone:
/// the stock screens' facing is the glue VM's.
/// and booth slots exist, so the tree spawns in [`materialize_screen`].
pub(super) fn enter_select(mut preview: ResMut<GluePreview>) {
// The model faces the camera on entry: the reference's facing global defaults to zero.
preview.yaw = 0.0;
// `CharacterSelect`'s OnShow (`0x46fa60`).
preview.shown = preview.shown.wrapping_add(1);
}
/// Spawn the screen tree once the art is loaded, respawn it when art lands after an artless spawn
@@ -148,7 +153,6 @@ fn spawn_screen(
let root = commands
.spawn((
crate::glue::NativeGlue,
CharSelectUi {
with_art: art.button_up.is_some(),
s,
@@ -239,16 +243,19 @@ fn spawn_screen(
rotate_cluster(ui, art, &font, s);
// `CharacterSelectAddonsButton`, hidden on `GetNumAddOns() == 0` (`UpdateAddonButton`).
if super::addons::AddonsPanel::any_installed() {
ui.spawn((Node {
position_type: PositionType::Absolute,
left: px(30.0),
bottom: px(25.0),
flex_direction: FlexDirection::Row,
..default()
},))
.with_children(|actions| {
// `CharacterSelectAddonsButton`, hidden on `GetNumAddOns() == 0` (`UpdateAddonButton`),
// and the AutoLogin mod's Unlock Characters beside it — which is always shown, since
// reordering has nothing to do with whether an addon is installed.
ui.spawn((Node {
position_type: PositionType::Absolute,
left: px(30.0),
bottom: px(25.0),
flex_direction: FlexDirection::Row,
column_gap: px(4.0), // the mod's `("LEFT", addonsButton, "RIGHT", 4, 0)`
..default()
},))
.with_children(|actions| {
if super::addons::AddonsPanel::any_installed() {
glue_button(
actions,
art,
@@ -260,8 +267,27 @@ fn spawn_screen(
GlueBtnKind::Small,
s,
);
});
}
}
// Deviation from the mod, twice over, and both to avoid saying something
// false. The mod labels this "Unlock Characters" always and greys the text
// while unlocked — but a greyed caption is this screen's own word for
// *disabled* (`glue_hilights` greys exactly that way), and the fixed label is
// wrong in one of its two states. "Reorder Characters" names the mode rather
// than the next transition, so it is accurate in both and needs no retitling —
// which would otherwise mean rewriting the caption's outline copies too. The
// arrows appearing on the rows are the state readout.
glue_button(
actions,
art,
&font,
SelectAction::ToggleCharLock,
"Reorder Characters",
170.0,
35.0,
GlueBtnKind::Small,
s,
);
});
// Back (100×35 at BOTTOMRIGHT (-30,25)) and Delete Character (165×35 at its LEFT).
ui.spawn((Node {
@@ -450,6 +476,35 @@ fn row_button(
abs(s, -20.0, -8.0, 256.0, 74.0),
));
}
// The reorder arrows: 32² at the row's right edge, the upper one at the top and the
// lower overlapping it by 8, which is the mod's own `("TOP", up, "BOTTOM", 0, 8)`.
// Spawned hidden on every row and revealed by `refresh_row_arrows`, so flipping the
// lock costs a visibility write rather than a screen rebuild.
for (up, top, art_pair) in [
(true, 0.0, (&art.row_arrow_up, &art.row_arrow_up_pushed)),
(
false,
24.0,
(&art.row_arrow_down, &art.row_arrow_down_pushed),
),
] {
let Some((normal, _)) = art_pair.0 else {
continue;
};
let mut a = b.spawn((
SelectAction::MoveRow { row, up },
Button,
Visibility::Hidden,
ImageNode::new(normal.clone()),
abs(s, ROW_W - 62.0, top, 32.0, 32.0),
));
if let Some((pushed, _)) = art_pair.1 {
a.insert(ArtSwap {
up: normal.clone(),
down: pushed.clone(),
});
}
}
outlined_text(
b,
Node {
@@ -605,21 +660,18 @@ pub(super) fn hide_under_world_cover(
}
}
/// Leaving the native select screen drops its scene; the next screen sets its own.
pub(super) fn clear_select_scene(mut preview: ResMut<GluePreview>) {
preview.look = None;
preview.scene = None;
}
pub(super) fn exit_select(
mut commands: Commands,
roots: Query<Entity, With<CharSelectUi>>,
mut preview: ResMut<GluePreview>,
mut dialog: ResMut<super::dialog::DeleteDialog>,
mut glue_dialog: ResMut<crate::glue::dialog::GlueDialog>,
) {
for e in &roots {
commands.entity(e).despawn();
}
preview.look = None;
preview.scene = None;
dialog.close();
// The glue dialog must not follow into the world or the create screen. Its tree is despawned
// here, since its driver runs on the glue screens only.
@@ -70,8 +70,6 @@ fn collect_character_shadows(
Option<&RigPart>,
&ShadowOccluder,
Option<&MeshMaterial3d<WowModelMaterial>>,
// MONKEY (B035): the part's mesh bound, for the WMO/doodad caster reach.
Option<&bevy::camera::primitives::Aabb>,
),
Without<BillboardCard>,
>,
File diff suppressed because it is too large Load Diff
-51
View File
@@ -157,7 +157,6 @@ impl Plugin for CinematicPlugin {
Update,
drive_letterbox.in_set(WorldStage::Input).after(drive),
)
.add_systems(Update, send_opening_cinematic.in_set(WorldStage::Input))
// Leaving the world drops a cinematic with no ack, as the reference's teardown does
// (`0x490a80` clears the flag and sends no `CMSG_COMPLETE_CINEMATIC`).
.add_systems(OnExit(ClientState::InWorld), abandon_on_leaving_world);
@@ -578,20 +577,6 @@ fn feed_ui(
);
}
/// Each Lua `OpeningCinematic()` is one empty `CMSG_OPENING_CINEMATIC` (`0x48c8c0`); the server
/// answers, if at all, with the `SMSG_TRIGGER_CINEMATIC` [`take_trigger`] plays.
fn send_opening_cinematic(script: Option<NonSendMut<UiScript>>, net: Option<Res<NetCommands>>) {
let Some(mut script) = script else {
return;
};
let asks = script.take_opening_cinematic_asks();
if let Some(net) = net {
for _ in 0..asks {
let _ = net.0.send(ClientCommand::OpeningCinematic);
}
}
}
fn ack(net: Option<&NetCommands>) {
if let Some(net) = net {
let _ = net.0.send(ClientCommand::CompleteCinematic);
@@ -852,39 +837,3 @@ mod settling_under_black {
);
}
}
/// Lua's `OpeningCinematic()` reaches the wire as one empty `CMSG_OPENING_CINEMATIC` per call.
#[cfg(test)]
mod opening_cinematic {
use super::*;
#[test]
fn each_call_sends_one_opening_cinematic() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx));
app.insert_non_send_resource(UiScript::new().unwrap());
app.add_systems(Update, send_opening_cinematic);
app.world()
.non_send_resource::<UiScript>()
.run("OpeningCinematic() OpeningCinematic()")
.unwrap();
app.update();
let sent = rx
.try_iter()
.filter(|c| matches!(c, ClientCommand::OpeningCinematic))
.count();
assert_eq!(sent, 2);
app.update();
assert_eq!(rx.try_iter().count(), 0, "drained once");
// The opcode the reference puts at `0x48c8c9`.
assert_eq!(
benilla_protocol::messages::opcode::CMSG_OPENING_CINEMATIC,
0xf9
);
assert_eq!(
benilla_protocol::messages::opcode_name(0xf9),
Some("CMSG_OPENING_CINEMATIC")
);
}
}
+5 -57
View File
@@ -1139,11 +1139,8 @@ pub(super) fn environmental_damage_log(
/// `SMSG_SET_FACTION_STANDING`: "Your %s reputation has increased by %d.". The wire carries the
/// new total and the sentence the delta, so this runs before the store is overwritten, and an
/// unchanged value prints nothing (`0x4d6344`). Only the packet's first entry prints, and only if
/// its faction is not `HIDDEN`: the handler passes `0x4d6330` its print argument as
/// `index == 0 && !(flags & 4)` (`0x4d57dd`-`0x4d57ea`), so the spill-over entries of one kill
/// update silently. No range gate or classifier: the chat type is the literal `0x55`
/// `COMBAT_FACTION_CHANGE`. The wire's list id is `Faction.dbc`'s `rep_index`.
/// unchanged value prints nothing (`0x62c5f0`). No range gate or classifier: the chat type is the
/// literal `0x55` `COMBAT_FACTION_CHANGE`. The wire's list id is `Faction.dbc`'s `rep_index`.
pub(crate) fn faction_standing(
deltas: &[(u32, i32)],
reputations: &Reputations,
@@ -1153,15 +1150,11 @@ pub(crate) fn faction_standing(
let Some(catalog) = factions.map(|f| f.catalog()) else {
return;
};
for &(list_id, standing) in deltas.iter().take(1) {
let (flags, old) = reputations
for &(list_id, standing) in deltas {
let old = reputations
.0
.get(list_id as usize)
.copied()
.unwrap_or((0, 0));
if flags & benilla_formats::faction_flags::HIDDEN != 0 {
continue;
}
.map_or(0, |(_, standing)| *standing);
let delta = standing - old;
if delta == 0 {
continue;
@@ -1399,49 +1392,4 @@ mod tests {
// The direct packet takes the same wire number through one divide only.
assert_eq!(leech_figures(1, 100, 1.0), Some((10, 10)));
}
/// A standing packet prints the "increased by" line for its first entry alone (`0x4d57dd`), and
/// not at all when that faction is `HIDDEN`; the factions a kill spills over to update
/// silently. On the real DBC, Booty Bay moving with Gadgetzan and Ratchet.
#[test]
fn a_standing_packet_prints_its_first_entry_only_and_never_a_hidden_one() {
use crate::net::Reputations;
use crate::target::ring::Factions;
use crate::ui_chat::ChatLog;
use benilla_formats::faction_flags::HIDDEN;
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_faction_catalog(&mut chain).expect("factions");
let slot =
|id: u32| u32::try_from(catalog.reputation_faction(id).unwrap().rep_index).unwrap();
let (booty, gadget, ratchet) = (slot(21), slot(369), slot(470));
let factions = Factions::from_catalog(catalog);
let packet = [(booty, 100), (gadget, 50), (ratchet, 50)];
let kinds = |reps: &Reputations, deltas: &[(u32, i32)]| {
let mut log = ChatLog::default();
super::faction_standing(deltas, reps, Some(&factions), &mut log);
log.pending_kinds()
};
let quiet = Reputations::default();
assert_eq!(
kinds(&quiet, &packet),
["combat"],
"one line for the packet"
);
// Flag 4 on the first entry's slot silences the packet; on a later entry it changes nothing.
let mut v = vec![(0u8, 0i32); 64];
v[booty as usize].0 = HIDDEN;
assert!(kinds(&Reputations(v), &packet).is_empty());
let mut v = vec![(0u8, 0i32); 64];
v[gadget as usize].0 = HIDDEN;
assert_eq!(kinds(&Reputations(v), &packet), ["combat"]);
// An unchanged first entry prints nothing, and the entries behind it still stay silent.
let mut v = vec![(0u8, 0i32); 64];
v[booty as usize].1 = 100;
assert!(kinds(&Reputations(v), &packet).is_empty());
}
}
@@ -259,8 +259,6 @@ pub(crate) fn float_combat_text(
path: font.as_ref().and_then(|f| f.0.as_deref()),
height: Some(target_px),
outline: Outline::None,
spacing: 0.0, // one line
shadow_x: 0.0,
alpha_gradient: None,
},
// A world overlay with its own seat; the UI grid does not apply.
+2 -30
View File
@@ -61,31 +61,12 @@ impl ConsoleCommandApp for App {
}
}
/// Lines a CVar's change callback prints to the console (`0x63cb50`), such as `spellEffectLevel`'s
/// "Spell effect level set to %d.". A console command's own write collects them into its output;
/// any other write (a Lua `SetCVar`, the boot load) sends them to the log, as the reference's go
/// to a console screen nobody has open.
#[derive(Resource, Default)]
pub(crate) struct ConsoleEcho {
capture: Option<Vec<String>>,
}
impl ConsoleEcho {
pub(crate) fn print(&mut self, line: String) {
match &mut self.capture {
Some(lines) => lines.push(line),
None => info!("console: {line}"),
}
}
}
/// The built-ins: the reference's four `ConsoleVar.cpp` commands and `help`.
pub(crate) struct ConsolePlugin;
impl Plugin for ConsolePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ConsoleCommands>()
.init_resource::<ConsoleEcho>()
.console_command("set", "Set the value of a CVar.", set)
.console_command("cvar_reset", "Reset a CVar to its default.", cvar_default)
.console_command("cvar_default", "Reset a CVar to its default.", cvar_default)
@@ -155,18 +136,10 @@ fn set_cvar(world: &mut World, name: &str, value: &str) -> Vec<String> {
let outcome = cvars.set(name, value);
(outcome, cvars.take_events())
};
// The callbacks' own lines come first, as they print inside `CVar::Set`.
if let Some(mut echo) = world.get_resource_mut::<ConsoleEcho>() {
echo.capture = Some(Vec::new());
}
for event in events {
world.trigger(event);
}
let mut lines = world
.get_resource_mut::<ConsoleEcho>()
.and_then(|mut echo| echo.capture.take())
.unwrap_or_default();
lines.extend(match outcome {
match outcome {
SetOutcome::Unknown => vec![format!("console: no CVar named '{name}'")],
SetOutcome::Refused => vec![format!(
"console: '{value}' is not a number, and {name} takes one"
@@ -175,8 +148,7 @@ fn set_cvar(world: &mut World, name: &str, value: &str) -> Vec<String> {
SetOutcome::Staged => vec![format!(
"{name} staged as \"{value}\" — applies at the next RestartGx"
)],
});
lines
}
}
/// `set <name> <value>` (`0x63d500`). Deviation: the reference registers an unknown name as a new
+6 -16
View File
@@ -16,9 +16,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::BuildId;
/// Chains the crash-report hook after the current one. Called once as the client launches
/// ([`crate::run`], [`crate::run_with`]), before the `App` exists, so a panic while plugins build
/// is covered.
/// Chains the crash-report hook after the current one. Called once from [`crate::run`], before
/// the `App` exists, so a panic while plugins build is covered.
pub(crate) fn install(build: BuildId) {
let started = Instant::now();
let previous = std::panic::take_hook();
@@ -140,14 +139,12 @@ mod tests {
short: "0123456",
date: "2026-09-16",
profile: "debug",
project_dir: "",
extended: false,
};
#[test]
fn a_report_carries_the_build_the_place_the_payload_and_the_log() {
let log = vec!["+ 1.000s INFO benilla_app::net: entered world".to_owned()];
let mut report = Report {
let text = render(&Report {
build: BUILD,
unix: 1_800_000_000,
uptime: Duration::from_millis(12_345),
@@ -156,10 +153,10 @@ mod tests {
payload: "index out of bounds: the len is 3 but the index is 7",
backtrace: " 0: benilla_app::net::apply::apply_net_updates",
log: &log,
};
let text = render(&report);
});
for needle in [
"build: 0.2.0+3 · 0123456 · 2026-09-16 · debug (sha 0123456789abcdef0123456789abcdef01234567)",
"sha 0123456789abcdef0123456789abcdef01234567",
"0.2.0+3 · 0123456 · 2026-09-16 · debug",
"12.3 s after launch",
"thread: main",
"at: crates/benilla-app/src/net/apply.rs:100:5",
@@ -170,13 +167,6 @@ mod tests {
] {
assert!(text.contains(needle), "missing {needle:?} in:\n{text}");
}
// A client a crate on top of benilla extended says so in the build line.
report.build.extended = true;
let extended = render(&report);
assert!(
extended.contains("build: 0.2.0+3 · 0123456 · 2026-09-16 · debug · extended (sha "),
"{extended}"
);
let dir = std::env::temp_dir().join(format!("benilla-crash-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
-21
View File
@@ -424,10 +424,6 @@ pub(crate) struct EmoteAnim {
pub(crate) anim_id: u16,
/// `PlaySeq` stamp; a kit anim inherits its `CastEvent`'s.
pub(crate) seq: u64,
/// Came through the shared one-shot emote player (`0x5fcd20`: `SMSG_EMOTE`, the gesture
/// dispatcher), which skips an id already armed on the key bone or bone 0 (`0x5fcd56`). A kit
/// anim and `DoEmote`'s local play call `0x5fe2f0` directly and take no such test.
pub(crate) via_player: bool,
}
/// A state kit's `AnimID`, compared and never played: both field-4 callers (`0x5ff4c6`,
@@ -469,7 +465,6 @@ fn flourish_to_anim(
entity: child.0,
anim_id: select::MOUNT_SPECIAL,
seq: play_seq.next(),
via_player: false,
});
}
}
@@ -516,7 +511,6 @@ mod emote_anim;
/// The client-local gestures (chat, NPC interact), the reference's `0x60bb30`.
mod gesture;
use emote_anim::emote_to_anim;
pub(crate) use emote_anim::{local_play_eligible, LocalPlay};
pub(crate) use gesture::{select_gesture, Gesture, GestureQueue};
/// The Bevy systems that execute the state machine `select` picks.
@@ -570,16 +564,6 @@ pub(crate) struct AnimDriver {
/// The movement flags the base was last armed for: the reference has no per-one-shot latch,
/// so a root landing with a cast one-shot (Ice Block) still lets the base overwrite bone 0.
gait_flags: u32,
/// Whether the unit was the interact NPC last frame: `SetInteractNPC` (`0x4930d0`) re-picks
/// its base on open (`0x493198`) and on clear (`0x493219`), so a flip cuts a one-shot.
interacting: bool,
/// Whether the interaction in `interacting` (or the one just cleared) targets a player, whose
/// open and clear skip the re-pick (`0x493159`, `0x493203`: the PLAYER typemask bit).
interact_player: bool,
/// The clear's re-pick (`0x493219`) runs while `[0xb4e2d0]` still names the NPC, so it picks
/// Stand; the global is zeroed after (`0x49334b`). The state returns at the next re-pick:
/// the Stand window's end, a movement-flag edge, or leaving Gait.
interact_hold: bool,
/// The reference's `[unit+0xd58] & 0xc0000`: while Knockdown, LiftOff or Land holds it, base
/// requests are refused, so a stun's knockdown survives the root's Stand recompute.
base_lock: driver::play::BaseAnimLock,
@@ -689,9 +673,6 @@ impl Default for AnimDriver {
mode: Mode::Gait,
gait: None,
gait_flags: 0,
interacting: false,
interact_player: false,
interact_hold: false,
base_lock: driver::play::BaseAnimLock::default(),
sheath_cur: None,
sheath_byte: None,
@@ -851,8 +832,6 @@ impl Plugin for CreatureAnimPlugin {
// Same frame as the loot kneel: the reference force-plays Loot 50 in the
// handler that arms the latch.
.after(crate::ui_loot::resolve_loot_kneel)
// `DoEmote`'s local play, written in the chat drain.
.after(crate::ui_chat::ChatDrain)
// After Input so a sheath request (the Z toggle) executes the same frame.
.after(WorldStage::Input)
// `VisualSheath` must land before `resolve_equipment` reads it, or a sheath
+10 -114
View File
@@ -56,8 +56,7 @@ const JUMP_ARC_MIN_UP: f32 = 0.5;
#[derive(Clone, Copy)]
enum OneShotReq {
Swing(u32),
/// The id, and whether it came through the emote player's armed-id test.
Emote(u16, bool),
Emote(u16),
}
/// The live one-shot the combat fast path tests: the key bone's clip, else bone 0's (`0x5fe422`).
@@ -81,31 +80,6 @@ fn live_oneshot(
None
}
/// The id armed on the unit, as `0x5fdb50` reads it: the key bone's while one runs, else bone 0's.
/// It is the REQUESTED id, not the fallback clip that plays: op4 stores its raw argument at
/// `+0xf8` (`0x71252f`) and the getter returns it (`0x7120d3`). A clip that has finished counts as
/// gone: its completion callback re-arms the base.
fn armed_id(
drv: &AnimDriver,
player: &AnimationPlayer,
tr: &AnimationTransitions,
anims: &ModelAnimations,
) -> Option<u16> {
let running = |n| player.animation(n).is_some_and(|a| !a.is_finished());
if let Some(ov) = drv.overlay.filter(|ov| running(ov.node)) {
return Some(ov.id);
}
let node = tr.get_main_animation().filter(|&n| running(n))?;
// The clip's own id only when the mode holds no requested id.
drv.active_anim().or_else(|| {
anims
.clips
.iter()
.find(|c| c.node == node)
.map(|c| c.anim_id)
})
}
/// Whether a one-shot is live on the unit; the missile queue polls it to launch a missile whose
/// release keyframe never fired when the cast ends (the reference's `0x5fc920` → `0x60c9b0`).
pub(crate) fn oneshot_is_live(
@@ -306,16 +280,13 @@ pub(super) fn drive_animations(
MessageReader<BaseAnimRecompute>,
// A rider's mount child, where a mount-set request goes (`0x5fe7c1`).
Query<(&ObjectStore, &crate::entities::mount::MountChild)>,
// The open NPC session's unit (`[0xb4e2d0]`): its state emote is passed over when the
// row's `EmoteFlags` carry `0x2000` (`0x5fd7e2`-`0x5fd7fd`).
Option<Res<crate::ui_session::InteractNpc>>,
),
// The variation roll's LCG, the reference's single CRT `_rand` stream shared by every play.
mut rng: ResMut<benilla_assets::AnimRng>,
// The last anim trace line per traced unit; the trace writes only on change.
mut anim_trace_last: Local<std::collections::HashMap<Entity, String>>,
) {
let (emote_sounds, loot_kneel, time, names, mut recomputes, riders, interact) = aux;
let (emote_sounds, loot_kneel, time, names, mut recomputes, riders) = aux;
let dt = time.delta_secs();
// This frame's one-shot plays per unit, replayed in the reference's call order (`PlaySeq`
// stamps): a later call overwrites an earlier, and the combat fast path keys on what is playing
@@ -364,37 +335,7 @@ pub(super) fn drive_animations(
pending
.entry(e.entity)
.or_default()
.push((OneShotReq::Emote(e.anim_id, e.via_player), e.seq));
}
// A mounted rider's mount-set emotes go to the mount model (`0x5fe7c1`); the rider keeps the
// request for its own CLASS_A and nowhere routes. The emote player's armed test (`0x5fcd56`)
// runs once, on the rider, ahead of that routing, so the forwarded copy carries none.
let mut to_mount: Vec<(Entity, (OneShotReq, u64))> = Vec::new();
for (rider, reqs) in pending.iter() {
let Ok((store, child)) = riders.get(*rider) else {
continue;
};
if store.0.unit_mount_display_id() == 0 {
continue;
}
for &(req, seq) in reqs {
let OneShotReq::Emote(id, via_player) = req else {
continue;
};
if !route_mounted(id).mount {
continue;
}
let armed = via_player
.then(|| units.get(*rider).ok())
.flatten()
.and_then(|u| armed_id(u.4, u.2, u.3, u.1));
if armed != Some(id) {
to_mount.push((child.0, (OneShotReq::Emote(id, false), seq)));
}
}
}
for (child, req) in to_mount {
pending.entry(child).or_default().push(req);
.push((OneShotReq::Emote(e.anim_id), e.seq));
}
// A mounted rider's mount-set emotes go to the mount model (`0x5fe7c1`); the rider keeps the
// request for its own CLASS_A and nowhere routes.
@@ -787,7 +728,7 @@ pub(super) fn drive_animations(
});
// In `PlaySeq` order, defense last: the reference plays it from the deferred impact scan,
// after every message handler.
let mut requests: Vec<(u16, Option<u16>)> = pending
let mut requests: Vec<u16> = pending
.remove(&entity)
.map(|mut v| {
v.sort_by_key(|&(_, seq)| seq);
@@ -803,21 +744,19 @@ pub(super) fn drive_animations(
swing_anim_main(w.armed_main())
};
debug!("swing: unit {entity} anim {id} (hitInfo {hit_info:#x})");
(id, None)
id
}
// The armed-id test compares the record's own AnimID, before the play
// seam's unarmed remap.
OneShotReq::Emote(id, via_player) => (id, via_player.then_some(id)),
OneShotReq::Emote(id) => id,
})
.collect()
})
.unwrap_or_default();
requests.extend(defense.map(|id| (id, None)));
requests.extend(defense);
// At the play seam (`0x5fe2f0`), Special1H/2H with both hands empty becomes
// SpecialUnarmed(118), for every request.
{
let w = wielded.copied().unwrap_or_default();
for (id, _) in &mut requests {
for id in &mut requests {
*id = select::unarmed_special(*id, w.armed_main(), w.armed_off());
}
}
@@ -829,22 +768,9 @@ pub(super) fn drive_animations(
&& !airborne_frozen
&& live_oneshot(&drv, &player, &tr, anims, catalog).is_none()
{
requests.extend(drv.deferred.take().map(|id| (id, None)));
requests.extend(drv.deferred.take());
}
for (id, armed_test) in requests {
// The emote player's own test first (`0x5fcd56`): an id already armed on the key bone
// or bone 0 plays nothing, ahead of the lock and the fast path in `0x5fe2f0`.
if let Some(raw) = armed_test {
if armed_id(&drv, &player, &tr, anims) == Some(raw) {
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!("anim armed-skip unit={entity} id={raw}"),
);
}
continue;
}
}
for id in requests {
// The lock first: `0x5fe2f0`'s head guard returns before routing, the fast path and
// the dedup, so a locked unit gets no arm and no deferral.
if drv.base_lock.refuses() {
@@ -1009,7 +935,6 @@ pub(super) fn drive_animations(
{
if drv.mode == Mode::Gait {
drv.gait = None;
drv.interact_hold = false; // the window's end is a re-pick (`0x5fc45a`)
} else {
let head = anims
.clips
@@ -1050,34 +975,6 @@ pub(super) fn drive_animations(
}
}
// `SetInteractNPC` re-picks the base at once on open and on clear (`0x5fd9e0(-1)`, sites
// `0x493198` and `0x493219`), over a live one-shot as any base arm; in Gait the per-frame
// pick already follows. A player's open and clear skip it (`0x493159`, `0x493203`), a pose
// under the one-shot re-picks to its own id and an airborne unit to the freeze
// (`0x5fd8e8`), which cut nothing.
let target = interact.as_ref().filter(|i| i.0 == Some(entity));
let interacting = target.is_some();
if let Some(guid) = target.and_then(|i| i.1) {
drv.interact_player = benilla_protocol::guid::is_player(guid);
}
if std::mem::replace(&mut drv.interacting, interacting) != interacting
&& !drv.interact_player
{
// Opening re-picks with the NPC named, so it ends a hold; clearing re-picks with it
// still named, so the state stays passed over until the next re-pick.
drv.interact_hold = !interacting;
if matches!(drv.mode, Mode::Swing { under: None, .. }) && !airborne_frozen {
drv.deferred = None;
drv.mode = Mode::Gait;
drv.gait = None;
}
}
if drv.interact_hold
&& (mv.flags != drv.gait_flags || !matches!(drv.mode, Mode::Gait | Mode::Swing { .. }))
{
drv.interact_hold = false;
}
// ── The mode machine: the base track's decision.
mode::run(
mode::Frame {
@@ -1097,7 +994,6 @@ pub(super) fn drive_animations(
wielded,
store,
emote_sounds: emote_sounds.as_deref(),
interacting: interacting || drv.interact_hold,
walk,
model_scale,
traced,
@@ -9,7 +9,10 @@ use bevy::prelude::*;
use crate::net::ObjectStore;
use crate::sound::EmoteSounds;
use super::super::select::{self, base_candidates, playback_rate, ready_anim, Mode, DEATH, STAND};
use super::super::select::{
self, gait_candidates, is_bare_stand, playback_rate, ready_anim, state_emote_gait, Mode, DEATH,
STAND,
};
use super::super::{find_resolved, move_flags, AnimDriver, CastHold, MovementState, Wielded};
use super::play::{enter_special, leave_special, oneshot_finished, play, play_clip, roll_loop};
use super::transplant_up;
@@ -33,8 +36,6 @@ pub(super) struct Frame<'a> {
pub(super) wielded: Option<&'a Wielded>,
pub(super) store: Option<&'a ObjectStore>,
pub(super) emote_sounds: Option<&'a EmoteSounds>,
/// Whether this unit is the current interact NPC.
pub(super) interacting: bool,
pub(super) walk: f32,
pub(super) model_scale: f32,
/// Whether this body's plays go to the anim trace; `subject` labels them (rider or mount).
@@ -67,7 +68,6 @@ pub(super) fn run(
wielded,
store,
emote_sounds,
interacting,
walk,
model_scale,
traced,
@@ -315,22 +315,7 @@ pub(super) fn run(
load
}
});
// The looping state-emote idle (`UNIT_NPC_EMOTESTATE`: `/dance`, NPC work loops, and
// `/stand`'s state 26) is the resolver `0x5fd770`: it claims the base ahead of the
// idle fallbacks (swim, prowl, Stand) and behind everything else in the chain.
let state_anim = store.and_then(|s| {
let state = s.0.unit_emote_state();
emote_sounds.and_then(|e| {
select::state_emote_anim(
state,
e.state_anim(state),
e.emote_flags(state),
interacting,
)
})
});
let base = base_candidates(&mv, walk, ready, ranged_load, state_anim);
let cands = base.as_slice();
let cands = gait_candidates(&mv, walk, ready, ranged_load);
// The stationary cast/channel pin (`[CGUnit+0xb4]`), full-body over the Ready and
// state-emote idles; stationary is `[9e8] & 0x20000f`, never the turn bits, so a
// turning caster keeps it and a moving one gets the masked hold instead.
@@ -342,6 +327,22 @@ pub(super) fn run(
}
_ => cands,
};
// The looping state-emote idle (`UNIT_NPC_EMOTESTATE`: `/dance`, NPC work loops)
// fills only the bare-Stand slot; whatever outranks Stand already routed `cands`.
let state_emote_cands;
let cands: &[u16] = if is_bare_stand(cands) {
let emote_anim = store
.and_then(|s| emote_sounds.and_then(|e| e.anim(s.0.unit_emote_state())));
match emote_anim {
Some(id) => {
state_emote_cands = state_emote_gait(id as u16);
&state_emote_cands
}
None => cands,
}
} else {
cands
};
// The loot kneel (Loot 50) of a stationary, unmounted unit with the trigger up,
// over the cast pin, the Ready and ranged idles, the chair loops and the state
// emote: `0x5fd8b0` tries locomotion, then loot (`0x5fd260`), then the cast/channel
@@ -662,7 +662,6 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() {
entity: spinner,
anim_id: 57,
seq: 1,
via_player: false,
});
app.world_mut().write_message(SwingMessage {
attacker: swinger,
@@ -748,14 +747,12 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
entity: spin_last,
anim_id: 57,
seq: 2,
via_player: false,
});
// swing_last: the spin arrives first.
app.world_mut().write_message(EmoteAnim {
entity: swing_last,
anim_id: 57,
seq: 3,
via_player: false,
});
app.world_mut().write_message(SwingMessage {
attacker: swing_last,
@@ -1453,7 +1450,6 @@ fn a_jump_in_place_cast_replaces_the_hang_and_survives_the_arc() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
fn drv(app: &App, unit: Entity) -> &AnimDriver {
@@ -1498,7 +1494,6 @@ fn landing_mid_cast_plays_the_land_pick() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
app.world_mut()
@@ -1530,7 +1525,6 @@ fn a_cast_over_the_fall_loop_holds_until_landing() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update(); // Swing { 54, under: Jump }
app.world_mut().entity_mut(unit).insert(MovementState {
@@ -1549,7 +1543,6 @@ fn a_cast_over_the_fall_loop_holds_until_landing() {
entity: unit,
anim_id: 54,
seq: 2,
via_player: false,
});
app.update();
assert!(
@@ -1604,7 +1597,6 @@ fn a_moving_jump_cast_masks_onto_the_overlay() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
let drv = app.world().entity(unit).get::<AnimDriver>().unwrap();
@@ -1630,7 +1622,6 @@ fn a_jump_over_a_live_cast_transplants_it_to_the_torso() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
{
@@ -1783,7 +1774,6 @@ fn a_finished_masked_cast_fades_out_instead_of_snapping() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
{
@@ -1913,7 +1903,6 @@ fn a_midair_deferred_park_survives_the_level_and_dies_at_the_landing_play() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
app.world_mut().write_message(SwingMessage {
attacker: unit,
@@ -2237,7 +2226,6 @@ fn a_root_landing_with_the_cast_returns_the_body_to_neutral() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
assert_eq!(
@@ -2264,7 +2252,6 @@ fn a_run_starting_under_a_cast_still_transplants_it_up() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
@@ -2566,7 +2553,6 @@ fn the_mount_transition_takes_bone_0_back_from_a_full_body_one_shot() {
entity: unit,
anim_id: SPELL_CAST_OMNI,
seq: 1,
via_player: false,
});
app.update();
assert_eq!(
@@ -2886,7 +2872,6 @@ fn a_mid_volley_fire_clip_re_pulls_and_the_pull_promotes_to_the_hold() {
entity: unit,
anim_id: 46,
seq: 1,
via_player: false,
});
app.update();
assert_eq!(
@@ -3037,7 +3022,6 @@ fn every_shot_of_a_volley_re_arms_the_fire_clip_through_the_emote_lane() {
entity: unit,
anim_id: 46,
seq: shot,
via_player: false,
});
app.update();
assert_eq!(mode(&app), FIRING, "shot {shot} takes bone 0");
@@ -3445,7 +3429,6 @@ fn a_special_goes_unarmed_only_when_both_hands_are_empty() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
unit
};
@@ -3715,7 +3698,6 @@ fn a_gait_change_raises_the_anim_edge_and_a_steady_frame_does_not() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
app.update();
assert!(edge(&app), "and so does a one-shot");
@@ -3747,7 +3729,6 @@ fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
app.update();
assert!(edge(&app), "the first combat play arms normally");
@@ -3825,7 +3806,6 @@ mod base_anim_lock {
entity: unit,
anim_id,
seq: 1,
via_player: false,
});
app.update();
}
@@ -4061,7 +4041,6 @@ mod routing {
entity,
anim_id,
seq: 1,
via_player: false,
});
app.update();
}
@@ -4158,515 +4137,4 @@ mod routing {
"no full-body fallback"
);
}
/// The armed id is the requested one (`0x712090` returns `+0xf8`, op4's raw argument), not
/// the clip a fallback plays: Stand runs on bone 0 while 68 is the requested id.
#[test]
fn the_armed_id_is_the_requested_one_not_the_fallback_clip() {
use super::super::super::select::Mode;
let mut app = app();
let unit = spawn(&mut app, model(vec![clip(0, 1, true)]), 0);
app.update();
let armed = |app: &App| {
let e = app.world().entity(unit);
super::super::armed_id(
e.get::<AnimDriver>().unwrap(),
e.get::<AnimationPlayer>().unwrap(),
e.get::<AnimationTransitions>().unwrap(),
e.get::<ModelAnimations>().unwrap(),
)
};
assert_eq!(armed(&app), Some(0), "Stand requested, Stand playing");
app.world_mut()
.entity_mut(unit)
.get_mut::<AnimDriver>()
.unwrap()
.mode = Mode::Swing {
id: CHEER,
under: None,
};
assert_eq!(armed(&app), Some(CHEER), "the requested id, not clip 0");
}
/// A mounted rider's mount-set emote is tested once, on the rider: an id the rider has armed
/// is not forwarded to the mount child.
#[test]
fn a_rider_armed_id_stops_the_forward_to_the_mount() {
use super::super::super::select::Mode;
let mut app = app();
let rider = spawn(&mut app, model(vec![clip(0, 1, true)]), 1);
let child = app
.world_mut()
.spawn((
model(vec![clip(0, 1, true), clip(SHUFFLE, 2, false)]),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
))
.id();
app.world_mut()
.entity_mut(rider)
.insert(crate::entities::mount::MountChild(child));
app.update();
let child_mode = |app: &App| app.world().entity(child).get::<AnimDriver>().unwrap().mode;
let send = |app: &mut App, via_player: bool| {
app.world_mut().write_message(EmoteAnim {
entity: rider,
anim_id: SHUFFLE,
seq: 1,
via_player,
});
app.update();
};
// A control: a kit anim (no armed test) is forwarded.
send(&mut app, false);
assert!(
matches!(child_mode(&app), Mode::Swing { id: SHUFFLE, .. }),
"the mount-set emote reaches the mount"
);
app.world_mut()
.entity_mut(child)
.get_mut::<AnimDriver>()
.unwrap()
.mode = Mode::Gait;
// The rider has it armed: the player's test refuses before the forward.
app.world_mut()
.entity_mut(rider)
.get_mut::<AnimDriver>()
.unwrap()
.mode = Mode::Swing {
id: SHUFFLE,
under: None,
};
send(&mut app, true);
assert_eq!(child_mode(&app), Mode::Gait, "not forwarded to the mount");
}
/// `DoEmote`'s local play (`0x5fe2f0`) arms the clip at once; the `SMSG_EMOTE` echo that follows
/// through the emote player finds the id armed (`0x5fcd56`) and leaves it running.
#[test]
fn the_echo_of_a_local_emote_does_not_restart_it() {
let mut app = app();
let unit = spawn(
&mut app,
model(vec![clip(0, 1, true), masked(CHEER, 2, 12)]),
0,
);
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FORWARD,
..Default::default()
});
app.update();
let send = |app: &mut App, via_player: bool| {
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: CHEER,
seq: 1,
via_player,
});
app.update();
};
let node = AnimationNodeIndex::new(12);
let speed = |app: &App| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(node)
.map(bevy::animation::ActiveAnimation::speed)
};
send(&mut app, false);
assert_eq!(speed(&app), Some(1.0), "the local play armed the key bone");
// A fresh arm resets the rate to 1.0, so a marker rate shows a restart.
app.world_mut()
.entity_mut(unit)
.get_mut::<AnimationPlayer>()
.unwrap()
.animation_mut(node)
.unwrap()
.set_speed(0.25);
send(&mut app, true);
assert_eq!(speed(&app), Some(0.25), "the echo did not re-arm the clip");
}
}
/// The `UNIT_NPC_EMOTESTATE` resolver (`0x5fd770`): the current interact NPC passes over a state
/// whose `Emotes.dbc` flags carry `0x2000`, and `SetInteractNPC` re-picks its base on open and
/// on clear.
mod emote_state {
use super::*;
use crate::creature_anim::Mode;
use crate::net::ObjectStore;
use crate::sound::EmoteSounds;
use crate::ui_session::InteractNpc;
use benilla_formats::EmoteSoundCatalog;
/// Emote states: 28 plays AnimID 234 and yields to interaction (`0x2000`); 29 plays 235 and
/// does not.
const YIELDING: u32 = 28;
const STUBBORN: u32 = 29;
/// `/stand`'s state 26 (vmangos `ChatHandler.cpp` `HandleTextEmoteOpcode`): AnimID 0.
const STAND_STATE: u32 = 26;
const FIELD_NPC_EMOTESTATE: u16 = 148;
/// Real graph nodes, so loop windows complete: every clip lasts 0.1 s, and the loops are one
/// pass per window.
fn model(
app: &mut App,
) -> (
ModelAnimations,
bevy::animation::graph::AnimationGraphHandle,
) {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
let ids: [(u16, bool); 6] = [
(0, true), // Stand
(234, true), // the yielding state's loop
(235, true), // the other state's loop
(118, false), // SpecialUnarmed, a one-shot
(41, true), // SwimIdle
(42, true), // Swim
];
let handles: Vec<_> = ids
.iter()
.map(|_| {
let mut c = AnimationClip::default();
c.set_duration(0.1);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
})
.collect();
let (graph, nodes) = AnimationGraph::from_clips(handles);
let graph = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
let clips = ids
.iter()
.zip(nodes)
.map(|(&(id, looping), node)| {
let mut c = clip(id, 0, looping);
c.node = node;
c.duration = 0.1;
c.blend_time = 0.0;
c.frequency = 0x4000;
c.replay = (1, 1);
c
})
.collect();
(
ModelAnimations {
graph: graph.clone(),
clips,
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
},
AnimationGraphHandle(graph),
)
}
fn app_with_rows() -> App {
let mut app = app();
app.init_resource::<InteractNpc>();
app.insert_resource(EmoteSounds(
EmoteSoundCatalog::default()
.with_row(YIELDING, 234, 0x2000)
.with_row(STUBBORN, 235, 0)
.with_row(STAND_STATE, 0, 0),
));
app
}
fn unit(app: &mut App, state: u32) -> Entity {
let (model, graph) = model(app);
app.world_mut()
.spawn((
model,
graph,
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_EMOTESTATE, state)])),
))
.id()
}
fn gait(app: &App, unit: Entity) -> Option<u16> {
app.world().entity(unit).get::<AnimDriver>().unwrap().gait
}
/// A creature's guid, which `SetInteractNPC` re-picks for; a player's does not.
const CREATURE: u64 = 0xF130_0000_0000_0042;
const PLAYER: u64 = 0x42;
fn interact_guid(app: &mut App, npc: Option<Entity>, guid: u64) {
*app.world_mut().resource_mut::<InteractNpc>() = InteractNpc(npc, npc.map(|_| guid));
app.update();
}
fn interact(app: &mut App, npc: Option<Entity>) {
interact_guid(app, npc, CREATURE);
}
fn mode(app: &App, unit: Entity) -> Mode {
app.world().entity(unit).get::<AnimDriver>().unwrap().mode
}
#[test]
fn a_yielding_state_stops_while_its_unit_is_the_interact_npc() {
let mut app = app_with_rows();
let npc = unit(&mut app, YIELDING);
app.update();
assert_eq!(gait(&app, npc), Some(234), "the work loop plays");
interact(&mut app, Some(npc));
assert_eq!(gait(&app, npc), Some(0), "the window is open: Stand");
// The clear's re-pick runs with the NPC still named (`0x493219` before the zeroing at
// `0x49334b`), so Stand holds until the next re-pick: the end of its window.
interact(&mut app, None);
for _ in 0..3 {
advance(&mut app, 10);
assert_eq!(
gait(&app, npc),
Some(0),
"cleared: Stand until its window ends"
);
}
for _ in 0..20 {
advance(&mut app, 25);
}
assert_eq!(gait(&app, npc), Some(234), "then the loop resumes");
}
/// A live one-shot is cut to Stand by the clear, not to the loop.
#[test]
fn the_clear_cuts_a_one_shot_to_stand() {
let mut app = app_with_rows();
let npc = unit(&mut app, YIELDING);
interact(&mut app, Some(npc));
app.world_mut().write_message(EmoteAnim {
entity: npc,
anim_id: 118,
seq: 1,
via_player: false,
});
app.update();
assert!(matches!(mode(&app, npc), Mode::Swing { id: 118, .. }));
interact(&mut app, None);
assert_eq!(mode(&app, npc), Mode::Gait);
assert_eq!(gait(&app, npc), Some(0));
}
/// `0x493159` and `0x493203` skip the re-pick for a player (a trade partner).
#[test]
fn a_player_target_is_not_re_picked() {
let mut app = app_with_rows();
let partner = unit(&mut app, YIELDING);
app.update();
app.world_mut().write_message(EmoteAnim {
entity: partner,
anim_id: 118,
seq: 1,
via_player: false,
});
app.update();
interact_guid(&mut app, Some(partner), PLAYER);
assert!(
matches!(mode(&app, partner), Mode::Swing { id: 118, .. }),
"open"
);
interact_guid(&mut app, None, PLAYER);
assert!(
matches!(mode(&app, partner), Mode::Swing { id: 118, .. }),
"clear"
);
}
/// The re-pick of a seated unit goes to its pose (`0x5fd550`), so the one-shot over it is
/// left to finish and the sit-down entry is not replayed.
#[test]
fn a_pose_under_the_one_shot_is_not_cut() {
use crate::creature_anim::select::Special;
let mut app = app_with_rows();
let npc = unit(&mut app, YIELDING);
app.world_mut().entity_mut(npc).insert(MovementState {
stand_state: 1,
..Default::default()
});
let swing = Mode::Swing {
id: 118,
under: Some(Special::Pose(1)),
};
app.world_mut()
.entity_mut(npc)
.get_mut::<AnimDriver>()
.unwrap()
.mode = swing;
interact(&mut app, Some(npc));
assert!(
!matches!(mode(&app, npc), Mode::Gait | Mode::Entering(_)),
"no forced Gait, so no replay of the entry: {:?}",
mode(&app, npc)
);
}
#[test]
fn a_state_without_the_bit_keeps_playing_while_interacted() {
let mut app = app_with_rows();
let npc = unit(&mut app, STUBBORN);
interact(&mut app, Some(npc));
assert_eq!(gait(&app, npc), Some(235));
}
#[test]
fn only_the_interact_npc_stops() {
let mut app = app_with_rows();
let npc = unit(&mut app, YIELDING);
let other = unit(&mut app, YIELDING);
interact(&mut app, Some(npc));
assert_eq!(gait(&app, npc), Some(0));
assert_eq!(gait(&app, other), Some(234), "a neighbour keeps working");
}
/// The re-pick overwrites bone 0 like any base arm, so it cuts a live one-shot.
#[test]
fn opening_the_window_re_picks_over_a_live_one_shot() {
let mut app = app_with_rows();
let npc = unit(&mut app, YIELDING);
app.update();
app.world_mut().write_message(EmoteAnim {
entity: npc,
anim_id: 118,
seq: 1,
via_player: false,
});
app.update();
assert!(matches!(
app.world().entity(npc).get::<AnimDriver>().unwrap().mode,
Mode::Swing { id: 118, .. }
));
interact(&mut app, Some(npc));
assert_eq!(gait(&app, npc), Some(0), "the one-shot is cut for Stand");
}
/// State 26 holds Stand over the swim idle until the unit moves (`0x5fd770` before `0x5fd830`).
#[test]
fn a_set_state_holds_stand_over_the_swim_idle_until_the_unit_moves() {
let mut app = app_with_rows();
let swimmer = unit(&mut app, STAND_STATE);
let bare = unit(&mut app, 0);
for u in [swimmer, bare] {
app.world_mut().entity_mut(u).insert(MovementState {
flags: move_flags::SWIMMING,
..Default::default()
});
}
app.update();
assert_eq!(gait(&app, swimmer), Some(0), "state 26 stands in the water");
assert_eq!(gait(&app, bare), Some(41), "no state: the swim idle");
app.world_mut().entity_mut(swimmer).insert(MovementState {
speed: 4.7,
flags: move_flags::SWIMMING | move_flags::FORWARD,
..Default::default()
});
app.update();
assert_eq!(gait(&app, swimmer), Some(42), "moving, it swims");
}
}
/// The emote player's armed-id test (`0x5fcd56`, `0x5fdb50`): an id already running on bone 0 is
/// not played again, whether it is the gait clip or an earlier one-shot; a different id is, and a
/// kit anim (`0x5fe2f0` directly) takes no such test.
#[test]
fn the_emote_player_skips_an_id_already_armed_but_plays_a_different_one() {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
let mut app = app();
let mut asset = |secs: f32| {
let mut c = AnimationClip::default();
c.set_duration(secs);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
};
let (stand, dance, wave) = (asset(10.0), asset(10.0), asset(10.0));
let (graph, nodes) = AnimationGraph::from_clips([stand, dance, wave]);
let graph_handle = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
let mut clips = vec![clip(0, 0, true), clip(69, 0, true), clip(67, 0, false)];
for (c, n) in clips.iter_mut().zip(nodes.iter().copied()) {
c.node = n;
c.duration = 10.0;
}
let unit = app
.world_mut()
.spawn((
ModelAnimations {
graph: graph_handle.clone(),
clips,
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
},
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimationGraphHandle(graph_handle),
AnimDriver::default(),
))
.id();
app.update(); // settle: Stand holds bone 0
let play = |app: &mut App, anim_id: u16, via_player: bool| {
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id,
seq: 1,
via_player,
});
advance(app, 500);
};
let swing = |app: &App| match app.world().entity(unit).get::<AnimDriver>().unwrap().mode {
super::super::select::Mode::Swing { id, .. } => Some(id),
_ => None,
};
let time = |app: &App, node| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(node)
.map(|a| a.seek_time())
};
// The gait's own id over itself: nothing arms.
play(&mut app, 0, true);
assert_eq!(swing(&app), None, "Stand over Stand is skipped");
// A kit anim has no such test: it arms the same id.
play(&mut app, 0, false);
assert_eq!(swing(&app), Some(0), "a kit anim is not skipped");
// A different id arms and runs.
play(&mut app, 69, true);
assert_eq!(swing(&app), Some(69), "a different id plays");
let t = time(&app, nodes[1]).expect("armed");
assert!(t > 0.4, "the dance has run since its arm: {t}");
// The same id over itself does not restart: its clip time keeps advancing.
play(&mut app, 69, true);
let t2 = time(&app, nodes[1]).expect("armed");
assert!(t2 > t, "the dance kept advancing, {t} then {t2}");
// A different id replaces it.
play(&mut app, 67, true);
assert_eq!(swing(&app), Some(67));
}
@@ -13,7 +13,7 @@ use bevy::prelude::*;
use crate::net::{EmoteKind, EmoteMessage, ObjectStore, RemoteMotion};
use crate::sound::EmoteSounds;
use super::{move_flags, select, EmoteAnim, Engaged, MovementState};
use super::{move_flags, EmoteAnim, Engaged, MovementState};
/// The performer's gate inputs: stand state, move flags (`MovementState` for us, `RemoteMotion`
/// for a remote player; a creature's spline carries no swim bit) and the [`Engaged`] marker.
@@ -45,7 +45,7 @@ pub(super) fn emote_to_anim(
};
let (store, movement, remote, engaged) =
units.get(entity).unwrap_or((None, None, None, false));
if !play_eligible(store, movement, remote, engaged, anim_id) {
if !play_eligible(store, movement, remote, engaged) {
debug!("emote_anim: suppressed anim {anim_id} for {entity:?}");
continue;
}
@@ -53,7 +53,6 @@ pub(super) fn emote_to_anim(
entity,
anim_id: anim_id as u16,
seq: play_seq.next(),
via_player: true,
});
}
}
@@ -66,13 +65,10 @@ pub(super) fn receive_eligible(stand_state: u8, swimming: bool) -> bool {
}
/// The shared player's half, `0x5fcd20`: no play while channeling (`0x5fcd83`) or in combat
/// (`0x5fcd9d`), and LiftOff (192) and Land (200) skip the combat test alone (`0x5fcd5f`/`0x5fcd67`
/// set the flag that `0x5fcd90` branches past `0x60ecd0` on; the channel test at `0x5fcd83` runs
/// first). Its already-armed test (`0x5fcd5d`) is the driver's `armed_id`, and its
/// `[+0xd58] & 0x400` test (`0x5fcd8e`, the weapon-visual hold) is [`super::RangedHold`].
fn player_eligible(anim_id: u32, channeling: bool, in_combat: bool) -> bool {
let exempt = anim_id == u32::from(select::LIFT_OFF) || anim_id == u32::from(select::LAND);
!channeling && (exempt || !in_combat)
/// (`0x5fcd9d`). Its already-armed test (`0x5fcd5d`) is the driver's same-id dedup, and its
/// `[+0xd58] & 0x400` test (`0x5fcd8e`) reads an anim-state bit with no counterpart here.
fn player_eligible(channeling: bool, in_combat: bool) -> bool {
!channeling && !in_combat
}
/// The whole gate for one unit, as both producers call it; `engaged` stands for the client's
@@ -82,7 +78,6 @@ pub(super) fn play_eligible(
movement: Option<&MovementState>,
remote: Option<&RemoteMotion>,
engaged: bool,
anim_id: u32,
) -> bool {
let fields = store.map(|s| &s.0);
let stand_state = fields.map_or(0, |f| f.unit_stand_state());
@@ -94,53 +89,7 @@ pub(super) fn play_eligible(
!= 0;
let channeling = fields.is_some_and(|f| f.unit_channel_spell() != 0);
let in_combat = engaged || fields.is_some_and(|f| f.unit_flags() & UNIT_FLAGS_COMBAT_BIT != 0);
receive_eligible(stand_state, swimming) && player_eligible(anim_id, channeling, in_combat)
}
/// Our own unit's inputs to `DoEmote`'s local-play gate `0x5fd680`.
#[derive(Clone, Copy, Default)]
pub(crate) struct LocalPlay {
/// The shared dead test `0x605f90`: health 0, `UNIT_DYNFLAG_DEAD` or stand state 7.
pub(crate) reads_dead: bool,
pub(crate) stand_state: u8,
pub(crate) move_flags: u32,
pub(crate) vertical_speed: f32,
/// On a flying server spline (a taxi), which exempts the turn-shuffle test (`0x5fce47`).
pub(crate) flying: bool,
/// The loot-kneel latch with its kneeling target (`0x6126b0` and `0x612710`).
pub(crate) loot_kneel: bool,
pub(crate) channeling: bool,
/// The auto-attack target `[+0xc48]` alone (`0x60ecb0`), not the combat flag bit.
pub(crate) engaged: bool,
}
/// Move flags that void the turn-shuffle test: HOVER, SWIMMING and `0x800` (`0x5fce5d`).
const SHUFFLE_VOID: u32 = move_flags::HOVER | move_flags::SWIMMING | 0x800;
/// `0x5fd680`, the gate on `DoEmote`'s local play (`0x5ef660`, then `0x5fe2f0` at `0x5ef66f`): the emote plays at once only while
/// a direction key is held, and every other case waits for the server's `SMSG_EMOTE`. It refuses
/// the dead (`0x605f90`), a unit both jumping and falling far (`0x5fd6a9`), a jump with vertical
/// speed (`0x5fd6c5`), a kneeling looter (`0x5fd6e7`), a channel (`0x5fd701`), an auto-attack
/// target (`0x5fd721`), a turn shuffle (`0x5fd72c`, approximated) and any stand state but 0 (`0x5fd74d`, and
/// the cached `[+0xc1c]` at `0x5fd757`, which tracks it). The `[+0xd58]` test at `0x5fd711` refuses
/// only when both 0x400 ([`super::RangedHold`]) and 0x10000 (unmodelled) are set, and so is not
/// tested here; the turn-shuffle test approximates `0x5fce30`, which also exempts on the armed id,
/// exempts on `d58 & 0xc` and fires on the mouse-turn latch `d58 & 0x1800`. The ranged auto-repeat
/// test (`0x5fd735`)
/// needs sheath state 2, which the stow `DoEmote` runs first (`0x611cf0(0)`) has cleared.
pub(crate) fn local_play_eligible(i: &LocalPlay) -> bool {
let f = i.move_flags;
let turning = f & (move_flags::TURN_LEFT | move_flags::TURN_RIGHT) != 0;
let shuffling = turning && !i.flying && f & SHUFFLE_VOID == 0;
!i.reads_dead
&& !(f & move_flags::FALLING != 0 && f & move_flags::FALLING_FAR != 0)
&& (f & move_flags::FALLING == 0 || i.vertical_speed == 0.0)
&& f & move_flags::ANY_MOVE != 0
&& !i.loot_kneel
&& !i.channeling
&& !i.engaged
&& !shuffling
&& i.stand_state == 0
receive_eligible(stand_state, swimming) && player_eligible(channeling, in_combat)
}
/// The flag half of the in-combat test `0x60ecd0` (`UNIT_FIELD_FLAGS` bit 11); the other half is
@@ -183,24 +132,11 @@ mod tests {
#[test]
fn channeling_or_combat_suppresses_the_play() {
assert!(
player_eligible(66, false, false),
player_eligible(false, false),
"idle and out of combat plays"
);
assert!(!player_eligible(66, true, false), "channeling suppresses");
assert!(!player_eligible(66, false, true), "in combat suppresses");
}
/// `0x5fcd5f`/`0x5fcd67`: LiftOff (192) and Land (200) skip the combat test, not the channel.
#[test]
fn lift_off_and_land_play_in_combat_but_not_while_channeling() {
assert!(player_eligible(192, false, true), "LiftOff in combat");
assert!(player_eligible(200, false, true), "Land in combat");
assert!(!player_eligible(66, false, true), "any other id refuses");
assert!(!player_eligible(193, false, true), "the neighbours refuse");
assert!(
!player_eligible(192, true, true),
"channeling still refuses"
);
assert!(!player_eligible(true, false), "channeling suppresses");
assert!(!player_eligible(false, true), "in combat suppresses");
}
/// The attack-target half of `0x60ecd0` (`[+0xc48]`, from ATTACKSTART to ATTACKSTOP) refuses
@@ -208,114 +144,10 @@ mod tests {
#[test]
fn an_engaged_unit_refuses_the_play_without_the_flag_bit() {
assert!(
play_eligible(None, None, None, false, 66),
play_eligible(None, None, None, false),
"idle, no store: plays"
);
assert!(
!play_eligible(None, None, None, true, 66),
"engaged: refused"
);
assert!(
play_eligible(None, None, None, true, 192),
"engaged LiftOff"
);
assert!(play_eligible(None, None, None, true, 200), "engaged Land");
}
fn moving() -> LocalPlay {
LocalPlay {
move_flags: move_flags::FORWARD,
..Default::default()
}
}
#[test]
fn a_local_emote_plays_only_while_a_direction_key_is_held() {
assert!(local_play_eligible(&moving()));
for dir in [
move_flags::BACKWARD,
move_flags::STRAFE_LEFT,
move_flags::STRAFE_RIGHT,
] {
let i = LocalPlay {
move_flags: dir,
..moving()
};
assert!(local_play_eligible(&i), "{dir:#x}");
}
assert!(
!local_play_eligible(&LocalPlay::default()),
"standing still"
);
let turn_only = LocalPlay {
move_flags: move_flags::TURN_LEFT,
..Default::default()
};
assert!(!local_play_eligible(&turn_only), "turning in place");
}
#[test]
fn each_local_play_gate_refuses_on_its_own() {
let base = moving();
let refused = [
LocalPlay {
reads_dead: true,
..base
},
LocalPlay {
stand_state: 1,
..base
},
LocalPlay {
loot_kneel: true,
..base
},
LocalPlay {
channeling: true,
..base
},
LocalPlay {
engaged: true,
..base
},
// Both jumping and falling far.
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::FALLING | move_flags::FALLING_FAR,
..base
},
// A jump still rising.
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::FALLING,
vertical_speed: 3.0,
..base
},
// Turning while moving is a turn shuffle.
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT,
..base
},
];
for (n, i) in refused.iter().enumerate() {
assert!(!local_play_eligible(i), "case {n}");
}
// A jump whose vertical speed reads exactly 0.0 (a step-off fall's first tick), and a turn while swimming or on a flying spline, pass.
for i in [
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::FALLING,
..base
},
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT | move_flags::SWIMMING,
..base
},
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT,
flying: true,
..base
},
] {
assert!(local_play_eligible(&i));
}
assert!(!play_eligible(None, None, None, true), "engaged: refused");
}
#[test]
@@ -114,7 +114,7 @@ pub(super) fn drive_gestures(
};
let (store, movement, remote, engaged) =
units.get(entity).unwrap_or((None, None, None, false));
if !super::emote_anim::play_eligible(store, movement, remote, engaged, anim_id) {
if !super::emote_anim::play_eligible(store, movement, remote, engaged) {
debug!("gesture: {gesture:?} suppressed for {entity:?}");
continue;
}
@@ -122,7 +122,6 @@ pub(super) fn drive_gestures(
entity,
anim_id: anim_id as u16,
seq: play_seq.next(),
via_player: true,
});
}
}
@@ -333,7 +333,6 @@ fn text_emote(
chat_log: &mut crate::ui_chat::ChatLog,
) {
out.write(EmoteMessage {
guid,
source: index.0.get(&guid).copied(),
kind: EmoteKind::Text(text_emote),
});
@@ -343,7 +342,6 @@ fn text_emote(
/// `SMSG_EMOTE`: an `Emotes.dbc` id to play on a unit (NPC scripts, a `/`-emote's animation).
fn emote(guid: u64, emote_id: u32, index: &GuidIndex, out: &mut MessageWriter<EmoteMessage>) {
out.write(EmoteMessage {
guid,
source: index.0.get(&guid).copied(),
kind: EmoteKind::Anim(emote_id),
});
+41 -114
View File
@@ -111,13 +111,10 @@ pub(crate) mod move_flags {
/// Any horizontal direction bit: the client's `[9e8] & 0xf` gate.
pub const ANY_MOVE: u32 = FORWARD | BACKWARD | STRAFE_LEFT | STRAFE_RIGHT;
/// The low byte: forward, back, strafe, turn and pitch, the loot response's test (`0x5ebc40`).
pub const STEERING: u32 = 0xff;
/// The bits that get a mover integrated at all, the client's `0x20ff` (`0x616e20`, `0x6166f5`):
/// without one a mover keeps its last packet's pose, and a flag-less unit is not even in the
/// mover list (`0x618940`). Swim and the mode bits are not among them.
pub const INTEGRATED: u32 = STEERING | FALLING;
pub const INTEGRATED: u32 = 0xff | FALLING;
/// The committed-lower-body test routing a one-shot to the masked overlay (`0x5fe6dc`); the
/// client's separate mouse-turn test (`d58 & 0x1800`) reads a field benilla does not model.
@@ -284,82 +281,17 @@ pub(super) enum Mode {
/// The ids a unit in [`Mode::Gait`] tries, most specific first: the `0x5fd8b0` chain without its
/// Special states. `ready` is the engaged idle, which locomotion outranks.
///
/// The pick without a state emote, which [`base_candidates`] adds.
pub(super) fn gait_candidates(
state: &MovementState,
walk_speed: f32,
ready: Option<u16>,
ranged_load: Option<u16>,
) -> &'static [u16] {
claimed_candidates(state, walk_speed, ready, ranged_load)
.unwrap_or_else(|| idle_fallback(state))
}
/// What the base picks: a fixed candidate list, or the unit's emote state's loop, Stand behind it.
pub(super) enum BaseCands {
Fixed(&'static [u16]),
State([u16; 2]),
}
impl BaseCands {
pub(super) fn as_slice(&self) -> &[u16] {
match self {
Self::Fixed(c) => c,
Self::State(c) => c,
}
}
}
/// The full pick of the `0x5fd8b0` chain in its byte order: the resolvers [`claimed_candidates`]
/// covers, then the state-emote resolver (`0x5fd770`, `0x5fd9a1`), then the idle fallback
/// (`0x5fd830`). `state_anim` is the resolver's verdict ([`state_emote_anim`]); a set state claims
/// the base over the swim idle, the prowl idle and Stand, so `/stand`'s state 26 holds Stand
/// against both until the unit moves.
pub(super) fn base_candidates(
state: &MovementState,
walk_speed: f32,
ready: Option<u16>,
ranged_load: Option<u16>,
state_anim: Option<u16>,
) -> BaseCands {
if let Some(c) = claimed_candidates(state, walk_speed, ready, ranged_load) {
return BaseCands::Fixed(c);
}
match state_anim {
Some(anim) => BaseCands::State([anim, STAND]),
None => BaseCands::Fixed(idle_fallback(state)),
}
}
/// The idle fallback (`0x5fd830`), the chain's last resolver: the swim idle on `SWIMMING`
/// (`0x5fd83c`, `[9e8] & 0x200000`), else the prowl idle, else Stand. The hover idle (193,
/// `0x5fd88d`) is not modelled.
fn idle_fallback(state: &MovementState) -> &'static [u16] {
if state.flags & move_flags::SWIMMING != 0 {
&[41, 0]
} else if state.stealthed {
&[STEALTH_STAND, STAND]
} else {
&[STAND]
}
}
/// The resolvers ahead of the state-emote resolver, `None` where none claims the base. The
/// locomotion resolver (`0x5fd100`) runs on `[9e8] & 0xf` alone, so a stationary swimmer is not
/// its: it falls through the standing resolvers to the swim idle in [`idle_fallback`].
fn claimed_candidates(
state: &MovementState,
walk_speed: f32,
ready: Option<u16>,
ranged_load: Option<u16>,
) -> Option<&'static [u16]> {
use move_flags::*;
let f = state.flags;
let swimming = f & SWIMMING != 0;
// Swimming locomotion (`0x5fd100`): turn, then strafe, then backward, then forward.
if swimming && f & ANY_MOVE != 0 {
return Some(if f & (TURN_LEFT | TURN_RIGHT) != 0 {
// Swimming (`0x5fd100`): turn, then strafe, then backward, then forward.
if f & SWIMMING != 0 {
return if f & (TURN_LEFT | TURN_RIGHT) != 0 {
&[41, 0]
} else if f & STRAFE_LEFT != 0 {
&[43, 42, 41, 0]
@@ -367,58 +299,57 @@ fn claimed_candidates(
&[44, 42, 41, 0]
} else if f & BACKWARD != 0 {
&[45, 41, 0]
} else {
} else if f & FORWARD != 0 {
&[42, 41, 0]
});
} else {
&[41, 0]
};
}
// A flying spline, the taxi (`0x5fd19c`): Fly 135 ahead of backward and speed.
if state.flying {
return Some(&[135, 0]);
return &[135, 0];
}
// Backward dominates strafe (`0x5fd1bc`, `[9e8] & 2` → WalkBackwards 13).
if f & BACKWARD != 0 {
return Some(&[13, 4, 0]);
return &[13, 4, 0];
}
// Stealthed and moving, the prowl (`0x5fd1d3`); Walk is row 119's AnimationData fallback.
if state.stealthed && f & ANY_MOVE != 0 {
return Some(&[STEALTH_WALK, 4, 0]);
return &[STEALTH_WALK, 4, 0];
}
// Ground gaits by live speed (`0x5fd202`/`0x5fd224`): Sprint at 11 and above, Run above twice
// walk, else Walk. Strafe takes these too: the reference has no ground strafe branch.
if f & ANY_MOVE != 0 {
let s = state.speed;
return Some(if s >= FAST_RUN_SPEED {
return if s >= FAST_RUN_SPEED {
&[143, 5, 4, 0]
} else if s > 2.0 * walk_speed {
&[5, 4, 0]
} else {
&[4, 0]
});
};
}
// Turning in place: the shuffle (`0x5fd3f0`, 11 left, 12 right); the fidget gate (`0x5fce30`)
// refuses a swimmer.
if !swimming && f & TURN_LEFT != 0 {
return Some(&[SHUFFLE_LEFT, STAND]);
// Turning in place: the shuffle (`0x5fd3f0`, 11 left, 12 right).
if f & TURN_LEFT != 0 {
return &[SHUFFLE_LEFT, STAND];
}
if !swimming && f & TURN_RIGHT != 0 {
return Some(&[SHUFFLE_RIGHT, STAND]);
if f & TURN_RIGHT != 0 {
return &[SHUFFLE_RIGHT, STAND];
}
// Standing and engaged: the Ready idle (`0x5fd360`), gated on engagement, not the sheath; a
// swimmer's is the swim idle (`0x5fd3ac`).
// Standing and engaged: the Ready idle (`0x5fd360`), gated on engagement, not the sheath.
if let Some(r) = ready {
return Some(match r {
_ if swimming => &[41, 0],
return match r {
26 => &[26, 25, 0],
27 => &[27, 25, 0],
28 => &[28, 25, 0],
_ => &[25, 0],
});
};
}
// Auto-repeat in the ranged stance (`0x5fd460`), ahead of the chair loops (`0x5fd550`).
if let Some(l) = ranged_load {
// A finished Load becomes its Hold through the completion dispatch (`0x5fc3f0`, fired at
// `0x7075af`); each Hold falls back to its Load, so a model without it holds full draw.
return Some(match l {
return match l {
105 => &[105, 25, 0],
106 => &[106, 25, 0],
109 => &[109, 105, 25, 0],
@@ -426,15 +357,16 @@ fn claimed_candidates(
111 => &[111, 25, 0],
112 => &[112, 25, 0],
_ => &[25, 0],
});
};
}
// Standing: a chair loop (4/5/6); state 2 (`SIT_CHAIR`) writes no override in the reference
// (its row declines, `0x5fd644`), so it falls through.
// Standing: a chair loop (4/5/6), else the prowl idle, else Stand; state 2 (`SIT_CHAIR`)
// writes no override in the reference (its row declines, `0x5fd644`), so it stands.
match state.stand_state {
4 => Some(&[102, 0]),
5 => Some(&[103, 0]),
6 => Some(&[104, 0]),
_ => None,
4 => &[102, 0],
5 => &[103, 0],
6 => &[104, 0],
_ if state.stealthed => &[STEALTH_STAND, STAND],
_ => &[STAND],
}
}
@@ -521,21 +453,16 @@ pub(super) fn is_cast_anim(id: u16) -> bool {
matches!(id, 2 | 32 | 33 | 53 | 54)
}
/// The state-emote resolver's verdict (`0x5fd770`): the row's `AnimID` for the unit's
/// `UNIT_NPC_EMOTESTATE`, `None` when it passes over. A state with no row passes over, a row
/// whose `AnimID` is 0 claims the base for Stand (`0x5fd816` stores `row+8` as it is), and the
/// current interact NPC passes over a row whose `EmoteFlags` carry `EMOTE_FLAG_INTERACTION`
/// (`0x5fd7e2`-`0x5fd7fd`), so a vendor stops hammering while his window is open.
pub(super) fn state_emote_anim(
state: u32,
anim: Option<u32>,
flags: Option<u32>,
interacting: bool,
) -> Option<u16> {
if state == 0 || (interacting && flags? & benilla_formats::EMOTE_FLAG_INTERACTION != 0) {
return None;
}
anim.map(|a| a as u16)
/// Whether `cands` is bare Stand, the one slot the state-emote idle (`UNIT_NPC_EMOTESTATE`) may
/// fill. The prowl idle counts as bare: the reference's emote-state resolver (`0x5fd770`) runs
/// before the fallback idle (`0x5fd830`) that picks it.
pub(super) fn is_bare_stand(cands: &[u16]) -> bool {
cands == [STAND] || cands == [STEALTH_STAND, STAND]
}
/// The state-emote idle's candidates, Stand as the fallback; only for a bare-Stand frame.
pub(super) fn state_emote_gait(emote_anim: u16) -> [u16; 2] {
[emote_anim, STAND]
}
/// The mainhand swing id (`0x6246a0`); anything but an equipped melee weapon swings unarmed (16).
@@ -807,7 +734,7 @@ pub(super) fn scaled_rate(clip: &AnimClip, speed: f32, model_scale: f32) -> Opti
/// remote's relayed flags ([`RemoteMotion`]), a creature's spline, else still.
/// `creature_swimming` supplies the `SWIMMING` the wire never sends for a creature; `modes`, the
/// `SMSG_SPLINE_MOVE_*` grants, join the remote and creature flags (one word in the reference).
pub(crate) fn unify(
pub(super) fn unify(
movement: Option<&MovementState>,
remote: Option<&RemoteMotion>,
spline: Option<&Spline>,
@@ -472,11 +472,13 @@ fn ranged_load_idle_selects_by_weapon_and_ranks_below_ready() {
gait_candidates(&moving_forward(3.0), 2.5, None, Some(105))[0],
4
);
// It outranks the state-emote resolver (`0x5fd460` runs before `0x5fd770`).
assert_eq!(
base_candidates(&standing, 2.5, None, Some(105), Some(234)).as_slice(),
&[105, 25, 0]
);
// It takes the bare-Stand slot, so it also blocks the state-emote idle.
assert!(!is_bare_stand(gait_candidates(
&standing,
2.5,
None,
Some(105)
)));
}
/// `0x5fd460` claims the drawn ranged idle ([`ranged_idle_gate`]) on the ranged sheath
@@ -508,85 +510,46 @@ fn each_ranged_load_promotes_to_its_weapon_familys_hold() {
assert_eq!(ranged_hold_anim(46), 46); // a fire id is no Load and maps onto no hold
}
/// The state-emote resolver (`0x5fd770`) runs right after the chair loops and before the idle
/// fallback (`0x5fd830`, `0x5fd9a1`): a set state outranks the swim idle, the prowl idle and
/// Stand, and everything ahead of it outranks the state.
#[test]
fn a_set_state_outranks_the_idle_fallbacks_and_nothing_before_them() {
let claimed = |state: &MovementState, ready, ranged, anim| {
base_candidates(state, 2.5, ready, ranged, anim)
.as_slice()
.to_vec()
};
let still = MovementState::default();
assert_eq!(claimed(&still, None, None, Some(234)), [234, STAND]);
assert_eq!(claimed(&still, None, None, None), [STAND]);
fn state_emote_idle_only_fills_the_bare_stand_slot() {
assert!(is_bare_stand(gait_candidates(
&MovementState::default(),
2.5,
None,
None
)));
assert_eq!(state_emote_gait(200), [200, STAND]);
// Behind it: the swim idle (`/stand`, state 26, plays AnimID 0) and the prowl idle.
let swimming = MovementState {
flags: move_flags::SWIMMING,
..Default::default()
};
assert_eq!(claimed(&swimming, None, None, None), [41, 0]);
assert_eq!(claimed(&swimming, None, None, Some(0)), [0, STAND]);
assert_eq!(claimed(&swimming, None, None, Some(234)), [234, STAND]);
let prowling = MovementState {
stealthed: true,
..Default::default()
};
assert_eq!(claimed(&prowling, None, None, None), [STEALTH_STAND, STAND]);
assert_eq!(claimed(&prowling, None, None, Some(234)), [234, STAND]);
// Ahead of it: locomotion (`0x5fd100`), swimming included, runs on the move bits alone.
let swim_forward = MovementState {
flags: move_flags::SWIMMING | move_flags::FORWARD,
..Default::default()
};
assert_eq!(claimed(&swim_forward, None, None, Some(234)), [42, 41, 0]);
assert_eq!(claimed(&moving_forward(3.0), None, None, Some(234)), [4, 0]);
// The turn shuffle, the Ready idle (a swimmer's is the swim idle), the ranged idle and the
// chair loops all come first.
assert!(!is_bare_stand(gait_candidates(
&moving_forward(3.0),
2.5,
None,
None
)));
let turning = MovementState {
flags: move_flags::TURN_LEFT,
..Default::default()
};
assert_eq!(claimed(&turning, None, None, Some(234)), [11, 0]);
assert_eq!(claimed(&still, Some(26), None, Some(234)), [26, 25, 0]);
assert_eq!(claimed(&swimming, Some(26), None, Some(234)), [41, 0]);
assert_eq!(claimed(&still, None, Some(105), Some(234)), [105, 25, 0]);
// `0x5fd460` has no swim gate: a swimmer takes it too.
assert_eq!(claimed(&swimming, None, Some(105), Some(234)), [105, 25, 0]);
let chair = MovementState {
stand_state: 4,
assert!(!is_bare_stand(gait_candidates(&turning, 2.5, None, None)));
let swimming = MovementState {
flags: move_flags::SWIMMING,
..Default::default()
};
assert_eq!(claimed(&chair, None, None, Some(234)), [102, 0]);
// A turning swimmer is no shuffle (`0x5fce30` refuses `SWIMMING`) and no mover: the state.
let swim_turning = MovementState {
flags: move_flags::SWIMMING | move_flags::TURN_LEFT,
..Default::default()
};
assert_eq!(claimed(&swim_turning, None, None, Some(234)), [234, STAND]);
assert_eq!(claimed(&swim_turning, None, None, None), [41, 0]);
}
/// The resolver's verdict: a row's `AnimID` is taken as it is, 0 included (`0x5fd816`); a missing
/// row or no state passes over; the interact NPC passes over a row with `0x2000`.
#[test]
fn the_state_resolver_claims_a_row_even_when_its_anim_is_stand() {
assert_eq!(state_emote_anim(26, Some(0), Some(0), false), Some(0));
assert_eq!(
state_emote_anim(28, Some(234), Some(0x2000), false),
Some(234)
);
assert_eq!(state_emote_anim(28, Some(234), Some(0x2000), true), None);
assert_eq!(state_emote_anim(29, Some(235), Some(0), true), Some(235));
assert_eq!(state_emote_anim(30, None, None, false), None, "no row");
assert_eq!(
state_emote_anim(0, Some(0), Some(0), false),
None,
"no state"
);
assert!(!is_bare_stand(gait_candidates(&swimming, 2.5, None, None)));
assert!(!is_bare_stand(gait_candidates(
&MovementState::default(),
2.5,
Some(26),
None
)));
// The chair stand states 4, 5 and 6 have their own slot, above bare Stand.
for stand_state in [4, 5, 6] {
let chair = MovementState {
stand_state,
..Default::default()
};
assert!(!is_bare_stand(gait_candidates(&chair, 2.5, None, None)));
}
}
// ── The per-play one-shot route ──
@@ -1067,10 +1030,9 @@ fn the_prowl_idle_is_the_lowest_priority_stand() {
gait_candidates(&idle, 2.5, None, None),
&[STEALTH_STAND, STAND],
);
assert_eq!(
base_candidates(&idle, 2.5, None, None, Some(234)).as_slice(),
&[234, STAND],
"a set state outranks it",
assert!(
is_bare_stand(gait_candidates(&idle, 2.5, None, None)),
"the state-emote idle still owns this slot",
);
let chair = MovementState {
stand_state: 4,
@@ -443,7 +443,6 @@ fn play_kit(
entity,
anim_id,
seq,
via_player: false,
});
}
}
-250
View File
@@ -1,250 +0,0 @@
//! A unit's creature type as the reference resolves it (`0x605570`), which the spell bind's
//! creature-type gate and the tracking dots read: the shapeshift form's type when above 0
//! (`SpellShapeshiftForm.dbc` column 12, `0x60559a`), else the cached creature template's
//! (`[unit+0xb30] + 0x18`, `0x6055a8`), else the race's (`ChrRaces.dbc` column 9, `0x6055ca`).
//! 0 is a unit with no type, which a creature-type-limited spell refuses.
use std::collections::HashMap;
use benilla_formats::ShapeshiftForm;
use crate::names::NameCache;
use crate::net::ObjectStore;
use crate::ui_action::Spells;
/// What the resolver reads beside the unit's own fields: the creature template cache and the
/// form table. A source that is absent resolves nothing at its stage.
#[derive(Clone, Copy, Default)]
pub(crate) struct CreatureTypeSources<'a> {
pub(crate) names: Option<&'a NameCache>,
pub(crate) forms: Option<&'a HashMap<u32, ShapeshiftForm>>,
}
impl<'a> CreatureTypeSources<'a> {
/// The sources a system holds as resources: the name cache and, once `Spell.dbc` has loaded,
/// the form table it carries.
pub(crate) fn of_resources(names: &'a NameCache, spells: Option<&'a Spells>) -> Self {
Self {
names: Some(names),
forms: spells.map(|s| &s.forms),
}
}
/// The unit's creature type, 0 for none. The template stage answers whatever the record
/// holds, 0 included, and is keyed by `OBJECT_FIELD_ENTRY` as the reference's cache is.
pub(crate) fn of(&self, store: &ObjectStore) -> u32 {
let fields = &store.0;
let form = self
.forms
.and_then(|forms| forms.get(&u32::from(fields.unit_shapeshift_form())))
.map(|row| row.creature_type)
.filter(|&ty| ty > 0);
if let Some(ty) = form {
return ty as u32;
}
let record = self
.names
.zip(fields.object_entry())
.and_then(|(names, entry)| names.creature_type(entry));
if let Some(ty) = record {
return ty;
}
// The race byte of `UNIT_FIELD_BYTES_0`: a player's, or 0 on a creature whose template
// has not come back, which has no row.
race_creature_type((fields.unit_bytes_0().unwrap_or(0) & 0xff) as u8)
}
}
/// `ChrRaces.dbc` column 9 by race id: Humanoid on all nine shipped rows, and 0 where the table
/// has no row (`0x6055b3`-`0x6055d1`).
fn race_creature_type(race: u8) -> u32 {
if (1..=9).contains(&race) {
CREATURE_TYPE_HUMANOID
} else {
0
}
}
/// `CreatureType.dbc` id 7.
const CREATURE_TYPE_HUMANOID: u32 = 7;
#[cfg(test)]
mod tests {
use super::*;
use crate::names::CreatureRecord;
use benilla_protocol::ObjectFields;
/// `UNIT_FIELD_BYTES_1` and `UNIT_FIELD_BYTES_0`, absolute descriptor indices.
const BYTES_1: u16 = 138;
const BYTES_0: u16 = 36;
const OBJECT_FIELD_ENTRY: u16 = 3;
fn store(entry: u32, race: u32, form: u32) -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[
(OBJECT_FIELD_ENTRY, entry),
(BYTES_0, race),
(BYTES_1, form << 16),
]))
}
fn names_with(entry: u32, creature_type: u32) -> NameCache {
let mut names = NameCache::default();
names.insert_creature(
entry,
Some(CreatureRecord {
name: "Test".into(),
subname: None,
creature_type,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
display_id: 0,
}),
);
names
}
fn forms() -> HashMap<u32, ShapeshiftForm> {
let row = |creature_type| ShapeshiftForm {
creature_type,
..Default::default()
};
// Cat form is a Beast; a warrior stance carries type 0, and Tree of Life -1.
HashMap::from([(1, row(1)), (2, row(-1)), (17, row(0))])
}
/// The three stages in the reference's order: form above 0, then the template, then the
/// race, and 0 for a unit none of them names.
#[test]
fn stages_resolve_in_the_references_order() {
let names = names_with(69, 1);
let forms = forms();
let sources = CreatureTypeSources {
names: Some(&names),
forms: Some(&forms),
};
// A cat-form player is a Beast; unshifted, a Humanoid by race.
assert_eq!(sources.of(&store(0, 1, 1)), 1);
assert_eq!(sources.of(&store(0, 1, 0)), 7);
// A row of type 0 or -1 falls through to the race, not to a fixed answer.
assert_eq!(sources.of(&store(0, 5, 17)), 7);
assert_eq!(sources.of(&store(0, 5, 2)), 7);
// A cached creature is its template's type; the form still wins over it.
assert_eq!(sources.of(&store(69, 0, 0)), 1);
let humanoid = names_with(69, 7);
let sources = CreatureTypeSources {
names: Some(&humanoid),
forms: Some(&forms),
};
assert_eq!(sources.of(&store(69, 0, 0)), 7);
assert_eq!(
sources.of(&store(69, 0, 1)),
1,
"a form outranks the template"
);
// The template answers whatever it holds: type 0 does not fall through to the race.
let untyped = names_with(69, 0);
let sources = CreatureTypeSources {
names: Some(&untyped),
forms: None,
};
assert_eq!(sources.of(&store(69, 1, 0)), 0);
}
/// A creature whose template has not answered has race 0, which has no row: type 0. A form
/// id with no row, or no form table at all, resolves nothing at that stage.
#[test]
fn an_uncached_creature_and_a_missing_row_read_type_zero() {
let names = NameCache::default();
let forms = forms();
let sources = CreatureTypeSources {
names: Some(&names),
forms: Some(&forms),
};
assert_eq!(sources.of(&store(69, 0, 0)), 0, "no template, race 0");
assert_eq!(sources.of(&store(69, 0, 99)), 0, "no form row either");
assert_eq!(sources.of(&store(0, 10, 0)), 0, "race 10 has no row");
assert_eq!(CreatureTypeSources::default().of(&store(0, 1, 1)), 7);
assert_eq!(CreatureTypeSources::default().of(&store(69, 0, 1)), 0);
}
/// The shipped `SpellShapeshiftForm.dbc` through the resolver: the forms of type 1 name a
/// Beast, and every other row falls through to the race.
#[test]
fn the_shipped_forms_name_a_beast_or_fall_through_to_the_race() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let forms =
benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc");
let sources = CreatureTypeSources {
names: None,
forms: Some(&forms),
};
// Cat, Travel, Aquatic, Bear, Dire Bear, Creature - Bear, Creature - Cat, Ghost Wolf.
for id in [1, 3, 4, 5, 8, 14, 15, 16] {
assert_eq!(forms[&id].creature_type, 1, "form {id} is type 1");
assert_eq!(sources.of(&store(0, 1, id)), 1, "form {id}");
}
// Tree of Life and Battle Stance carry -1: the human race's type answers.
for id in [2, 17] {
assert!(forms[&id].creature_type <= 0, "form {id} carries no type");
assert_eq!(sources.of(&store(0, 1, id)), 7, "form {id}");
}
// Every row by the rule: above 0 names the type, else the race.
for (&id, row) in &forms {
let expected = u32::try_from(row.creature_type).ok().filter(|&t| t > 0);
assert_eq!(
sources.of(&store(0, 1, id)),
expected.unwrap_or(7),
"form {id}"
);
}
}
/// The unit snapshot carries the resolver's word for every unit, players included: a
/// shapeshifted player a Beast, an unshifted one a Humanoid by race, a form of no type the
/// same, and a creature its template's.
#[test]
fn the_snapshot_carries_the_resolved_type_word() {
let names = names_with(69, 1);
let forms = forms();
let sources = CreatureTypeSources {
names: Some(&names),
forms: Some(&forms),
};
let word = |store: &ObjectStore| {
crate::ui_unit::snapshot(store, 0, None, 0, None, sources).creature_type_name
};
assert_eq!(word(&store(0, 1, 1)).as_deref(), Some("Beast"), "cat form");
assert_eq!(
word(&store(0, 1, 0)).as_deref(),
Some("Humanoid"),
"no form"
);
assert_eq!(
word(&store(0, 1, 2)).as_deref(),
Some("Humanoid"),
"type -1"
);
assert_eq!(word(&store(69, 0, 0)).as_deref(), Some("Beast"), "template");
assert_eq!(word(&store(70, 0, 0)), None, "uncached: race 0, no type");
}
/// The frozen race table against the shipped `ChrRaces.dbc`.
#[test]
fn race_creature_type_matches_the_shipped_table() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let rows = benilla_formats::load_race_creature_types(&mut chain).expect("ChrRaces walk");
assert_eq!(rows.len(), 9, "ChrRaces.dbc row count");
for race in 0..=u8::MAX {
assert_eq!(
race_creature_type(race),
rows.get(&race).copied().unwrap_or(0),
"race {race}"
);
}
}
}
+1 -1
View File
@@ -483,7 +483,7 @@ mod macos {
}
// A cinematic hides the pointer too (`0x58b590`); `CursorOptions.visible` is inert here, so
// it joins the look's hide/unhide pair, keeping AppKit's hide counter balanced.
let looking = rig.holds_cursor() || cinematic.is_some_and(|c| c.is_playing());
let looking = rig.is_looking() || cinematic.is_some_and(|c| c.is_playing());
let stem = mode.0.stem();
// The key names the chosen cursor, so `NSCursor::set`, a WindowServer round-trip that can
// stall the main thread for milliseconds, fires only on a change or a drift.
File diff suppressed because it is too large Load Diff

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