diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..0f5db367 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +# `cargo play` — the run loop. Same optimization as `--release`, plus incremental compilation (see +# `[profile.play]` in the workspace Cargo.toml for why it is a separate profile and not a flip of +# `release`). Env vars still prefix it: `WOW_USER=one WOW_PASS=pone cargo play`. +[alias] +play = "run --profile play -p benilla" diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..e27a53a9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,25 @@ +name: Bug report +description: Something benilla gets wrong, or does differently from 1.12.1. +body: + - type: textarea + id: what + attributes: + label: What happened + description: Screenshots or a clip help a lot; drag them in here. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What 1.12.1 does instead + - type: textarea + id: steps + attributes: + label: How to see it + placeholder: Where you were and what you did. + - type: input + id: build + attributes: + label: Build and setup + description: The `benilla build` line from the start of the terminal output, your OS, and the server you play on. + placeholder: "benilla build 64cca13 · 2026-09-24 · release, macOS 15, vmangos" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..cdea83c4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Discord + url: https://discord.gg/wJSJx467G4 + about: Questions, ideas and chat. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..3a23b396 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ + + +**What this changes** + +**What 1.12.1 does, and how you know** (the client's behaviour, a DBC field, a FrameXML line, a capture) + +**How you checked it** (`scripts/check.sh` or `scripts/gates.sh`, and before and after) diff --git a/.github/workflows/close-prs.yml b/.github/workflows/close-prs.yml deleted file mode 100644 index f2b9dfc8..00000000 --- a/.github/workflows/close-prs.yml +++ /dev/null @@ -1,21 +0,0 @@ -# GitHub has no switch to disable pull requests, so this closes them on arrival. -# Uses pull_request_target (base-repo context, no PR code checked out) so it can -# comment and close on PRs from forks. -name: close-prs -on: - pull_request_target: - types: [opened, reopened] -permissions: - pull-requests: write -jobs: - close: - runs-on: ubuntu-latest - steps: - - run: | - gh pr comment "$PR" --repo "$REPO" --body \ - "Thanks for the interest! benilla is a solo project and isn't taking pull requests for now — please bring bug reports and ideas to the Discord instead: https://discord.gg/wJSJx467G4" - gh pr close "$PR" --repo "$REPO" - env: - GH_TOKEN: ${{ github.token }} - PR: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} diff --git a/.gitignore b/.gitignore index 706cf583..11050328 100644 --- a/.gitignore +++ b/.gitignore @@ -4,18 +4,40 @@ # Cargo.lock IS committed (this workspace produces binaries). # --- Blizzard-owned content: NEVER commit --- -# A legally-installed 1.12.1 client, read at runtime; not redistributed. +# The user's legally-installed 1.12.1 client. Read at runtime; not redistributed. /WoW -# Cached / extracted assets derived from an install. +# Cached / extracted assets derived from the user's install. /extracted/ /out/ # Real client files used as test fixtures (document expected paths, do not commit). **/tests/fixtures/ -# Profiling output (`--features bevy/trace_chrome`). +# --- benilla's own local state --- +# The one folder benilla persists into: config.toml, macros/, bindings/, saved-variables.lua, +# camera/. A dev build resolves it to the main checkout (so linked worktrees share one); a +# release build puts it beside the binary — which is why it is not called `benilla/`: that name +# collides with the binary of the same name. Machine-local by definition — `crate::local_state` +# is the only thing in the tree that may write it. +/benilla-config + +# The account a scripted run from this checkout logs in as (scripts/probe-identity.sh and the +# client's account guard read it): WOW_USER=, WOW_PASS=, WOW_CHAR=, one per line. A credential. +/.probe-identity + +# Profiling output: `--features bevy/trace_chrome` drops a multi-GB flame chart in the cwd, which +# otherwise leaves the checkout "dirty". trace-*.json # OS / editor cruft .DS_Store *.swp /.idea/ +# Claude Code: settings and skills under .claude/ are each person's own. +/.claude/ + +# Python bytecode from the `scripts/` tooling. +__pycache__/ + +# The vanilla addon corpus the UI engine's real-addon tests read (third-party content): a symlink +# beside `WoW`, or `BENILLA_ADDON_CORPUS`. +/wow-addons-vanilla diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a0b67e0d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# benilla + +A from-scratch World of Warcraft 1.12.1 client in Rust and Bevy. The reference client is the +spec: benilla is a faithful, modern implementation of it, not a place to get creative. + +Read, in this order: + +1. `docs/METHOD.md` — the rules and how we work. Binding for every session. +2. `docs/MAP.md` — what is built right now, generated from the code. Orient here before + touching anything. +3. `docs/CONTRIBUTING.md` — what gets in and how a change is judged. + +The rest of the root is code and data: `crates/` the workspace, `reference/` the 1.12 name +catalogues the tests check against, `scripts/` the gates and instruments, `third_party/` +vendored code. + +@docs/METHOD.md diff --git a/Cargo.lock b/Cargo.lock index f775f249..ab0e83e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,7 +513,6 @@ dependencies = [ "benilla-formats", "benilla-protocol", "benilla-ui", - "benilla-wmo", "benilla-world", "bevy", "bevy_egui", @@ -674,7 +673,6 @@ dependencies = [ "bevy", "bytemuck", "image", - "libc", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", diff --git a/Cargo.toml b/Cargo.toml index 0552b3cc..9ff6207c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,33 +8,18 @@ edition = "2021" license = "MIT OR Apache-2.0" authors = ["sam "] -# Shared pins live here; promote a dep the moment two crates pin the same version (the policy -# since day one, enacted at the 0530 debt sweep). Per-crate feature additions stay in the crate. -# `image` deliberately stays per-crate: its default-features/feature sets differ per consumer, -# and a workspace default-features choice can't be subtracted from downstream. -# **Lint policy** (decision 2266 §C4). `too_many_arguments` is a readability lint for plain -# functions; on a Bevy system the parameter list IS the scheduler's dependency declaration, and -# a system that reads eight resources has eight parameters by construction. The lint had fired -# 364 times in this workspace and been allowed 364 times, 281 of them on systems — a lint whose -# every verdict is overridden is not a gate, it is a tax on adding a system. Allowed once here; -# the per-site attributes are gone. `type_complexity` stays on: it fires on shapes a type alias -# or a `SystemParam` bundle genuinely fixes. +# A version two crates share is pinned in `[workspace.dependencies]`; feature additions stay per +# crate. `image` stays per-crate: its consumers differ on default features, and a member cannot +# turn off default features that a workspace entry enables. +# `too_many_arguments` is off: a Bevy system's parameter list is its dependency declaration. [workspace.lints.clippy] too_many_arguments = "allow" [workspace.dependencies] anyhow = "1.0.102" -# bevy's `default` is `["2d", "3d", "ui"]`, and every one of those three pulls `audio` -# (= `bevy_audio` + `vorbis`) — so a default bevy compiles rodio, a second cpal, a second ALSA -# binding and an ogg/vorbis decoder into a client that mixes through kira and has had bevy's -# `AudioPlugin` disabled at boot since 0530. This spells the same `default` out with `audio` -# taken away: `2d`/`3d`/`ui` decomposed into the parts they are made of, which is the whole -# union of the three minus that one entry (decision 1932 has the expansion, verified against a -# before/after feature tree). Nothing else in the graph asks for it — avian3d, bevy_egui and -# bevy_transform_interpolation all take bevy with `default-features = false`. -# -# If a future bevy needs another member, add it here by name; do not reach back for `2d`/`3d`/ -# `ui`, which would quietly bring the audio stack with it. +# bevy's default features spelled out without `audio`: `2d`, `3d` and `ui` each pull `bevy_audio` +# and `vorbis`, and the client mixes through kira. Add a feature by name, never `2d`/`3d`/`ui`; a +# plugin that takes bevy with its default features brings the audio stack back too. bevy = { version = "0.18.1", default-features = false, features = [ "default_app", "default_platform", @@ -45,88 +30,44 @@ bevy = { version = "0.18.1", default-features = false, features = [ "scene", "picking", ] } -# The render app's raw GPU handles (the WOW_GPU_MS meter's query set): bevy 0.18 does not wrap -# wgpu's QuerySet family, so the meter reaches for the same wgpu the workspace already builds. +# For the `WOW_GPU_MS` meter's query set, which bevy 0.18 does not wrap; must match bevy's wgpu. wgpu = "27" clap = { version = "4.6.1", features = ["derive"] } sha1 = "0.10.6" -# The WoW format + protocol stack is fully in-repo (decision 0021), so there are no warcraft-rs / -# wow_messages crates (or forks of them) anywhere in the tree. That is still true and is why this -# section stood empty for the project's whole life. -# -# The one entry below is not a fork of a WoW crate at all. `lua-src` compiles the Lua the client's -# addon VM runs on, and benilla's Lua has to accept EXACTLY the grammar the 1.12.1 client's Lua -# accepts — no more and no less. Six hunks, all byte-read out of the client's own parser and VM: -# three restore what 5.1 removed (the iterator-less generic-for `for k, v in someTable do`, which -# 183 of 218 corpus addons are reached by; nesting long strings; the constructor's compat -# semicolon), three delete what 5.1 added (`...` as an expression, `#`, `%`). Both directions are -# below the reach of any layer above the VM — an opcode and a grammar are not a library function, -# a global, or a metamethod — and the corpus ASKS about the second direction directly: 170 -# `loadstring` sites compile a vararg expression purely to find out which interpreter they are on. -# -# The fork landed BYTE-IDENTICAL to upstream's 5.1.5 sources on purpose: the pipeline was proven -# transparent before any behavioural payload went into it, so when the suite moved, the patch was -# the only thing that could have moved it. `third_party/lua-src/BENILLA.md` has the current state, -# the per-hunk byte evidence and the verifying command (decisions 1215, 1315, 2101). +# lua-src builds the addon VM's Lua 5.1.5, patched to accept exactly the 1.12 client's grammar; +# the hunks and the command that checks them: `third_party/lua-src/BENILLA.md`. [patch.crates-io] lua-src = { path = "third_party/lua-src" } -# kira 0.12.1 with one patch: spatial gains and the volume tween evaluated per CHUNK and -# interpolated, not per frame (decision 1945; `third_party/kira/BENILLA.md`). +# kira 0.12.1, patched to compute spatial gains and the volume tween per output chunk and +# interpolate them, not per sample frame (`third_party/kira/BENILLA.md`). kira = { path = "third_party/kira" } -# Optimize *dependencies* even in dev builds. avian/parry collision math (and wgpu/bevy render) is -# unusably slow at opt-level 0 — a plain `cargo run` crawls to <1 fps once physics is doing real work. -# opt-level 3 on deps (the standard Bevy fix) makes debug builds playable while our own crates stay -# lightly optimized for fast incremental rebuilds. One-time cost: deps recompile optimized the first time. +# Dependencies at opt-level 3 in dev: at opt-level 0, avian/parry collision drops a run to <1 fps. [profile.dev.package."*"] opt-level = 3 -# Our own crates at opt-level 1, not 0: Bevy's generic hot paths (material specialization scans, -# query par_for_each closures) monomorphize INTO our crate, so at opt 0 they run at debug codegen -# speed over every resident entity — measured at Stormwind as the difference between ~45 fps and a -# pinned 60 (decision 0362). Warm incremental rebuild cost measured at ~zero. NB: FPS probe numbers -# in decisions ≤ 0361 were taken at opt 0 and are not comparable with post-0362 probes. +# Our crates at opt-level 1: Bevy's generic hot paths monomorphize into them, and at opt-level 0 +# Stormwind drops from a pinned 60 fps to ~45; the warm incremental rebuild cost is ~0. [profile.dev] opt-level = 1 -# No debuginfo trimming: `debug = "line-tables-only"` was A/B-measured (decision 0192) at zero -# recurring win on this stack (macOS unpacked split-debuginfo; sccache; fast ld) — not worth -# invalidating every warm target and the sccache cache for. +# No debuginfo trimming: `debug = "line-tables-only"` measures no recurring win on macOS + sccache. -# Shipping-grade optimization, A/B-graded at the pin (decision 0736): −1.0 cpu_ms (−5 %) vs -# default release, flat at the empty-map and bare-Bevy floors. The campaign keeps grading on -# default `release` — same-day A/B is the law, and fat LTO's link time would tax every -# iteration for a constant offset. Build with `--profile ship` for actual releases (add -# `RUSTFLAGS=-Ctarget-cpu=native` for a machine-local binary; not set here — it isn't portable). +# The shipping build (`--profile ship`): fat LTO measures -1.0 cpu_ms (-5 %) against `release`, +# which stays the measured profile because fat LTO's link time would tax every iteration. +# `-Ctarget-cpu=native` is not set: it is not portable. [profile.ship] inherits = "release" lto = "fat" codegen-units = 1 -# The director's play loop (decision 1157): `release` plus incremental compilation. `benilla-app` is one crate of -# ~243k lines, and release is non-incremental by cargo's default, so a one-line edit re-optimizes -# all of it — measured 2m47s (43s frontend, ~121s LLVM at opt-level 3) against a 1.8s stamp rebuild -# and a 2.5s no-op. Incremental caches both halves per codegen unit, so an edit pays for what it -# touched. Cargo applies `incremental` to workspace members ONLY (verified: `target/debug/incremental` -# holds our 16 crates and zero registry deps), so the ~760 dependencies stay non-incremental and -# sccache-cacheable — 0192's wrapper and the cold-slot build are untouched. -# -# Why a separate profile rather than flipping `release`: incremental raises the codegen-unit count -# and drops cross-unit inlining, which is a real codegen difference. The FPS campaign grades on -# default `release` (0736) and same-day A/B is the law, so the graded profile must not quietly -# change under it. Run it as `cargo play` (the alias in `.cargo/config.toml`). +# `release` plus incremental, run as `cargo play`: under `release` a one-line edit to +# `benilla-app` costs 2m47s. Incremental applies to workspace members only, so dependencies stay +# sccache-cacheable; it changes codegen, so it lives here and not in the measured `release`. [profile.play] inherits = "release" incremental = true -# ThinLTO OFF, and it is not a performance choice -- it is the fix for rustc #86049. `release` -# implies thin-LOCAL LTO (ThinLTO across a crate's own codegen units), which promotes each CGU's -# internal anonymous constants to external symbols named `anon..N.llvm.`. Under -# `incremental` some CGUs come back from the cache carrying names minted in an earlier compile, -# so a promoted reference finds no definition and `cc` fails with "symbol(s) not found for -# architecture arm64" -- an intermittent link failure with no source-level cause, cleared only by -# a full rebuild. Measured on the 2026-08-29 occurrence: 7 poisoned CGUs in `benilla-formats`, 48 -# unresolved symbols. `lto = "off"` disables the promotion step entirely (verified: the play rlib -# goes from 381 undefined `anon.*.llvm.*` symbols to 0). `release` and `ship` are UNTOUCHED -- they -# are non-incremental, cannot hit this, and stay the graded profiles (0736). Decision 1698. +# Not for speed: under `incremental`, thin-local LTO leaves promoted `anon.*.llvm.*` symbols +# unresolved and the link fails intermittently (rustc #86049). lto = "off" diff --git a/LIGHTING.md b/LIGHTING.md index f2039a1e..2122d3df 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -2,7 +2,7 @@ A dynamic light and shadow system for [benilla](https://github.com/samwhosung/benilla), the Rust + Bevy reimplementation of the 1.12.1 client. This branch is upstream benilla (merged at -`edec5b22`) plus the lighting work, and nothing else. It needs no data changes: everything is +`fd386e75`) plus the lighting work, the enhanced water module and volumetric fog, and nothing else. It needs no data changes: everything is derived from what a 1.12 install already carries (WMO `MOLT` lights, `MOCV` vertex colour, portals, M2 particle emitters, `Light.dbc`). diff --git a/README.md b/README.md index aa6f4c9d..9d73c9d0 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,6 @@

-> [!IMPORTANT] -> **Issues and pull requests are closed here.** benilla is a solo project developed in a private -> tree; this repo is its export, published as squashed snapshots, so a PR here has nothing to land -> on. The best way to contribute is to join the [Discord](https://discord.gg/wJSJx467G4) and report -> the bugs you find. Questions and ideas are welcome in the same place. - benilla speaks the original 1.12.1 protocol, so it connects to any server the real client could, and reads its game data at runtime from your own 1.12.1 install. Every file format and the network protocol are implemented from scratch, with no original client code, no third-party WoW crates, @@ -34,12 +28,12 @@ and no bundled game assets. modes from slow fall to roots, a follow camera with collision, boats, zeppelins and taxi flights. - **Networking:** SRP6 auth through world-session crypto, the object mirror into the ECS, and live wire coverage from movement and chat through spells, party, quests, mail, trade, vendors, bank, - loot, the auction house and PvP honor. -- **UI:** a from-scratch FrameXML + Lua engine driving the built-in interface, from the login and - character screens through the full HUD, the classic windows (guild, macros and key bindings - included), chat, nameplates, floating combat text and tooltips; third-party addons load from - a `benilla-config/AddOns/` folder beside the executable (partial: AtlasLoot and Bagnon run, - see below). + loot, the auction house, PvP honor and battlegrounds. +- **UI:** a from-scratch FrameXML + Lua engine that runs the client's own stock interface off + your install's patch chain, from the login and character screens through the full HUD, the + classic windows (guild, macros and key bindings included), chat, nameplates, floating combat + text and tooltips; third-party addons load from a `benilla-config/AddOns/` folder beside the + executable (partial: AtlasLoot and Bagnon run). - **Combat:** melee on the faithful swing law, ranged and Auto Shot, casting with GCD and cooldowns, combo points, crowd control that really holds you, and the spell visual pipeline. - **Audio:** music, ambience and SFX under the client's own selection and crossfade rules, with @@ -51,10 +45,8 @@ benilla is done when a 1.12.1 player can do everything here that they could in t client, it looks and feels the same, and it runs from a download on Windows, Linux and macOS. No dates; the order is what is likely, not a promise. -- Battlegrounds, then the long tail of small features that separates a working client from a - finished one. +- The long tail of small features that separates a working client from a finished one. - Addons, options and performance, ongoing. -- The no-brainer fixes from VanillaFixes, SuperWoW and the like. - Playable downloads for Windows, Linux and macOS. Linux first. Not planned: other expansions or client versions, Warden (anticheat). @@ -62,8 +54,10 @@ Not planned: other expansions or client versions, Warden (anticheat). ## Running it You need a **1.12.1 (build 5875) client install** for game data, a vanilla server to connect to, -and stable Rust. Any 1.12.1 core works; [vmangos](https://github.com/vmangos/core) is what -development runs against, and cMaNGOS and the rest speak the same protocol. +stable Rust and a C compiler (the Lua is built from source; on macOS the Xcode command line +tools, on Linux the ALSA and udev development packages). Any 1.12.1 core works; +[vmangos](https://github.com/vmangos/core) is what development runs against, and cMaNGOS and +the rest speak the same protocol. ```sh WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla @@ -74,6 +68,12 @@ at any IP or hostname, appending the auth port if yours is remapped (`WOW_HOST=play.example.com:5000`). Credentials go in at the login screen, or set `WOW_USER` / `WOW_PASS` to skip it. +## Contributing + +Issues and pull requests are open. [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) says what gets +in and how a change is judged. Bugs, questions and ideas are welcome on the +[Discord](https://discord.gg/wJSJx467G4) too. + --- Early inspiration and file format guidance came from the @@ -82,9 +82,11 @@ Early inspiration and file format guidance came from the benilla is an independent fan project, not affiliated with or endorsed by Blizzard Entertainment. It ships **no Blizzard content** — no art, models, sounds, maps, MPQ contents or FrameXML; you -provide your own legally obtained 1.12.1 client. The interface code under -`crates/benilla-app/assets/ui/` is ours, written to the client's own layout and API names so that -the windows look right and 1.12.1 addons find the names they expect. +provide your own legally obtained 1.12.1 client. The stock interface runs off your own install's +FrameXML at runtime; the handful of files under `crates/benilla-app/assets/ui/` are our own +adapters and developer frames, not copies of it. World of Warcraft is a trademark of Blizzard Entertainment, Inc. Our own code is licensed under -[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. +[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. The two vendored components +under `third_party/` — the kira audio engine, and a Lua 5.1 patched to the 1.12 client's dialect — +keep their own upstream licenses, alongside each. diff --git a/crates/benilla-adt/Cargo.toml b/crates/benilla-adt/Cargo.toml index 10ad7516..5af32449 100644 --- a/crates/benilla-adt/Cargo.toml +++ b/crates/benilla-adt/Cargo.toml @@ -3,7 +3,7 @@ name = "benilla-adt" version.workspace = true edition.workspace = true license.workspace = true -description = "ADT terrain-tile reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021" +description = "ADT terrain-tile reader for WoW 1.12.1 (build 5875)" [lints] workspace = true diff --git a/crates/benilla-adt/src/combined_alpha.rs b/crates/benilla-adt/src/combined_alpha.rs index 05316818..016bb37f 100644 --- a/crates/benilla-adt/src/combined_alpha.rs +++ b/crates/benilla-adt/src/combined_alpha.rs @@ -1,13 +1,11 @@ -//! MCAL → one 64×64 RGBA alpha map (R/G/B = the opacity of texture layers 1/2/3; A = 255). Ported -//! from wow-adt's `CombinedAlphaMap` so the output is byte-identical: layers are ingested in MCLY -//! order (skipping the opaque base), each decoded into the next channel; encodings are 4-bit packed -//! (×16), 8-bit raw, or Blizzard RLE (token bit 7 = fill, else copy; low 7 bits = count). `fix_alpha` -//! reconstructs a 64×64 plane from 63×63 source by duplicating the previous pixel at the last row/col. +//! MCAL to one 64×64 RGBA alpha map, ported from wow-adt's `CombinedAlphaMap`: R, G, B are the +//! opacity of texture layers 1, 2, 3 and A is 255. Layers go in MCLY order after the opaque base. A +//! layer is 4-bit packed (×17: the reference packs it into an RGBA4444 texel, read as `n / 15`), +//! 8-bit raw, or RLE (token bit 7 fill, else copy; the low 7 bits are the count). use crate::McnkChunk; -/// Assembles the per-layer MCAL alpha maps into one 64×64 RGBA buffer (`y`-major, then `x`, then -/// channel R,G,B,A). +/// The combined 64×64 RGBA alpha map, `y`-major. pub struct CombinedAlphaMap { map: Vec, // 64*64*4, [y][x][rgba] x: usize, @@ -20,12 +18,13 @@ pub struct CombinedAlphaMap { const W: usize = 64; impl CombinedAlphaMap { - /// Construct and ingest `chunk`'s alpha layers. `has_big_alpha`/`fix_alpha` select the uncompressed - /// encoding width and the 63→64 edge fix (vanilla: `false`, `true`). + /// Decode `chunk`'s alpha layers. `has_big_alpha` makes uncompressed layers 8-bit, and + /// `fix_alpha` fills the last row and column from their neighbours (a 63×63 source); vanilla + /// is `false`, `true`. pub fn new(chunk: &McnkChunk, has_big_alpha: bool, fix_alpha: bool) -> Self { let mut map = vec![0u8; W * W * 4]; for px in map.as_chunks_mut::<4>().0 { - px[3] = 255; // A unused, set opaque for tool visibility (matches wow-adt) + px[3] = 255; // A is unused; opaque, so a dump shows the channels } let mut s = Self { map, @@ -76,10 +75,10 @@ impl CombinedAlphaMap { const PACKED: usize = W * W / 2; if offset + PACKED <= raw.len() { for &p in &raw[offset..offset + PACKED] { - if !self.set_next((p & 0x0F) * 16) { + if !self.set_next((p & 0x0F) * 17) { break; } - if !self.set_next(((p >> 4) & 0x0F) * 16) { + if !self.set_next(((p >> 4) & 0x0F) * 17) { break; } } @@ -171,3 +170,33 @@ impl CombinedAlphaMap { self.y = 0; } } + +#[cfg(test)] +mod tests { + use super::*; + + fn blank() -> CombinedAlphaMap { + CombinedAlphaMap { + map: vec![0; W * W * 4], + x: 0, + y: 0, + layer: 0, + has_big_alpha: false, + fix_alpha: true, + } + } + + /// The reference packs a 4-bit weight into an RGBA4444 texel (64×64 `0x6b03d0`, 32×32 + /// `0x6b08d0`), read as `n / 15`, so nibble 15 is full coverage. + #[test] + fn four_bit_alpha_reads_as_n_over_15() { + let mut m = blank(); + // Low nibble first: texel 0 = 0xF, texel 1 = 0x8. + m.ingest_small(&[0x8F; W * W / 2], 0); + assert_eq!(m.get(0, 0, 0), 255, "nibble 15 is full coverage"); + assert_eq!(m.get(1, 0, 0), 136, "nibble 8 is 8/15"); + let mut z = blank(); + z.ingest_small(&[0x00; W * W / 2], 0); + assert_eq!(z.get(0, 0, 0), 0); + } +} diff --git a/crates/benilla-adt/src/lib.rs b/crates/benilla-adt/src/lib.rs index ebee4e03..8e89a478 100644 --- a/crates/benilla-adt/src/lib.rs +++ b/crates/benilla-adt/src/lib.rs @@ -1,27 +1,11 @@ -//! An ADT terrain-tile reader for **WoW 1.12.1 (build 5875)** — in-repo, replacing `wow-adt` -//! (decision 0021). -//! -//! Vanilla ADTs are monolithic, chunked (`IFF`-style: a 4-char magic — stored **reversed** on disk — -//! plus a `u32` size, then payload). The root file holds `MTEX` (textures), `MMDX`/`MMID` (M2 paths), -//! `MWMO`/`MWID` (WMO paths), `MDDF`/`MODF` (placements), and 256 `MCNK` terrain chunks. Each `MCNK` is -//! a 128-byte header followed by sub-chunks (`MCVT` heights, `MCNR` normals, `MCLY` layers, `MCAL` -//! alpha, `MCSH` shadow, `MCLQ` liquid), which we locate by **scanning** the chunk payload for their -//! magics (robust, and identical to the header-offset data for vanilla). -//! -//! The shape mirrors what the renderer's terrain/liquid mesher consumes; quirks the consumer -//! compensates for (the MCNR `[x,z,y]` byte order, the split predominant-texture/noEffectDoodad -//! grids) are preserved deliberately. Proven against `wow-adt` over real ADTs during the decision-0021 -//! migration (oracle test in git history); the `benilla-formats` terrain golden tests pin the meshing -//! end-to-end on every run. -//! -//! Byte access goes through `benilla-bytes` (decision 0064): every read is bounds-checked and a -//! truncated chunk/record is a typed [`Error::Truncated`], never a panic. +//! A reader for 1.12.1's monolithic root ADTs: IFF chunks (a reversed magic, a `u32` size, the +//! payload) holding the asset names, the placements and 256 `MCNK` terrain chunks. use std::io::Cursor; use benilla_bytes::{chunks, ByteExt}; -/// A terrain vertex normal — stored on disk as `x, z, y` signed bytes. +/// A terrain vertex normal: three signed bytes, named `x, z, y` in disk order. #[derive(Debug, Clone, Copy)] pub struct VertexNormal { pub x: i8, @@ -30,8 +14,7 @@ pub struct VertexNormal { } impl VertexNormal { - /// `[x/127, y/127, z/127]` — i.e. on-disk `[b0, b2, b1]` / 127 (kept bit-identical to wow-adt; the - /// consumer cancels the z/y swap). + /// `[x, y, z] / 127`, which is disk bytes `[b0, b2, b1]`; the terrain mesher swaps them back. pub fn to_normalized(&self) -> [f32; 3] { [ f32::from(self.x) / 127.0, @@ -41,19 +24,19 @@ impl VertexNormal { } } -/// MCVT — 145 heights (9×9 outer + 8×8 inner, stride-17 interleave). +/// MCVT: 145 heights (9×9 outer + 8×8 inner, stride-17 interleave). #[derive(Debug, Clone)] pub struct McvtChunk { pub heights: Vec, } -/// MCNR — one normal per MCVT vertex. +/// MCNR: one normal per MCVT vertex. #[derive(Debug, Clone)] pub struct McnrChunk { pub normals: Vec, } -/// MCLY per-layer flags (we only need the alpha-compression bit). +/// MCLY per-layer flags. #[derive(Debug, Clone, Copy)] pub struct MclyFlags { pub value: u32, @@ -66,7 +49,7 @@ impl MclyFlags { } } -/// MCLY — one texture layer (16 bytes on disk). +/// MCLY: one texture layer (16 bytes on disk). #[derive(Debug, Clone)] pub struct MclyLayer { pub texture_id: u32, @@ -75,19 +58,19 @@ pub struct MclyLayer { pub effect_id: u32, } -/// MCLY — the chunk's texture layers (0–4; layer 0 is the opaque base). +/// MCLY: the chunk's texture layers (0–4; layer 0 is the opaque base). #[derive(Debug, Clone)] pub struct MclyChunk { pub layers: Vec, } -/// MCAL — raw alpha-map bytes (indexed per layer via [`MclyLayer::offset_in_mcal`]). +/// MCAL: raw alpha-map bytes (indexed per layer via [`MclyLayer::offset_in_mcal`]). #[derive(Debug, Clone)] pub struct McalChunk { pub data: Vec, } -/// MCSH — a 64×64 1-bit baked shadow map (512 bytes). +/// MCSH: a 64×64 1-bit baked shadow map (512 bytes). #[derive(Debug, Clone)] pub struct McshChunk { pub shadow_map: Vec, @@ -104,11 +87,8 @@ impl McshChunk { } } -/// One MCLQ liquid vertex (8 bytes: 4 union bytes + an absolute height). -/// -/// The leading 4 bytes are a **union whose meaning is the block's liquid type** — water/ocean spend -/// them on a depth byte + flow/foam, magma/slime on a `u16` texture-coordinate pair. Both readers are -/// below; which one applies is the caller's decision, from the cell nibble. +/// One MCLQ liquid vertex, 8 bytes: 4 union bytes and an absolute height. The union follows the +/// cell's liquid type: a depth byte and flow for water and ocean, a UV pair for magma and slime. #[derive(Debug, Clone, Copy)] pub struct LiquidVertex { pub union_data: [u8; 4], @@ -116,19 +96,14 @@ pub struct LiquidVertex { } impl LiquidVertex { - /// The depth byte (union byte 0) — drives the water depth-swatch on the render side. Water and - /// ocean blocks only; on a magma block these bytes are [`Self::texcoords`] instead. + /// The water or ocean depth byte (union byte 0). pub fn depth_byte(&self) -> u8 { self.union_data[0] } - /// The authored `(s, t)` texture-coordinate pair — two little-endian `u16`s over the same 4 - /// union bytes. **Magma blocks only**: the reference's lava vert-fill is single-stage and reads - /// its `tc0` straight from here, where water/ocean instead write a UV into the depth-ramp - /// texture (VERIFIED bit-exact `WoW.exe 0x68d890` `liquid_render_verts`, emulation-diffed in - /// wow-re `crates/terrain`: `u = (s as i32 as f64 · 3/256) as f32`, same for `t`). The field is - /// authored world-continuous — a chunk's east edge repeats its neighbour's west edge exactly — - /// so lava tiles seamlessly across MCNK borders. + /// A magma block's authored `(s, t)`, two little-endian `u16`s the reference's lava fill takes + /// as its UV (`0x68d890`: `u = (s as i32 as f64 · 3/256) as f32`, same for `t`). The field is + /// world-continuous, so lava tiles seamlessly across MCNK borders. pub fn texcoords(&self) -> [u16; 2] { [ u16::from_le_bytes([self.union_data[0], self.union_data[1]]), @@ -137,51 +112,33 @@ impl LiquidVertex { } } -/// One MCLQ liquid **block** — a 9×9 absolute-height grid + an 8×8 cell-flag grid, `0x324` bytes. -/// -/// An MCNK carries **one block per set liquid header bit** (bits 2–5), packed back to back and with -/// absent bits consuming nothing (VERIFIED wow-re `system/terrain/scratch/adt-format.md`, the cursor -/// walk at `0x6af7a3`–`0x6af7cb`). Most liquid chunks set exactly one bit; **28 shipped Azeroth -/// chunks set two** — a river *and* the sea, at a river mouth — and carry two blocks at different -/// heights (measured: `sizeMCLQ` 1616 = 8 + 2·`0x324`, block 0 the stream at z≈5.0, block 1 the -/// ocean at z=0). Reading only the first is how the sea goes missing there. -/// -/// **No liquid type here on purpose.** The header-bit→type ordering (bit2 river / bit3 ocean / -/// bit4 magma / bit5 slime) is *INFERRED* upstream — never byte-proven — while the per-cell flag low -/// nibble IS the type, VERIFIED at both consumers (`0x6ba970` `and al,0xf`, `0x68d9b0`). So this -/// reader takes only the block *count* from the flags and leaves the type to whoever reads -/// [`Self::tile_flags`]. (The old `LiquidType::from_mcnk_flags` priority guess labelled all 28 -/// two-bit chunks "ocean" while handing back the river block's bytes.) +/// One MCLQ liquid block: a 9×9 absolute-height grid and an 8×8 cell-flag grid. An MCNK carries +/// one per set liquid header bit, back to back (`0x6af7a3`–`0x6af7cb`): two at the 28 shipped river +/// mouths. The liquid type is each cell's flag nibble (`0x6ba970`, `0x68d9b0`), not a header bit. #[derive(Debug, Clone)] pub struct MclqChunk { pub min_height: f32, pub max_height: f32, pub vertices: Vec, - /// Per-cell flags, row-major 8×8. Low nibble = liquid type (`0xf` = dry/hole); `0x80` shared. - /// `0x40` ("fishable") is carried but deliberately unread: its only compiled reader, the - /// reference's fishable query `0x69b5d0`, is a zero-caller dead island — fishability is the - /// server's verdict (`SPELL_FAILED_NOT_FISHABLE` passthru). Decision 1825. + /// Per-cell flags, row-major 8×8: the low nibble is the liquid type (`0xf` dry), `0x80` + /// shared. `0x40`, fishable, is unread: the reference's only reader (`0x69b5d0`) has no + /// caller, and the server decides fishability. pub tile_flags: [u8; 64], } -/// MCNK header flag **bit 1** — the chunk a mover may not enter (the "impassable" band that walls -/// Searing Gorge and the other mountain rims off from their neighbours). -/// -/// The *producer* is VERIFIED per-instruction in wow-re (`system/terrain/scratch/adt-format.md`, -/// the MCNK header walk: `hdr+0x00` bit1 → `chunk+0xc |= 0x40`); the *name* is wow-re's INFERRED -/// label for that bit. Consuming it is decision 1266 / report B129. +/// MCNK header flag bit 1: a chunk a mover may not enter, the bands along mountain rims such as +/// Searing Gorge's. The reference's header walk copies it to its chunk flag `0x40` (`0x6af5f0`). pub const MCNK_IMPASSABLE: u32 = 0x2; -/// The fields of the 128-byte MCNK header the renderer reads. `pred_tex`/`no_effect_doodad`/ -/// `unknown_8bytes` mirror wow-adt's (mis)split of the predominant-texture + noEffectDoodad region, -/// which the consumer reconstructs — kept identical on purpose. +/// The MCNK header fields the renderer reads. The names mislead: `pred_tex` and `no_effect_doodad` +/// are the two halves of the predominant-texture map at `+0x40`, and `unknown_8bytes` is the real +/// noEffectDoodad at `+0x50`; the consumer reassembles them. #[derive(Debug, Clone)] pub struct McnkHeader { pub flags: u32, pub index_x: u32, pub index_y: u32, - /// `AreaTable.dbc` id of this chunk (header +0x34) — the zone/subzone the player is standing - /// in; drives zone music/ambience/reverb selection (decision 0070). + /// `AreaTable.dbc` id of the chunk's zone or subzone (`+0x34`). pub area_id: u32, pub holes_low_res: u16, pub pred_tex: [u8; 8], @@ -191,9 +148,7 @@ pub struct McnkHeader { } impl McnkHeader { - /// Is this chunk flagged [`MCNK_IMPASSABLE`]? The flag is a header bit, so its granularity is - /// the whole 33.333 yd chunk; in the shipped data the set chunks form contiguous ribbons along - /// zone rims (Searing Gorge's is 48 chunks of `Azeroth_33_44` alone). + /// Whether the raw header sets [`MCNK_IMPASSABLE`], which covers the whole 33.333 yd chunk. pub fn impassable(&self) -> bool { self.flags & MCNK_IMPASSABLE != 0 } @@ -208,12 +163,11 @@ pub struct McnkChunk { pub layers: Option, pub alpha: Option, pub shadow: Option, - /// The chunk's MCLQ liquid blocks — **one per set liquid header bit**, in on-disk order (see - /// [`MclqChunk`]). Empty on a dry chunk; two at a river mouth. + /// The MCLQ liquid blocks in disk order: none on a dry chunk, two at a river mouth. pub liquids: Vec, } -/// MDDF — a placed M2 doodad (36 bytes). +/// MDDF: a placed M2 doodad (36 bytes). #[derive(Debug, Clone)] pub struct DoodadPlacement { pub name_id: u32, @@ -224,7 +178,7 @@ pub struct DoodadPlacement { pub flags: u16, } -/// MODF — a placed WMO (64 bytes). +/// MODF: a placed WMO (64 bytes). #[derive(Debug, Clone)] pub struct WmoPlacement { pub name_id: u32, @@ -233,8 +187,8 @@ pub struct WmoPlacement { pub rotation: [f32; 3], pub flags: u16, pub doodad_set: u16, - /// `WMOAreaTable.NameSetID` selector — which naming/audio variant of the building this - /// placement is (Northshire Abbey vs Tyr's Hand Abbey are one WMO, different name sets). + /// `WMOAreaTable.NameSetID`: which name and audio variant of the WMO this placement is + /// (Northshire Abbey and Tyr's Hand Abbey are one WMO). pub name_set: u16, } @@ -249,8 +203,7 @@ pub struct RootAdt { pub mcnk_chunks: Vec, } -/// Either a vanilla monolithic root ADT (all we produce) — kept as an enum to mirror the consumer's -/// `ParsedAdt::Root(..)` match. +/// A parsed ADT; vanilla has only the monolithic root. #[derive(Debug, Clone)] pub enum ParsedAdt { Root(Box), @@ -274,8 +227,7 @@ impl std::error::Error for Error {} type Result = std::result::Result; -/// Split a NUL-separated string blob into a `Vec` of start offsets paired with strings (used to -/// resolve MMDX/MWMO via MMID/MWID offsets). +/// The NUL-terminated string at `offset`: an MMDX or MWMO name by its MMID or MWID offset. fn cstring_at(blob: &[u8], offset: usize) -> String { let end = blob[offset.min(blob.len())..] .iter() @@ -285,7 +237,7 @@ fn cstring_at(blob: &[u8], offset: usize) -> String { String::from_utf8_lossy(&blob[offset.min(blob.len())..end]).into_owned() } -/// Parse a vanilla monolithic ADT. (`cursor` position is ignored; the file starts at the first chunk.) +/// Parse a vanilla monolithic ADT; the cursor's position is ignored. pub fn parse_adt(cursor: &mut Cursor<&[u8]>) -> Result { let b: &[u8] = cursor.get_ref(); @@ -312,7 +264,7 @@ pub fn parse_adt(cursor: &mut Cursor<&[u8]>) -> Result { } } - // Resolve model/WMO paths via the offset tables (the order MDDF/MODF `name_id` indexes). + // MDDF/MODF `name_id` indexes the MMID/MWID offset tables. let models = mmid .iter() .map(|&o| cstring_at(&mmdx, o as usize)) @@ -340,7 +292,6 @@ fn split_strings(blob: &[u8]) -> Vec { .collect() } -/// `name` tags the truncation error with the chunk this record list came from (MMID/MWID). fn read_u32_list(data: &[u8], name: &'static str) -> Result> { data.as_chunks::<4>() .0 @@ -388,18 +339,17 @@ fn read_modf(data: &[u8]) -> Result> { r.f32_at(24).ok_or(Error::Truncated("MODF"))?, r.f32_at(28).ok_or(Error::Truncated("MODF"))?, ], - // 0x20..0x38 = bounding box (6 f32); then: + // 0x20..0x38: the bounding box (6 f32), unread. flags: r.u16_at(56).ok_or(Error::Truncated("MODF"))?, doodad_set: r.u16_at(58).ok_or(Error::Truncated("MODF"))?, name_set: r.u16_at(60).ok_or(Error::Truncated("MODF"))?, - // 0x3E scale u16 — unused. + // 0x3E: a u16, unread. }); } Ok(out) } -/// Parse one MCNK: a 128-byte header, then sub-chunks located by scanning the payload for their -/// (reversed) magics. +/// Parse one MCNK: the 128-byte header, then the sub-chunks at the header's offsets. fn read_mcnk(data: &[u8]) -> Result { if data.len() < 128 { return Err(Error::Truncated("MCNK header")); @@ -443,14 +393,9 @@ fn read_mcnk(data: &[u8]) -> Result { liquids: Vec::new(), }; - // Locate sub-chunks by the MCNK header offsets (the authoritative method — sequential scanning - // breaks when sub-chunks aren't contiguous, e.g. AhnQiraj). MCVT/MCNR offsets live in the - // `multipurpose_field` (0x14/0x18); the rest are explicit header fields. The offset base differs - // across sources (relative to the MCNK magic vs the MCNK data), so we auto-detect per sub-chunk by - // validating the (reversed) magic at both candidate positions. Returns the payload `(start, size)`. - // `ofs` comes straight off a (possibly corrupt) file's header, so both the "minus 8" candidate - // and the 8-byte header read use checked/bounds-checked arithmetic (`checked_sub`/`bytes_at`/ - // `u32_at`) — a wrapped or out-of-range offset yields `None`, never a panic. + // Sub-chunks sit at the header's offsets, not in sequence (AhnQiraj's are not contiguous). + // An offset may count from the MCNK magic or from its data, so the reversed magic is checked + // at both; yields the payload's `(start, size)`. let locate = |ofs: u32, magic: &[u8; 4]| -> Option<(usize, usize)> { if ofs == 0 { return None; @@ -464,8 +409,7 @@ fn read_mcnk(data: &[u8]) -> Result { } None }; - // `start` always comes from a `locate` hit (bounds-proven above); `len` is attacker-controlled, - // so the end is saturating before clamping to the buffer. + // `locate` proves `start` in bounds; `len` is unvalidated, so the end saturates, then clamps. let slice = |start: usize, len: usize| &data[start..start.saturating_add(len).min(data.len())]; if let Some((p, n)) = locate( @@ -517,8 +461,8 @@ fn read_mcnk(data: &[u8]) -> Result { } chunk.layers = Some(MclyChunk { layers }); } - // MCAL's data length is the header's `size_alpha`, NOT the sub-chunk's declared size (they differ - // on some files, e.g. AhnQiraj — wow-adt reads `size_alpha`). + // MCAL's length is the header's `size_alpha`, not the sub-chunk's own size: they differ on some + // files (AhnQiraj). if let Some((p, _)) = locate( h.u32_at(0x24).ok_or(Error::Truncated("MCNK header"))?, b"LACM", @@ -540,8 +484,7 @@ fn read_mcnk(data: &[u8]) -> Result { shadow_map[..k].copy_from_slice(&sub[..k]); chunk.shadow = Some(McshChunk { shadow_map }); } - // MCLQ, like MCAL, takes its length from the MCNK header (`size_liquid`): the MCLQ sub-chunk's - // own declared size is always 0. + // MCLQ's length is the header's `size_liquid` too: its own declared size is always 0. if let Some((p, _)) = locate( h.u32_at(0x60).ok_or(Error::Truncated("MCNK header"))?, b"QLCM", @@ -555,14 +498,12 @@ fn read_mcnk(data: &[u8]) -> Result { /// Bytes per MCLQ block: `{f32 min, f32 max, 81×8B verts, 64B cell flags, u32 nFlow, 2×40B flow}`. const MCLQ_BLOCK: usize = 0x324; -/// Liquid header bits (2–5) — their **count** is the number of packed MCLQ blocks. Only the count is -/// read: the bit→type ordering is INFERRED, the cell nibble is VERIFIED (see [`MclqChunk`]). +/// Liquid header bits 2–5: how many are set is how many MCLQ blocks there are. const MCNK_LIQUID_BITS: u32 = 0x3c; -/// Read the packed MCLQ blocks — one per set liquid header bit, back to back (VERIFIED wow-re -/// `adt-format.md`). Stops early on a block the payload can't cover, so a truncated tail degrades to -/// the blocks that *are* whole rather than to nothing. A chunk with an MCLQ sub-chunk but no liquid -/// bit set still yields one block, matching the pre-multi-block reader. +/// Read the packed MCLQ blocks, stopping at the first one the payload cannot hold. A chunk with an +/// MCLQ sub-chunk but no liquid bit set still yields one block; the reference creates one per set +/// bit only (`0x6af760`). fn read_mclq_blocks(sub: &[u8], mcnk_flags: u32) -> Vec { let want = (mcnk_flags & MCNK_LIQUID_BITS).count_ones().max(1) as usize; let mut out = Vec::with_capacity(want); @@ -578,9 +519,8 @@ fn read_mclq_blocks(sub: &[u8], mcnk_flags: u32) -> Vec { out } -/// `None` (not an error) below the minimum size: too-short MCLQ data is treated as "no liquid", -/// matching the pre-0064 reader — a real-data tolerance, not a hardening carve-out. (The minimum is -/// the *read* span, short of the full [`MCLQ_BLOCK`]: the trailing flow vectors are unread.) +/// Too-short MCLQ data is no liquid, not an error. The minimum stops short of [`MCLQ_BLOCK`]: the +/// trailing flow data is unread. fn read_mclq_block(sub: &[u8]) -> Option { const NEED: usize = 8 + 81 * 8 + 64; if sub.len() < NEED { @@ -621,9 +561,7 @@ mod tests { v } - /// A 128-byte MCNK header with a single MCVT (145 heights) sub-chunk placed right after it, - /// referenced via the "offset relative to the MCNK magic" convention (multipurpose field @0x14 - /// pointing at the MCVT chunk's own magic, 128 bytes in). + /// An MCNK header and one MCVT after it, the `0x14` offset pointing at MCVT's magic, 128 in. fn mcnk_with_mcvt() -> Vec { let mut data = vec![0u8; 128]; data[0x14..0x18].copy_from_slice(&128u32.to_le_bytes()); @@ -679,9 +617,7 @@ mod tests { #[test] fn partial_trailing_mddf_record_is_dropped_not_a_panic() { - // A record shorter than the 36-byte MDDF stride: `chunks_exact` drops the short remainder - // (a pre-existing, unchanged leniency — every *complete* 36-byte record is still - // bounds-checked via `ByteExt`, so this can never panic or misread). + // Shorter than the 36-byte stride: `as_chunks` drops the remainder. let b = chunk(b"FDDM", &[0u8; 10]); let ParsedAdt::Root(root) = parse(&b).expect("parses"); assert!(root.doodad_placements.is_empty()); @@ -695,9 +631,7 @@ mod tests { #[test] fn corrupt_mcnk_subchunk_offset_skips_cleanly_never_panics() { - // ofs = 3: `checked_sub(8)` on the second candidate is `None`, and the first candidate (3) - // doesn't land on a valid magic — must yield `None`, not wrap/panic (the verified - // `ofs.wrapping_sub(8)` trap this replaces). + // ofs = 3: the minus-8 candidate underflows and 3 holds no magic. let mut data = vec![0u8; 128]; data[0x14..0x18].copy_from_slice(&3u32.to_le_bytes()); let b = chunk(b"KNCM", &data); @@ -716,20 +650,16 @@ mod tests { fn short_mclq_is_treated_as_no_liquid_not_an_error() { let mut data = vec![0u8; 128]; data[0x60..0x64].copy_from_slice(&128u32.to_le_bytes()); // MCLQ offset - data[0x64..0x68].copy_from_slice(&4u32.to_le_bytes()); // size_liquid — far short of NEED + data[0x64..0x68].copy_from_slice(&4u32.to_le_bytes()); // size_liquid, far short of NEED data.extend(chunk(b"QLCM", &[0u8; 4])); let b = chunk(b"KNCM", &data); let ParsedAdt::Root(root) = parse(&b).expect("parses"); assert!(root.mcnk_chunks[0].liquids.is_empty()); } - /// **One MCLQ block per set liquid header bit**, packed back to back — the shape that made the - /// sea vanish at 28 shipped river mouths when only the first was read (see [`MclqChunk`]). - /// Two bits set ⇒ two blocks, each keeping its OWN cell flags, so the consumer can tell the - /// river block from the ocean block by nibble. + /// Each block keeps its own cell flags, so the river and the ocean tell apart by nibble. #[test] fn a_two_bit_mcnk_yields_two_mclq_blocks_in_disk_order() { - // One block: min/max, 81×8B verts, 64B cell flags, then the flow tail — 0x324 total. let block = |height: f32, nibble: u8| { let mut b = vec![0u8; MCLQ_BLOCK]; b[0..4].copy_from_slice(&height.to_le_bytes()); @@ -740,11 +670,11 @@ mod tests { b[8 + 81 * 8..8 + 81 * 8 + 64].fill(nibble); b }; - let mut payload = block(5.0, 4); // bit 2 (river) — first on disk - payload.extend(block(0.0, 1)); // bit 3 (ocean) — second + let mut payload = block(5.0, 4); // the river, first on disk + payload.extend(block(0.0, 1)); // the ocean, second let mut data = vec![0u8; 128]; - data[0x00..0x04].copy_from_slice(&0x0cu32.to_le_bytes()); // liquid bits 2 AND 3 + data[0x00..0x04].copy_from_slice(&0x0cu32.to_le_bytes()); // liquid bits 2 and 3 data[0x60..0x64].copy_from_slice(&128u32.to_le_bytes()); data[0x64..0x68].copy_from_slice(&((payload.len() + 8) as u32).to_le_bytes()); data.extend(chunk(b"QLCM", &payload)); @@ -759,9 +689,7 @@ mod tests { assert_eq!(liquids[1].min_height, 0.0); } - /// The magma union bytes are an authored `(s, t)` u16 pair, little-endian over the same 4 bytes - /// the water blocks spend on a depth byte — VERIFIED `0x68d890` reads them as `u16 → i32`, so - /// `0xffff` is 65535 and never −1. + /// `0x68d890` reads the magma pair as `u16 → i32`, so `0xffff` is 65535, never −1. #[test] fn magma_union_bytes_read_as_two_unsigned_u16_texcoords() { let v = LiquidVertex { diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index 18380dad..e61c481a 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -3,164 +3,85 @@ name = "benilla-app" version.workspace = true edition.workspace = true license.workspace = true -description = "The Bevy app as a library: everything but the build-id stamp, which lives in the `benilla` launcher shim so a commit dirties ~30 lines instead of this crate (decision 0993)" +description = "The Bevy app as a library: everything but the build-id stamp, which lives in the `benilla` launcher so a new commit does not rebuild this crate" [lints] workspace = true [features] -# **The dev/player seam** (decisions 0026 + 1173). Default-ON, so every ordinary `cargo run`, -# capture, probe and gate builds exactly what it built before. `--no-default-features` compiles -# the instruments — the debug panel, the perf HUD, the inspector, the capture harness and the -# probe fleet — physically OUT: not dormant behind a runtime flag, absent. That is the whole -# requirement a player client has of them, and a runtime toggle cannot meet it. -# -# The rule the seam enforces is one-directional: **dev may see anything; nothing may depend on -# dev.** It is enforced by `cargo build -p benilla --no-default-features` in `scripts/gates.sh`, -# not by anyone remembering it — 0026 adopted the rule in June 2026 with no failing build behind -# it, and twenty-four couplings had accumulated by August (1173). +# `dev` compiles in the instruments: the debug panel, perf HUD, inspector, capture harness and +# probes. Nothing may depend on it; `scripts/gates.sh` builds `benilla` with no default features. default = ["dev"] -# -# The three benilla crates below are taken with `default-features = false` and their `dev` -# re-exported here (1175). Their `dev` is default-on too, so without the round trip a -# `--no-default-features` build gets the feature back through unification — and the binary -# carries the build machine's source-tree path after all. Same trap as the shim's, one level down. +# `benilla-assets`, `-formats` and `-world` come in without default features and their `dev` is +# forwarded here: it is on by default in each, so unification would put it back in a player build. dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:bevy_egui"] -# Opt-in Tracy profiling: `cargo run --features tracy` streams per-system `info_span!` markers (on the -# hot systems) + render-pass CPU timings to a Tracy capture (decision 0026 — the dev/player seam; -# a `docs/howto/running.md` this once cited never landed). tracy = ["bevy/trace_tracy"] -# Opt-in Chrome-trace profiling: `cargo run --features trace_chrome` writes a `trace-.json` of -# every system's per-run span next to the working dir. Tracy's offline twin — it needs no GUI and no -# live capture, so a per-system cost question is answerable from a terminal (and from a run someone -# else recorded). Aggregate it with `scripts/tracesum.py`. +# `--features trace_chrome` writes every system's per-run span to `trace-.json` in the cwd. trace_chrome = ["bevy/trace_chrome"] [dependencies] anyhow.workspace = true -# OS clipboard access for the UI EditBox copy/cut/paste chords (chat input). Already in the tree via -# bevy_egui; pinned to the same 3.x so there's one copy. Read on the main thread (the UI key feed is -# a NonSend system) — required for the macOS NSPasteboard path. Covers macOS, Windows, and Linux/BSD -# under X11; the Wayland backend is `smithay-clipboard` below (decision 0702). +# The EditBox clipboard chords, read on the main thread as macOS NSPasteboard requires; Wayland +# goes through `smithay-clipboard`. The 3.x bevy_egui uses, so the graph has one copy. arboard = "3" benilla-ui = { path = "../benilla-ui" } bevy.workspace = true wgpu.workspace = true -# Optional: the dev overlays are its only consumers (the panel, the perf pill, the inspector -# card), so a player build does not link it at all (`dev` feature, decision 1173). bevy_egui = { version = "0.39", optional = true } -# Zero-copy POD→bytes for the shared light storage buffer (one per-frame write_buffer). Already in -# the tree transitively; pinned to the same version as the dev-dep below. bytemuck = { version = "1", features = ["derive"] } -# `f16` decode for the booth-target dump (`WOW_BOOTH_DUMP`): the booth render targets are -# `Rgba16Float` (decision 0804) and the PNG wants display bytes. Already in the tree via -# wgpu/bevy_image; declared so the call site is an honest dependency. +# `f16` decode for the `WOW_BOOTH_DUMP` dump of the `Rgba16Float` booth render targets. half = "2" -# The shipped FrameXML tree, compiled into the binary (decision 1175 §2): 61 files, ~2.8 MB, so the -# client cannot boot without its interface on a machine that has never seen our source. A proc -# macro plus a no-dependency runtime shim — it adds nothing to the graph at runtime, and it is the -# crate everyone uses for this. `assets/ui` is OUR content (MIT/Apache), not Blizzard's. +# Compiles `assets/ui` into the binary, so the interface loads on a machine without the source. include_dir = "0.7" -# The 1.12 chat filters are PCRE over shipped DBCs (decision 2077; `text_filter`): 2289 profanity -# expressions and 28 spam ones, compiled once at startup and run over every chat line. `RegexSet` is -# exactly the shape that needs — one automaton over the whole list for the "can anything match at -# all" pass — and the crate is already in the tree transitively. +# The 1.12 chat filters (`text_filter`): the DBCs' 2289 profanity and 28 spam patterns, with one +# `RegexSet` for the any-match pass over every chat line. regex = "1" -# `getrusage` — whole-process CPU time (all threads), the perf probes' `cpu_ms`/`cpu_pct` metric -# (`perf::process_cpu_secs`). Wall-clock fps is unusable as a regression instrument on a machine -# running parallel sessions' builds; CPU-per-frame is, and it is also the number the Mac report -# is about ("250 % CPU at 59 fps"). Was dev-only; since 1857 the output backend needs it in every -# build too (`mach_absolute_time`, the host clock its meters read; `thread_policy_set`, the render -# thread's realtime policy). +# `getrusage` for the perf probes' process CPU time; `mach_absolute_time` and `thread_policy_set` +# for the audio output's meter clock and its render thread's realtime policy. libc = "0.2" -# Resample varying-size layer BLPs to a uniform size for the terrain texture array; `png` so we can -# encode the cursor image into data an NSImage can load (macOS hardware cursor). +# PNG encoding only: the macOS hardware cursor's NSImage data and screenshots. image = { version = "0.25", default-features = false, features = ["png"] } -# The net↔ECS bridge: a background IO thread streams typed `SessionEvent`s to the ECS over a -# channel, and outbound `ClientCommand`s back. crossbeam's Sender/Receiver are `Send + Sync + Clone` -# (std's mpsc Sender is `!Sync`, so it can't be a Bevy resource without a mutex — the very thing this -# bridge deletes). The canonical Bevy background-thread→channel→system idiom. See decision 0006. +# Thread-to-ECS channels such as the net bridge's: crossbeam's `Receiver` is `Sync`, so either end +# can be a Bevy resource without a mutex. crossbeam-channel = "0.5" benilla-assets = { path = "../benilla-assets", default-features = false } benilla-formats = { path = "../benilla-formats", default-features = false } benilla-protocol = { path = "../benilla-protocol" } benilla-world = { path = "../benilla-world", default-features = false } -# The CVar config diff (`benilla/config.toml`, decision 0954): serde derive on the persisted -# shape, toml as its format. (serde_json left with the era manifest — 0978.) +# The CVar diff persisted in `benilla-config/config.toml`. serde = { version = "1", features = ["derive"] } toml = "0.8" -# ECS-native physics: collider storage + broadphase BVH + shape-casts, with a thin custom kinematic -# character controller on top (decision 0009). Pinned 0.6: it targets Bevy 0.18 (verified single, -# unified bevy 0.18.1 in the tree); 0.7.0 jumps to Bevy 0.19 and would duplicate bevy. avian wraps -# parry under the hood, so adopting it lets us drop the *direct* parry3d dep once the bespoke -# CollisionWorld is gone. See docs… (decision 0007 superseded by 0009). +# Collision for our kinematic character controller; 0.6 targets Bevy 0.18, 0.7 moves to 0.19. avian3d = "0.6" -# The glyph shaper + rasterizer for `FontString` text (decision 0068 §2, probe-verified in -# `probes/text-glyph`). Pinned to 0.16 — the exact version already in the tree transitively -# (`bevy_text`'s own dependency, see `Cargo.lock`) — so this is the *same* `cosmic-text` the decision -# already commits to, not a second copy. Used as a standalone shaper/rasterizer (`FontSystem` + -# `Buffer` + `SwashCache`) in `ui_text.rs`, never `bevy_text`'s `Text2d`/ECS integration (that path -# doesn't feed our custom z-ordered `UiQuad` stream). +# Shapes and rasterizes `FontString` text standalone (`ui_text`), at bevy_text's own 0.16. cosmic-text = "0.16" -# The font database `cosmic-text` 0.16 uses (its `Source`/`ID`/`FaceInfo` appear in `ui_text.rs`'s -# public-in-crate types). Pinned to the exact version `cosmic-text` 0.16 depends on so there's one -# `fontdb` in the graph. +# `cosmic-text`'s font database, whose types `ui_text` names; must stay the version it depends on. fontdb = "0.23" -# kira renders the mix; the device it plays through is OURS (`sound::output`, decision 1857) — -# kira's own `cpal` backend is not enabled on any target. On macOS that took cpal out of the graph -# entirely: it could neither report a lost default device nor rebuild on one, and it discarded the -# IO-cycle timestamp the deadline meters need. Off macOS cpal is back (1920) but as OUR device -# layer under the same backend, not as kira's — the ring, the render thread and the meters are the -# same code on every platform. +# The mixer. Its `cpal` backend stays off on every target: `sound::output` is our own backend. kira = { version = "0.12", default-features = false, features = ["mp3", "wav", "pcm", "adpcm"] } -# The realtime-audio allocation tripwire (decision 1857): `assert_no_alloc(|| …)` around the output -# IO callback and the render pass aborts a DEBUG build on any allocation inside them, so a -# realtime-safety regression fails a test or a smoke instead of reaching an ear. Release builds -# compile it out (`disable_release`, the crate's default) and carry no allocator wrapper. +# Aborts a debug build on an allocation in the audio IO callback or render pass; off in release. assert_no_alloc = "1.1" -# kira's own decoder, named directly for one trait: the mixer feeds streams through its own -# `MediaSource` so kira's per-stream decode thread runs OUR code and can promote its QoS (decision -# 1109 — a bare-spawned decode thread starves under the world-entry burst and zero-fills the mix). -# No codecs of its own (kira's features enable those); must stay the version kira resolves — one -# symphonia in the graph, or the trait impl won't line up. +# For `MediaSource`: kira's decode thread reads through our source, which raises the thread's QoS +# so the world-entry burst cannot starve it. Must stay kira's version, or the trait won't match. symphonia = { version = "0.5", default-features = false } -# The mix tap's audio-thread → writer-thread ring (decision 1112): the tap effect must never -# block or allocate on the render path. kira's own SPSC ring crate; kept on the version kira -# resolves so the graph carries one rtrb. +# The audio threads' lock-free SPSC rings (output, mix tap); kira's version, so the graph has one. rtrb = "0.3" mint = "0.5" -# The gossip greeting is a weighted random DRAW per menu-open, not a fixed pick (wow-re -# `gossip-npctext-law.md` — the reference re-rolls in its gossip handler), so the app needs a uniform -# source. Pinned to 0.8: the version `benilla-srp` already depends on, so this adds no crate. +# The gossip greeting's weighted draw, re-rolled per open as in the reference; `benilla-srp`'s 0.8. rand = "0.8" -[dev-dependencies] -# The WMO portal-cull audit's raw-face instruments (`WOW_PIN_COL`): the pin probe compares the -# filtered walking-collision set against the group file's FULL face list with per-face MOPY flags, -# which only the raw parser exposes — benilla-formats' gathers are exactly the filtered views. -benilla-wmo = { path = "../benilla-wmo" } - -# Linux/BSD: the Wayland clipboard (decision 0702). We build with bevy's `wayland` feature, so on a -# Wayland session winit gives us a native Wayland window and `arboard` — X11-only without its -# non-default `wayland-data-control` feature — finds no X11 CLIPBOARD owner and every paste fails. -# smithay-clipboard is the right crate for a windowed app: it drives the *standard* `wl_data_device` -# off our own `wl_display`/seat, which every compositor supports. arboard's own Wayland feature would -# not do: it speaks `wlr-data-control`, a privileged manager protocol GNOME/Mutter deliberately -# doesn't implement. `raw-window-handle` (pinned to the 0.6 already in the tree via bevy_window, so -# this adds no crate) is only needed to read the `wl_display` out of bevy's `RawHandleWrapper`. +# Linux/BSD Wayland clipboard: smithay-clipboard drives the standard `wl_data_device` on our own +# `wl_display`, read through raw-window-handle (bevy's 0.6). arboard's Wayland feature speaks +# `wlr-data-control`, which GNOME does not implement. [target.'cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))'.dependencies] raw-window-handle = "0.6" smithay-clipboard = "0.7" -# macOS: drive a native NSCursor for a crisp hardware cursor. winit drives the cursor via AppKit -# cursor-rects, which our continuous Metal render loop doesn't honor on mouse-move (custom cursor / -# hide revert to the arrow); we bypass it with NSCursor + NSWindow::disableCursorRects. See -# docs/knowledge/camera-control.md. +# macOS: AppKit through objc2 drives a native NSCursor with the window's cursor rects disabled; +# winit's cursor rects revert to the arrow under our continuous render loop (`cursor.rs`). [target.'cfg(target_os = "macos")'.dependencies] -# The audio device layer (decision 1857, `sound::output::coreaudio`): the HAL output unit through -# coreaudio-rs's `AudioUnit`, and the AudioObject property/listener API through the objc2 CoreAudio -# bindings — the two crates cpal itself stood on, now used directly. The versions are the ones the -# graph already carries; the feature sets are the ones cpal proved to compile against. +# The audio device layer (`sound::output::coreaudio`): the HAL output unit through coreaudio-rs, +# the AudioObject property API through objc2-core-audio, at the versions and features cpal uses. coreaudio-rs = { version = "0.13", default-features = false, features = ["audio_unit", "core_audio"] } objc2-core-audio = { version = "0.3", default-features = false, features = ["std", "AudioHardware"] } objc2-core-audio-types = { version = "0.3", default-features = false, features = ["std", "CoreAudioBaseTypes"] } @@ -178,20 +99,12 @@ objc2-app-kit = { version = "0.2", features = [ ] } objc2-foundation = { version = "0.2", features = ["NSArray", "NSData", "NSGeometry"] } -# Everywhere else: the same device layer over cpal (decision 1920, `sound::output::cpal`). 1857 -# owns the mix-ahead ring, the render thread and the meters on every platform; only the last -# hop — find the default device, open a stream on it, hand back one buffer per cycle — is -# platform code, and off macOS that hop is ALSA/PipeWire, WASAPI or the host's own API. cpal is -# the layer that already speaks all of them; on macOS it is still not in the graph, because -# there its defects (a no-op error callback on the default device, a pinned `DefaultOutput` -# unit, the discarded IO-cycle timestamp) are exactly what 1857 went direct to escape. On an -# unsupported target cpal compiles to its Null host, which reports no device — the same silent -# posture the old `stub.rs` had, without a second file to rot. +# Off macOS the device layer's last hop runs over cpal (`sound::output::cpal`). macOS goes direct: +# there cpal cannot report or rebuild on a lost default device and drops the IO-cycle timestamp. +# An unsupported target gets cpal's Null host, which reports no device. [target.'cfg(not(target_os = "macos"))'.dependencies] cpal = "0.17" -# Windows: the journal's and the probes' CPU clocks (decision 2219, `perf::clock`) — -# `GetProcessTimes`, `GetThreadTimes` and `GetSystemTimes`, the twins of the `getrusage`, -# `clock_gettime` and `host_statistics64` arms. The version is one the graph already carries. +# Windows CPU clocks for `perf::clock`: `GetProcessTimes`, `GetThreadTimes`, `GetSystemTimes`. [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } diff --git a/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml b/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml index d2b71067..d6f168ae 100644 --- a/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml +++ b/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml @@ -1,65 +1,19 @@ - + - - + - + @@ -252,9 +115,8 @@ - + @@ -263,18 +125,10 @@ - - + - + - + - -- The shared era-window scale first (ERA_WINDOW_SCALE, OptionsFrame.xml — loaded - -- before this file): the era menu has no scale of its own (GameMenuFrame.xml + - -- MainMenuFrameTemplates, grepped clean), it draws at UIParent's px-per-unit — - -- the SAME density as the era options window, whose checkFit caps at 1. The knob - -- is our stand-in for that UIParent scale; without it the menu drew 28% larger - -- relative to the options window than the era pair ("esc menu scale too big" — - -- director, 2026-08-04). `or 1` guards a load-order regression only. NOTE: this - -- lives INSIDE the existing OnShow — a second Scripts/OnShow block on the same - -- frame would silently REPLACE this one (SetScript semantics: last wins). + -- Deviation: ERA_WINDOW_SCALE (OptionsFrame.xml, loaded earlier) stands in for the + -- UIParent scale the era draws this menu and its options window at. + -- A second OnShow on this frame would replace this one: the last SetScript wins. this:SetScale(ERA_WINDOW_SCALE or 1) if UpdateMicroButtons then UpdateMicroButtons() end if Disable_BagButtons then Disable_BagButtons() end diff --git a/crates/benilla-app/assets/ui/KeyBindingsPage.xml b/crates/benilla-app/assets/ui/KeyBindingsPage.xml index f25a4bde..2c316954 100644 --- a/crates/benilla-app/assets/ui/KeyBindingsPage.xml +++ b/crates/benilla-app/assets/ui/KeyBindingsPage.xml @@ -1,77 +1,24 @@ - + - + @@ -141,19 +84,17 @@ - + - + - + @@ -2230,13 +1527,9 @@ - + - + @@ -2293,12 +1580,8 @@ - + @@ -2325,29 +1608,11 @@ - + @@ -2370,10 +1635,8 @@ - + @@ -2391,9 +1654,7 @@ - + @@ -2429,18 +1690,9 @@ - + @@ -2463,8 +1715,7 @@ - + @@ -2481,18 +1732,13 @@ - + - + @@ -2500,28 +1746,9 @@ - + @@ -2531,20 +1758,15 @@ - + - + @@ -2553,19 +1775,10 @@ - + @@ -2600,19 +1813,10 @@ - + - + - + - + @@ -2714,8 +1914,7 @@ OptionsCategoryRow_OnLoad(self, "Keybindings", "Keybindings") - + - + - + @@ -2849,54 +2018,28 @@ BenillaScroll_SyncBar(this) - + - + - + - + @@ -2914,19 +2057,7 @@ self.invert = 1 - - + @@ -2955,12 +2086,9 @@ OptionsRow_OnLoad(self, "BlockTrades", "Block Trades", "OPTION_TOOLTIP_BLOCK_TRADES") - + @@ -2976,10 +2104,7 @@ - + OptionsRow_OnLoad(self, "autoLootDefault", "Auto Loot") @@ -2990,16 +2115,7 @@ OptionsRow_OnLoad(self, "mouseInvertPitch", "Invert Mouse", "OPTION_TOOLTIP_INVERT_MOUSE") - + @@ -3011,31 +2127,10 @@ OptionsSliderRow_Setup(self, 0.5, 1.5, 0.05) - - + @@ -3051,18 +2146,7 @@ }) - + @@ -3073,11 +2157,8 @@ "OPTION_TOOLTIP_FOLLOW_TERRAIN") - + @@ -3088,18 +2169,9 @@ "OPTION_TOOLTIP_HEAD_BOB") - + @@ -3111,10 +2183,9 @@ OptionsSliderRow_Setup(self, 90, 270, 10, "raw") - + @@ -3135,12 +2206,8 @@ "OPTION_TOOLTIP_SMART_PIVOT") - + @@ -3166,15 +2233,9 @@ - + @@ -3208,8 +2269,7 @@ OptionsRow_OnLoad(self, "SoundVolume", "Sound Volume", "OPTION_TOOLTIP_SOUND_VOLUME", 1) - -- era parentInitializer (search pulls Master in - -- above a matched child, SettingsPanel.lua l.665) + -- Era parentInitializer (SettingsPanel.lua l.665). self.parentRow = "RowMaster" @@ -3257,11 +2317,7 @@ self.soundQuirk = 1 - + @@ -3273,14 +2329,9 @@ self.soundQuirk = 1 - + @@ -3295,23 +2346,9 @@ - + @@ -3329,10 +2366,8 @@ OptionsSliderRow_Setup(self, OptionsFrameSliders[1].minValue, OptionsFrameSliders[1].maxValue, OptionsFrameSliders[1].valueStep) - -- era registers its uiscale row CommitFlag.Apply - -- (Graphics.lua l.684): the one row whose live - -- write would rescale the very slider mid-drag — - -- it stages and commits through the Apply button. + -- Era CommitFlag.Apply (Graphics.lua l.684): a + -- live write would rescale the slider mid-drag. self.deferred = 1 @@ -3357,42 +2392,24 @@ OptionsRow_OnLoad(self, "WorldDetail", "Environment Detail", "OPTION_TOOLTIP_ENVIRONMENT_DETAIL") - -- The stop names are the era preset ladder's (its - -- quality dropdowns say Low/Medium/High); 1.12's - -- slider had no stop labels to quote. + -- Stop names from the era quality ladder. OptionsSliderRow_Setup(self, OptionsFrameSliders[3].minValue, OptionsFrameSliders[3].maxValue, OptionsFrameSliders[3].valueStep, nil, { "Low", "Medium", "High" }) - + - -- The default is the reference's own computation - -- rather than a second copy of the number: - -- OptionsFrame_SetDefaults displays this row as - -- `1 - GetCVarDefault("gamma")` (OptionsFrame.lua - -- l.441-445), so the CVar table stays the single - -- source and Restore Defaults here lands exactly - -- where the reference's would. The `or 1` is for a - -- VM whose host registered nothing (a bare kit), - -- where the reference would have had a record to - -- read because its own lookup creates one. + -- OptionsFrame_SetDefaults' default (l.441-445); + -- `or 1` covers a VM with no CVars. local gd = tonumber(GetCVarDefault("gamma")) or 1 OptionsNumFuncRow_OnLoad(self, GetGamma, SetGamma, tostring(1 - gd), "Brightness", @@ -3402,17 +2419,9 @@ OptionsFrameSliders[6].valueStep, "travel") - + @@ -3421,16 +2430,9 @@ OptionsRow_OnLoad(self, "weatherDensity", "Weather Intensity", "OPTION_TOOLTIP_WEATHER_DETAIL") - -- The stop names are OURS, and they name the - -- reference's own quality table rather than a - -- borrowed preset ladder: `0x67b870` maps 0/1/2/3 - -- onto the spawn-rate gains {0.1, 0.33, 0.66, 1.0} - -- in `[0x8680ec]`, so the top stop really is the - -- full rate and the bottom is a tenth of it. 1.12 - -- labelled no stop on this slider -- it labelled - -- the two ENDS -- but every slider row in this kit - -- carries a readout, and "2" is the one thing that - -- seat could say that means nothing to a player. + -- Our stop names for the reference's rates: + -- `0x67b870` maps 0 to 3 onto the spawn-rate gains + -- {0.1, 0.33, 0.66, 1.0} in `[0x8680ec]`. OptionsSliderRow_Setup(self, OptionsFrameSliders[9].minValue, OptionsFrameSliders[9].maxValue, OptionsFrameSliders[9].valueStep, nil, @@ -3443,32 +2445,11 @@ - -- Display Mode (1650): MODERN CLASSIC's row, not - -- 1.12's, because 1627 already gave us modern - -- Classic's two states and a checkbox could only - -- name one of them. - -- - -- Blizzard_SettingsDefinitions_Shared/Graphics.lua - -- (live, classic AND classic_era, all identical): - -- a BOOLEAN proxy rendered as a dropdown, with - -- exactly two entries -- container:Add(true, - -- VIDEO_OPTIONS_WINDOWED_FULLSCREEN) and - -- container:Add(false, VIDEO_OPTIONS_WINDOWED). - -- There is no Fullscreen entry anywhere in it; - -- 8.0.1 removed the exclusive mode those clients - -- had, which is the same reason 1627 ships none. - -- - -- It rides gxWindow, OUR persisted CVar, not the - -- gxMaximize the modern client uses -- that client - -- deleted gxWindow entirely and had to move; we - -- did not, and repointing would orphan every - -- config.toml already holding the row for nothing. - -- Polarity is gxWindow's own: "0" is not-windowed, - -- which 1627 redefined as the borderless window. - -- - -- Not deferred, for the same reason as Vertical - -- Sync below: the reference row carries - -- gxRestart = 1 and ours applies on the click. + -- Deviation: modern Classic's two-entry Display + -- Mode dropdown (Graphics.lua in + -- Blizzard_SettingsDefinitions_Shared), as there + -- is no exclusive fullscreen; gxWindow "0" is the + -- borderless window. Applies on the click. OptionsRow_OnLoad(self, "gxWindow", "Display Mode", "BENILLA_TOOLTIP_DISPLAY_MODE") OptionsDropdownRow_Setup(self, { @@ -3483,12 +2464,9 @@ - -- Vertical Sync: 1.12's own Video Options checkbox - -- 5 (gxVSync), over the window's present mode. - -- The reference row carries gxRestart = 1; ours - -- does not and must not be marked deferred -- - -- wgpu swaps the presentation interval live, so - -- the box takes effect as you click it. + -- 1.12's Video Options checkbox 5. Not deferred + -- despite its gxRestart = 1: the present mode + -- changes live. OptionsRow_OnLoad(self, "gxVSync", "Vertical Sync", "OPTION_TOOLTIP_VERTICAL_SYNC") @@ -3506,33 +2484,11 @@ - -- Render Scale (decision 1639) -- benilla's own - -- row, no 1.12 counterpart: the reference renders - -- straight to the backbuffer and has no second - -- image to size. It CLOSES the page, and it is - -- the only antialiasing control on it: the - -- reference's own Multisampling row was pulled - -- 2026-08-27 (decision 1648, the director's call). - -- gxMultisample survives as a CVar and $WOW_MSAA - -- -- what went is the row, because multisampling - -- cannot touch the alpha-tested cutouts that carry - -- most of this game's visible aliasing, and above - -- 100% this row can. - -- - -- 50-200%, narrower than the CVar's own clamp - -- (RENDER_SCALE_RANGE, 0.25-4.0). That clamp stops - -- an absurd value and leaves room for the - -- supersampling instrument; this is what a player - -- should be offered, and it is where every engine - -- puts the same slider. - -- - -- DEFERRED, and for the reason the flag exists. - -- Committing live would rebuild the world's render - -- target on every drag tick -- tens of megabytes, - -- thirty times across one sweep of the handle. - -- Staging to Apply makes that exactly one rebuild, - -- which is the same answer uiScale's row already - -- reaches for a different reason. + -- Deviation: benilla's own row (the reference draws + -- to the backbuffer), and no Multisampling row, + -- which misses alpha-tested cutouts. Deferred: a + -- live write rebuilds the world render target on + -- every drag tick. OptionsRow_OnLoad(self, "renderScale", "Render Scale", "BENILLA_TOOLTIP_RENDER_SCALE") OptionsSliderRow_Setup(self, 0.5, 2.0, 0.05) @@ -3540,16 +2496,9 @@ - + for i, name in BENILLA_GRAPHICS_SLIDER_ROWS do @@ -3889,18 +2838,8 @@ AdvGfx_OnUpdate(this, arg1) - + @@ -3915,12 +2854,10 @@ OptionsGlobalRow_OnLoad(self, "SHOW_COMBAT_TEXT", "Enable Floating Combat Text", "OPTION_TOOLTIP_SHOW_COMBAT_TEXT") - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" - + @@ -3935,7 +2872,7 @@ { text = "Arc", value = "3" }, }) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -3948,7 +2885,7 @@ "Show Low Mana and Health", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_LOW_HEALTH_MANA", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -3960,7 +2897,7 @@ OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_AURAS", "Show Auras", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_AURAS", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -3972,7 +2909,7 @@ OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_AURA_FADE", "Show Fading Auras", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_AURA_FADE", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -3985,7 +2922,7 @@ "Show Combat State", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_COMBAT_STATE", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -3998,7 +2935,7 @@ "Show Dodge/Parries/Misses", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_DODGE_PARRY_MISS", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -4011,7 +2948,7 @@ "Show Damage Reduction", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_RESISTANCES", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -4024,7 +2961,7 @@ "Show Reputation", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_REPUTATION", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -4037,7 +2974,7 @@ "Show Reactive Spells and Abilities", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_REACTIVES", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -4050,12 +2987,10 @@ "Show Friendly Healer Names", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_FRIENDLY_NAMES", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" - + @@ -4066,7 +3001,7 @@ "Show Combo Points", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_COMBO_POINTS", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -4079,7 +3014,7 @@ "Show Energy Gains", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_MANA", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" @@ -4092,22 +3027,12 @@ "Show Honor Gained", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_HONOR_GAINED", 1) self.parentRow = "RowCombatText" - self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "CombatText_UpdateOrLoad" - + @@ -4118,12 +3043,8 @@ "OPTION_TOOLTIP_SHOW_DAMAGE") - + @@ -4135,12 +3056,7 @@ self.parentRow = "RowShowDamage" - + @@ -4155,45 +3071,11 @@ - + @@ -4260,15 +3142,8 @@ "0", "Show Right ActionBar", "OPTION_TOOLTIP_SHOW_MULTIBAR3") - + @@ -4297,14 +3172,8 @@ OptionsGlobalRow_OnLoad(self, "ALWAYS_SHOW_MULTIBARS", "Always Show ActionBars", "OPTION_TOOLTIP_ALWAYS_SHOW_MULTIBARS") - -- A CLICK runs MultiActionBar_UpdateGridVisibility, the - -- reference's own click arm (UIOptionsFrame.xml:683). - -- The LOAD arm — which only ever SHOWS, because every hide - -- is a counted decrement on each well (ActionButton_HideGrid) - -- and a load-time hide against wells nobody opened would push - -- the count negative — is the stock file's - -- VARIABLES_LOADED arm now (UIOptionsFrame.lua l.218-220), - -- which is why this row carries no `loadFunc` (2115). + -- The click arm (UIOptionsFrame.xml:683); the load + -- arm is the stock VARIABLES_LOADED one. self.applyFunc = "MultiActionBar_UpdateGridVisibility" @@ -4320,47 +3189,17 @@ - + - - + @@ -4380,19 +3219,9 @@ self.cvarEvent = "STATUS_BAR_TEXT" - + @@ -4401,17 +3230,11 @@ OptionsGlobalRow_OnLoad(self, "SHOW_TARGET_OF_TARGET", "Show Target of Target", "OPTION_TOOLTIP_SHOW_TARGET_OF_TARGET") - self.applyFunc = "TargetofTarget_Update" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "TargetofTarget_Update" - + @@ -4427,17 +3250,11 @@ { text = "Party or Raid", value = "4", tip = "OPTION_TOOLTIP_TARGETOFTARGET4" }, { text = "Always", value = "5", tip = "OPTION_TOOLTIP_TARGETOFTARGET5" }, }) - self.applyFunc = "TargetofTarget_Update" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "TargetofTarget_Update" - + @@ -4468,12 +3285,7 @@ "Instant Quest Text", "OPTION_TOOLTIP_SHOW_QUEST_FADING") - + @@ -4482,7 +3294,7 @@ OptionsGlobalRow_OnLoad(self, "SHOW_BUFF_DURATIONS", "Buff Durations", "OPTION_TOOLTIP_SHOW_BUFF_DURATION") - self.applyFunc = "BuffButtons_UpdatePositions" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "BuffButtons_UpdatePositions" @@ -4495,19 +3307,8 @@ "Automatic Quest Tracking", "OPTION_TOOLTIP_AUTO_QUEST_WATCH") - + @@ -4516,39 +3317,23 @@ OptionsRow_OnLoad(self, "profanityFilter", "Profanity Filter", "OPTION_TOOLTIP_PROFANITY_FILTER") - + - + OptionsFuncRow_OnLoad(self, TutorialsEnabled, BenillaOptionsFrame_SetTutorialsEnabled, "1", "Show Tutorials", "OPTION_TOOLTIP_SHOW_TUTORIALS") - + @@ -4570,29 +3355,9 @@ - - + @@ -4605,11 +3370,7 @@ - + OptionsRow_OnLoad(self, "guildMemberNotify", "Guild Member Alert", "OPTION_TOOLTIP_GUILDMEMBER_ALERT") @@ -4621,17 +3382,12 @@ - + OptionsGlobalRow_OnLoad(self, "REMOVE_CHAT_DELAY", "Remove Chat Hover Delay", "OPTION_TOOLTIP_REMOVE_CHAT_DELAY") - self.applyFunc = "ChatFrame_ApplyMouseOverDelay" -- by NAME, resolved when applied: this file loads before its definer now (1938) + self.applyFunc = "ChatFrame_ApplyMouseOverDelay" @@ -4650,13 +3406,7 @@ - + OptionsRow_OnLoad(self, "showLootSpam", "Detailed Loot Information", "OPTION_TOOLTIP_SHOW_LOOT_SPAM") @@ -4672,23 +3422,10 @@ "Show Party Chat Bubbles", "OPTION_TOOLTIP_PARTY_CHAT_BUBBLES") - + @@ -4702,28 +3439,10 @@ - + @@ -4754,13 +3473,8 @@ OptionsRow_OnLoad(self, "UnitNamePlayer", "Player Names", "OPTION_TOOLTIP_SHOW_PLAYER_NAMES") - + @@ -4790,18 +3504,9 @@ - - + @@ -4815,15 +3520,8 @@ - + @@ -4980,22 +3678,12 @@ - + - + @@ -5003,28 +3691,13 @@ ScrollFrame_OnLoad() - + FauxScrollFrame_OnVerticalScroll(KEYBINDINGS_ROW_HEIGHT, KeyBindingsPage_Update) - + if not KeyBindingsPage.selected then ScrollFrameTemplate_OnMouseWheel(arg1) @@ -5033,11 +3706,8 @@ - + if not KeyBindingsPage.selected then @@ -5046,11 +3716,8 @@ - + - + @@ -5129,21 +3792,9 @@ - + diff --git a/crates/benilla-app/assets/ui/ScriptLogFrame.xml b/crates/benilla-app/assets/ui/ScriptLogFrame.xml index 21407722..979c2b07 100644 --- a/crates/benilla-app/assets/ui/ScriptLogFrame.xml +++ b/crates/benilla-app/assets/ui/ScriptLogFrame.xml @@ -1,68 +1,31 @@ - + - + @@ -335,16 +271,14 @@ - + FauxScrollFrame_OnVerticalScroll(BENILLA_SCRIPTLOG_ROW_HEIGHT, BenillaScriptLog_Update) - + @@ -367,8 +301,7 @@ - + @@ -392,18 +325,16 @@ BenillaScriptLogFrame_OnLoad(self) - + BenillaScriptLogFrame_OnMouseWheel(arg1) BenillaScriptLog_Update() BenillaScriptLogFrame_OnUpdate() - + @@ -322,39 +219,24 @@ - + @@ -374,9 +256,7 @@ + (v 82/256 .. 108/256), ending on v 0.421875 as ReputationFrame's does. --> @@ -386,8 +266,7 @@ + Declared last: an anchor to a later sibling falls back to the parent. --> @@ -399,22 +278,4 @@ - - - - diff --git a/crates/benilla-app/assets/ui/SpellBookAdapters.xml b/crates/benilla-app/assets/ui/SpellBookAdapters.xml index 80dfc700..b9173803 100644 --- a/crates/benilla-app/assets/ui/SpellBookAdapters.xml +++ b/crates/benilla-app/assets/ui/SpellBookAdapters.xml @@ -1,27 +1,13 @@ `). Both run in document order relative to everything else at the top -/// level ("`` with no text at all. +/// The 1-based line of the first text byte inside a ` assert!(doc.warnings.is_empty()); } - /// `inherits="A, B"` is **one name**, not a list — and it misses. - /// - /// This test used to assert the opposite (that both templates merged, children concatenated in - /// order). That was a superset of 1.12 borrowed from later clients: the carve found no splitter - /// anywhere in the loader — all 8 call sites hand `0x6ee6f0` the pointer `GetAttribute` - /// returned, one or two instructions later, and no comma is examined in `0x6ed000–0x6f6000` at - /// all. So the lookup runs **once**, for the literal name `"A, B"`, misses, warns at - /// severity 1, and the element loads with **neither** template applied. - /// - /// Whitespace is never trimmed either, which is why the second half asserts `" A "` misses a - /// template that genuinely exists. + /// All eight callers of `0x6ee6f0` pass `GetAttribute`'s pointer as is, and nothing in + /// `0x6ed000`–`0x6f6000` examines a comma; the value is not trimmed either. #[test] fn a_comma_list_is_one_literal_name_that_misses_and_applies_neither_template() { let doc = parse( @@ -872,14 +658,13 @@ print(x) let mut warnings = Vec::new(); let expanded = expand(inst, &templates, &mut warnings); - // Warned, naming the whole unsplit string — not split, not silently ignored. assert_eq!(warnings.len(), 1, "{warnings:?}"); assert!( warnings[0].contains("A, B"), "the warning must name the literal it looked up: {warnings:?}" ); - // The element still loads, with all of its OWN content and none of the templates'. + // The element loads with its own content only. assert_eq!(expanded.attr("alpha"), Some("1.0")); assert_eq!(expanded.children.len(), 1); fn texture_name(el: &Element) -> &str { @@ -887,7 +672,7 @@ print(x) } assert_eq!(texture_name(&expanded.children[0]), "FromInst"); - // And a padded name misses a template that exists — the value is used verbatim. + // A padded name misses: the value is used verbatim. let TopLevel::Instance(padded) = &doc.items[3] else { panic!("expected instance") }; @@ -921,8 +706,7 @@ print(x) let mut warnings = Vec::new(); let expanded = expand(leaf, &templates, &mut warnings); assert!(warnings.is_empty()); - // Mid's own frameStrata (it overrode Root's) wins, since chain-expanding Mid resolves Root - // first and Mid's own attrs on top, before Leaf (which sets nothing) merges on top of that. + // Mid's frameStrata overrides Root's, and Leaf sets none. assert_eq!(expanded.attr("frameStrata"), Some("MEDIUM")); assert_eq!( expanded.children.len(), @@ -946,7 +730,6 @@ print(x) panic!("expected instance") }; let mut warnings = Vec::new(); - // Must terminate (not infinitely recurse) and report the cycle. let _expanded = expand(inst, &templates, &mut warnings); assert!( warnings.iter().any(|w| w.contains("cycle")), @@ -969,38 +752,17 @@ print(x) resolve_name("NotAParentName", "PlayerFrame"), "NotAParentName" ); - // No named ancestor: caller passes the literal "Top" fallback. + // No named ancestor: the caller passes the literal "Top". assert_eq!(resolve_name("$parentFoo", DEFAULT_PARENT_NAME), "TopFoo"); } #[test] fn parent_name_substitution_composes_through_nested_children() { - // A named parent, a child using $parent, and a grandchild using $parent against the - // child's *resolved* name — not the child's raw literal "$parent..." text (rf27 rule 4). + // The grandchild resolves against the child's resolved name, not its raw "$parent…" text. let parent_name = "PlayerFrame"; let child_name = resolve_name("$parentHealthBar", parent_name); assert_eq!(child_name, "PlayerFrameHealthBar"); let grandchild_name = resolve_name("$parentText", &child_name); assert_eq!(grandchild_name, "PlayerFrameHealthBarText"); } - - /// Parses a REAL shipped FrameXML file when `BENILLA_FRAMEXML` points at one (never committed — - /// extract e.g. `Interface\FrameXML\PlayerFrame.xml` with benilla-extract). Skips silently - /// otherwise, matching toc.rs's `real_manifest_when_available` pattern so CI/gates don't depend - /// on client data. - #[test] - fn real_framexml_when_available() { - let Ok(path) = std::env::var("BENILLA_FRAMEXML") else { - return; - }; - let text = std::fs::read_to_string(&path).expect("reading BENILLA_FRAMEXML"); - let doc = parse(&text).unwrap_or_else(|e| panic!("{path}: {e}")); - assert!( - !doc.items.is_empty(), - "{path}: a real FrameXML file has at least one top-level item" - ); - for w in &doc.warnings { - eprintln!("{path}: warning: {w}"); - } - } } diff --git a/crates/benilla-ui/src/justify.rs b/crates/benilla-ui/src/justify.rs index ab0995e2..a2a2255c 100644 --- a/crates/benilla-ui/src/justify.rs +++ b/crates/benilla-ui/src/justify.rs @@ -1,57 +1,11 @@ -//! The justify enum — **one** transcription of `.rdata 0x811ad0`, shared by every table that -//! speaks it. -//! -//! ## Why this is a module and not three sets of `match` arms -//! -//! Justification is set on **three** widget tables — a `FontString` (`0x87c1d8`), a `` -//! object (`0x87c7c8`) and an `EditBox` — and each had been transcribed *independently* from the -//! same byte law. Each drifted its own way, and each looked right alone: -//! -//! | | FontString ([`crate::script::region`]) | Font ([`crate::script::font`]) | EditBox ([`crate::widget::kinds::editbox`]) | the bytes | -//! |---|---|---|---|---| -//! | `GetJustifyH`/`V` | **absent** | present | present | present (`0x79e5f0`/`0x79e7f0`) | -//! | space-padded token | rejected | `.trim()`ed, **accepted** | rejected | rejected (whole-string) | -//! | unmatched string | silently `CENTER` | silently `CENTER` | **raises** | **raises** | -//! | cross-axis token | silently `CENTER` | silently `CENTER` | **clears the axis** | clears the axis | -//! | axis storage | resolved enum → **the dword** (1239) | resolved enum | **the raw dword** | one dword | -//! -//! That is the seam class the addon-lane round trip named: halves transcribed separately from one -//! law drift where they meet, while every test on every side still passes. **The EditBox column is -//! the one that was right** — written last, straight from the bytes, and never consulted by the two -//! older tables. So this module is not new law: it is that column, lifted to where all three reach -//! it. It sits at the crate root rather than under `script` because `widget::kinds` needs it too. -//! -//! ## The law -//! -//! Storage is a single dword — `CSimpleFont+0x54`, `CSimpleFontString+0x120` — with **bits 0–2 -//! horizontal and bits 3–5 vertical**. `SetJustifyH 0x79fc20` computes `(cur ^ parsed) & 0x07 ^ -//! cur`, i.e. `(cur & !0x07) | (parsed & 0x07)`, replacing only its own axis (VERIFIED at -//! `0x79fc5d`–`0x79fc64`); `SetJustifyV 0x79fce0` uses mask `0x38`. The ctor default is `0x212` = -//! `CENTER | MIDDLE | 0x200`, bit 9 being outside both masks and read by neither accessor. -//! -//! Two consequences fall out of that arithmetic, and both are transcribed below: -//! -//! - **A cross-axis token is accepted and clears.** `SetJustifyH("TOP")` parses fine (`0x08`) but -//! `0x08 & 0x07 == 0`, so it erases the horizontal justification with **no error raised**, and a -//! following `GetJustifyH()` answers `"UNKNOWN"` — while the glyphs keep drawing **centred**. -//! The two readers genuinely disagree, which is why [`Justify`] stores the dword rather than a -//! resolved enum: [`Justify::name_h`] is the getter's answer and [`Justify::paint_h`] is the -//! draw path's, and they differ only in this state. -//! - **A non-token raises.** `0x6f1990` scans all six entries with `SStrCmpI` — whole-string, -//! case-insensitive, **no trimming** — and returns 0 on a miss, which is the caller's cue to -//! raise `Usage: %s:SetJustifyH("justify")` (`.rdata 0x87c77c`) / `…SetJustifyV…` -//! (`0x87c7a0`). Per `script::binding_abi`, that arm does not return: it abandons the caller's -//! statement. -//! -//! Recorded in wow-re `system/ui/scratch/font-object-lua-surface.md` §9.4 and, for the -//! fall-through and the unconditional inherit clear, `system/ui/scratch/justify-fallthrough-law.md` -//! (§5 quad, arbitrated). +//! The justify dword and its token table (`.rdata 0x811ad0`), shared by the `FontString` +//! (`0x87c1d8`), `Font` (`0x87c7c8`) and `EditBox` method tables. Each setter replaces only its +//! own axis, and also clears the inherit bit whether or not the value changed (`0x79fc7c`). use crate::script::{JustifyH, JustifyV}; -/// `.rdata 0x811ad0` — six `{u32 bits, const char* name}` entries, in image order. The order is -/// load-bearing twice over: `0x6f1990` scans it linearly, and `0x6f1a00` answers with the **first** -/// entry whose bit is set. +/// `.rdata 0x811ad0`, six `{u32 bits, const char* name}` entries in image order. The order +/// matters: `0x6f1990` scans linearly and `0x6f1a00` answers the first entry with its bit set. const TOKENS: [(u32, &str); 6] = [ (0x01, "LEFT"), (0x02, "CENTER"), @@ -64,43 +18,30 @@ const TOKENS: [(u32, &str); 6] = [ /// The literal `0x6f1a00` answers when no bit in the requested axis is set (`.data 0x838044`). pub const UNKNOWN: &str = "UNKNOWN"; -/// The horizontal axis — bits 0–2, `SetJustifyH`'s mask. +/// The horizontal axis, bits 0–2: the mask of `SetJustifyH 0x79fc20`. pub const H_MASK: u32 = 0x07; -/// The vertical axis — bits 3–5, `SetJustifyV`'s mask. +/// The vertical axis, bits 3–5: the mask of `SetJustifyV 0x79fce0`. pub const V_MASK: u32 = 0x38; -/// What a justify argument turned out to be — the three outcomes of `0x6f1990` followed by the -/// setter's mask. +/// A justify argument after `0x6f1990` and the setter's mask. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Set { - /// A token carrying a bit on *this* axis: the new justification. + /// A token with a bit on this axis. To(J), - /// A token that matched the table but carries no bit on this axis — `SetJustifyH("TOP")`. - /// The reference **clears** the axis and raises nothing; `GetJustifyH()` then answers - /// `"UNKNOWN"` while the glyphs keep drawing centred ([`Justify::paint_h`]). - /// - /// Not hypothetical: 13 corpus sites call `SetJustifyV("CENTER")` meaning "middle" - /// (`FonzAppraiser` ×12, `Roid-Macros`), which parses and then erases the vertical axis. + /// A token with no bit on this axis (`SetJustifyH("TOP")`): the reference clears the axis and + /// raises nothing; corpus addons call `SetJustifyV("CENTER")` and hit this. Clears, - /// No entry matched. The caller raises its own `Usage:` string; it does not coerce. + /// No entry matched: the caller raises its `Usage:` string, never coerces. NoMatch, } -/// The ctor default — `CENTER | MIDDLE | 0x200`. Bit 9 is outside both axis masks and is read by -/// neither accessor nor the gx translator. `CSimpleFontString`'s **own** ctor writes it at -/// `0x770dd3`, so a FontString that has never been told otherwise reads `CENTER`/`MIDDLE`. +/// `CENTER | MIDDLE | 0x200`, as `CSimpleFontString`'s ctor writes it (`0x770dd3`); bit 9 is in +/// neither axis and no reader looks at it. const CTOR_DEFAULT: u32 = 0x212; -/// The justify **dword** — `CSimpleFont+0x54`, `CSimpleFontString+0x120`, `CSimpleEditBox`'s own — -/// with bits 0–2 horizontal and bits 3–5 vertical. -/// -/// Stored as the dword rather than a pair of resolved enums because **an axis with no bit set is a -/// real, reachable state** and a resolved enum cannot hold it: `SetJustifyH("TOP")` parses `0x08`, -/// contributes nothing to mask `0x07`, and erases the axis with no error raised. -/// -/// The two readers then disagree, faithfully, and that is the whole reason this type exists: -/// [`Justify::name_h`] (the Lua getter) answers `"UNKNOWN"`, while [`Justify::paint_h`] (the draw -/// path) answers `CENTER`. Anything that collapses the two loses one of them. +/// The justify dword (`CSimpleFont+0x54`, `CSimpleFontString+0x120`, `CSimpleEditBox`'s own), +/// bits 0–2 horizontal and 3–5 vertical. Kept raw because an axis with no bit set is reachable, +/// and there the getter answers `"UNKNOWN"` while the draw path centres. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Justify(pub u32); @@ -111,11 +52,9 @@ impl Default for Justify { } impl Justify { - /// Replace the horizontal bits — `(cur & !0x07) | (parsed & 0x07)`. pub fn set_h(&mut self, j: JustifyH) { self.0 = set_axis(self.0, H_MASK, bits_h(j)); } - /// Replace the vertical bits — `(cur & !0x38) | (parsed & 0x38)`. pub fn set_v(&mut self, j: JustifyV) { self.0 = set_axis(self.0, V_MASK, bits_v(j)); } @@ -123,46 +62,36 @@ impl Justify { pub fn clear_h(&mut self) { self.0 = set_axis(self.0, H_MASK, 0); } - /// Erase the vertical axis. pub fn clear_v(&mut self) { self.0 = set_axis(self.0, V_MASK, 0); } - /// `GetJustifyH`'s answer — `0x6f1a00`, so `"UNKNOWN"` for a cleared axis. + /// `GetJustifyH`'s answer (`0x79e5f0`) through `0x6f1a00`: `"UNKNOWN"` for a cleared axis. pub fn name_h(self) -> &'static str { name_of(self.0, H_MASK) } - /// `GetJustifyV`'s answer. + /// `GetJustifyV`'s answer (`0x79e7f0`). pub fn name_v(self) -> &'static str { name_of(self.0, V_MASK) } - /// What the glyphs actually draw at horizontally — the ui→gx translator `0x44d420`, whose one - /// call site image-wide is `0x772693`. - /// - /// **It is a priority ladder, not a switch, and the fall-through is `CENTER` — not `LEFT`.** - /// Each axis's result register is pre-set to `1` *between* the `test` and the `jcc` - /// (`0x44d4fb mov ebx,0x1`, then `0x44d516 je`), so an all-clear axis exits with `1` still in - /// it, and `1` is bit-identical to CENTER. A cleared axis is therefore visually - /// indistinguishable from the ctor default. - /// - /// **The trap, quoted from the finding:** the gx layer's `else` arms genuinely *are* LEFT and - /// BOTTOM, but both are unreachable from a FontString — `0x44d420` emits only `{0,1,2}` and - /// `0x5c1c30` rejects `>= 3`. *Mapping the ui bitmask onto the gx enum with a `0` default - /// inverts both axes.* + /// Where the glyphs draw horizontally: the translator `0x44d420` (called only from + /// `0x772693`) is a ladder whose result is pre-set to `1`, CENTER (`0x44d4fb`, before the + /// `je` at `0x44d516`), so a cleared axis draws centred. The gx enum's `else` arms, LEFT and + /// BOTTOM, are unreachable (`0x44d420` emits only 0 to 2, `0x5c1c30` rejects 3 and up): + /// mapping the bitmask onto that enum with a `0` default inverts both axes. pub fn paint_h(self) -> JustifyH { if self.0 & 0x04 != 0 { JustifyH::Right } else if self.0 & 0x01 != 0 { JustifyH::Left } else { - JustifyH::Center // 0x02 set, or NOTHING set — the pre-set `1` + JustifyH::Center // 0x02 set, or no bit set: the pre-set `1` } } - /// What the glyphs draw at vertically — the same ladder, `TOP > BOTTOM > MIDDLE`, falling - /// through to `MIDDLE`. `0x44d536`'s redundant `mov esi,0x1` on the MIDDLE arm is MSVC's own - /// tell that `default:` and `MIDDLE` share a constant. + /// The same ladder vertically, `TOP > BOTTOM > MIDDLE`, falling through to MIDDLE + /// (`0x44d536`). pub fn paint_v(self) -> JustifyV { if self.0 & 0x08 != 0 { JustifyV::Top @@ -190,8 +119,7 @@ fn bits_v(j: JustifyV) -> u32 { } } -/// `0x6f1990` — a linear scan of all six entries with `SStrCmpI`: **whole-string, -/// case-insensitive, and without trimming**, so `"left "` is a miss. +/// `0x6f1990`: `SStrCmpI` over the six entries, whole-string and untrimmed, so `"left "` misses. pub fn parse_bits(s: &str) -> Option { TOKENS .iter() @@ -199,7 +127,7 @@ pub fn parse_bits(s: &str) -> Option { .map(|(bits, _)| *bits) } -/// `0x6f1a00` — the **first** entry whose bit is set within `mask`, else the literal `UNKNOWN`. +/// `0x6f1a00`: the first entry whose bit is set within `mask`, else the literal `UNKNOWN`. pub fn name_of(bits: u32, mask: u32) -> &'static str { TOKENS .iter() @@ -207,14 +135,13 @@ pub fn name_of(bits: u32, mask: u32) -> &'static str { .map_or(UNKNOWN, |(_, name)| *name) } -/// Replace one axis's bits with `parsed`'s, masked to that axis — `0x79fc5d`'s -/// `(cur ^ parsed) & mask ^ cur`, i.e. `(cur & !mask) | (parsed & mask)`. This is the operation -/// that makes a cross-axis token *clear* rather than raise. +/// The setters' `(cur ^ parsed) & mask ^ cur` (`SetJustifyH` at `0x79fc5d`–`0x79fc64`), which is +/// why a cross-axis token clears rather than raises. pub fn set_axis(cur: u32, mask: u32, parsed: u32) -> u32 { (cur & !mask) | (parsed & mask) } -/// `SetJustifyH`'s argument, resolved against mask `0x07`. +/// `SetJustifyH`'s argument, against mask `0x07`. pub fn parse_h(s: &str) -> Set { match parse_bits(s) { None => Set::NoMatch, @@ -225,7 +152,7 @@ pub fn parse_h(s: &str) -> Set { } } -/// `SetJustifyV`'s argument, resolved against mask `0x38`. +/// `SetJustifyV`'s argument, against mask `0x38`. pub fn parse_v(s: &str) -> Set { match parse_bits(s) { None => Set::NoMatch, @@ -236,18 +163,17 @@ pub fn parse_v(s: &str) -> Set { } } -/// `GetJustifyH`'s answer for a resolved horizontal justification, through the same table. +/// `GetJustifyH`'s answer for a resolved justification. pub fn name_h(j: JustifyH) -> &'static str { name_of(bits_h(j), H_MASK) } -/// `GetJustifyV`'s answer for a resolved vertical justification, through the same table. pub fn name_v(j: JustifyV) -> &'static str { name_of(bits_v(j), V_MASK) } -/// The `Usage:` string `SetJustifyH` raises on a miss (`.rdata 0x87c77c`). The `%s` is spelled as -/// the widget type, matching the shipped convention in `script::font_block`. +/// The `Usage:` string `SetJustifyH` raises on a miss (`.rdata 0x87c77c`), the `%s` spelled as +/// the widget type. pub fn usage_h(widget: &str) -> mlua::Error { mlua::Error::runtime(format!("Usage: <{widget}>:SetJustifyH(\"justify\")")) } @@ -266,8 +192,7 @@ mod tests { assert_eq!(parse_h("LEFT"), Set::To(JustifyH::Left)); assert_eq!(parse_h("left"), Set::To(JustifyH::Left)); assert_eq!(parse_h("LeFt"), Set::To(JustifyH::Left)); - // `SStrCmpI` compares the whole string; the client does not trim, and neither do we — - // the Font-object path used to, which is the divergence this module exists to end. + // `SStrCmpI` compares the whole string, untrimmed. assert_eq!(parse_h("left "), Set::NoMatch); assert_eq!(parse_h(" left"), Set::NoMatch); assert_eq!(parse_h("leftmost"), Set::NoMatch); @@ -276,8 +201,6 @@ mod tests { #[test] fn an_unmatched_string_does_not_coerce_to_center() { - // The whole point of the raise: the reference tells the addon it passed nonsense, where we - // used to silently answer CENTER on both tables. assert_eq!(parse_h("MIDDLE_LEFT"), Set::NoMatch); assert_eq!(parse_v("CENTERED"), Set::NoMatch); } @@ -304,15 +227,12 @@ mod tests { #[test] fn the_formatter_is_first_bit_set_within_the_axis_else_unknown() { - // `0x6f1a00`'s two documented edges, neither reachable through our resolved enums but both - // part of the law being transcribed: no bit in the axis answers the literal UNKNOWN, and a - // dword with two bits set answers the *first* table entry rather than the largest. + // `0x6f1a00`: no bit in the axis answers UNKNOWN, two bits answer the first table entry. assert_eq!(name_of(0x00, H_MASK), "UNKNOWN"); assert_eq!(name_of(0x38, H_MASK), "UNKNOWN"); // vertical bits only assert_eq!(name_of(0x05, H_MASK), "LEFT"); // LEFT | RIGHT assert_eq!(name_of(0x30, V_MASK), "MIDDLE"); // MIDDLE | BOTTOM - // The ctor default `0x212` reads CENTER/MIDDLE, and bit 9 is invisible to both axes. - assert_eq!(name_of(0x212, H_MASK), "CENTER"); + assert_eq!(name_of(0x212, H_MASK), "CENTER"); // the ctor default; bit 9 is in no axis assert_eq!(name_of(0x212, V_MASK), "MIDDLE"); } } diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index de84dcae..8c68f058 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -1,69 +1,37 @@ -//! The frame anchor/layout resolver — anchor graph → resolved rects (decision 0068). +//! The frame anchor resolver: the 1.12 client's `CLayoutFrame` rect resolution, transcribed +//! function by function, with the reference's operations in the reference's order. //! -//! This is a faithful transcription of the WoW 1.12.1 client's `CLayoutFrame` geometry resolver, -//! whose math is reverse-engineered bit-exact against `WoW.exe` in the sibling repo `wow-5875-re` -//! (`system/ui/scratch/layout.md`, `.../rf32-composed-resolver.md`, `.../frame-model.md`, -//! `.../propagation.md`). The leaf float kernels and the recursion-guarded per-edge composition are -//! transcribed from that repo's own verified `w5875-ui::layout` crate -//! (`wow-5875-re/crates/ui/src/layout.rs`), which is gated bit-exact under a Unicorn/`WoW.exe` -//! difftest harness (per-function diffs + the composed `assemble` over 6006 synthesized cases). -//! Matching that crate's operations and their order — not "improving" the arithmetic — is the goal. +//! The client computes on the x87 at 53-bit precision, which `f64` matches, and narrows to `f32` +//! only at its stores (each sub-edge before a `combine_*`, each final edge, the `width * scale` +//! span), so the arithmetic here runs in `f64` with an explicit `as f32` at exactly those sites. //! -//! ## Precision — why `f64` intermediates, not naive `f32` -//! -//! The client is an x87 binary: every intermediate lives in an FPU register at PC_53 (53-bit -//! mantissa = IEEE `f64` precision) and is narrowed to `f32` *only* at the documented `fstp m32` -//! store sites (each sub-edge result before a `combine_*`; each final edge into the out-rect; the -//! `width·scale` span). A leaf like `(right + left) * 0.5 + xOff·scale` therefore rounds **once** -//! (at the final store), whereas doing every operation in `f32` would round at each step and -//! diverge. So "the client is f32" is true only at the storage boundaries: to be bit-faithful the -//! chain of `+`/`-`/`*0.5` runs in `f64` (host-exact — every operand is `f32`-derived, so each op is -//! exact in `f64`) with an explicit `as f32` at exactly the sites the RE pins. This mirrors -//! `w5875-ui::layout` verbatim. -//! -//! ## Coordinate space -//! -//! Rects are stored in the client's own convention: `[bottom, left, top, right]` (frame-absolute -//! `+0x64/+0x68/+0x6c/+0x70`; VERIFIED via `GetRect 0x768320` + the Lua `GetBottom/Left/Top/Right` -//! readers, `layout.md`). WoW UI space is **y-up**: `top > bottom`, the screen root's rect is the -//! physical screen (the anchor chain bottoms out at the `CSimpleTop` root), and `width = right − -//! left`, `height = top − bottom`. Coordinates are screen pixels throughout; `scale` (layoutScale = -//! effective scale, `propagation.md`) scales only the per-anchor offsets and the `width/height` span. +//! Rects are `[bottom, left, top, right]` (frame `+0x64..+0x70`, `GetRect` `0x768320`) in screen +//! pixels, y-up. use std::collections::VecDeque; -// ───────────────────────────────────────────────────────────────────────────────────────────── -// Constants (objdump-VERIFIED in layout.md "Constants") -// ───────────────────────────────────────────────────────────────────────────────────────────── +// ── Constants ──────────────────────────────────────────────────────────────────────────────── -/// UNSET coordinate sentinel: `+Infinity` (the `.data` slot `0xcf550c`, runtime-initialized from -/// `[0x81d56c] = 0x7f800000`, `layout.md`). "coordinate is set" ⇔ `x != +Inf`. Intermediates carry -/// this in `f64`; the `f32` narrowing preserves it (`+Inf as f32 == +Inf`). +/// The unset-coordinate sentinel, `+Inf` (`0xcf550c`, set at runtime from `0x81d56c`). const UNSET: f64 = f64::INFINITY; -/// `OnSizeChanged` trigger ε — `_DAT_008029d4` = `0x34800000` ≈ 2.384e-7 (`layout.md` Constants; -/// applied in `ApplyRect 0x76b580`). `OnSizeChanged(width,height)` fires iff `|Δwidth| ≥ ε` OR -/// `|Δheight| ≥ ε` between the old and new resolved rect. See [`size_changed`]. +/// The least width or height change that fires `OnSizeChanged`: 2^-22, bits `0x34800000` at +/// `0x8029d4`, tested in `ApplyRect` (`0x76b580`). pub const SIZE_EPS: f64 = 2.384_185_791_015_625e-7; -/// Recompute change threshold ε — `_DAT_0080c5c8` = `0x3727c5ac` ≈ 9.96e-6 (`layout.md`; applied in -/// `recompute 0x768d20`): an edge must move at least this far vs the cached rect for a re-store + -/// `ApplyRect`. Benilla owns its own caching (the caller's dirty gate); this const records the client's -/// documented threshold for fidelity work that needs it. +/// The least edge move against the cached rect for the client to re-store it and `ApplyRect`, 1e-5 +/// (`0x80c5c8`, in `0x768d20`). Recorded, not applied: the caller's own dirty gate decides. pub const RESOLVE_EPS: f64 = f32::from_bits(0x3727_c5ac) as f64; -/// point-id → X column (VERIFIED table `0x81c3b8`.., `layout.md` "Point-id → coordinate"): -/// `{0,3,6}` (LEFT column) → 0, `{1,4,7}` (center) → 1, `{2,5,8}` (RIGHT column) → 2. +/// Point id to X column: left 0, center 1, right 2. const X_COL: [u8; 9] = [0, 1, 2, 0, 1, 2, 0, 1, 2]; -/// point-id → Y row: `{0,1,2}` (TOP row) → 0, `{3,4,5}` (center) → 1, `{6,7,8}` (BOTTOM row) → 2. +/// Point id to Y row: top 0, center 1, bottom 2. const Y_ROW: [u8; 9] = [0, 0, 0, 1, 1, 1, 2, 2, 2]; -// ───────────────────────────────────────────────────────────────────────────────────────────── -// Public types -// ───────────────────────────────────────────────────────────────────────────────────────────── +// ── Public types ───────────────────────────────────────────────────────────────────────────── -/// The nine anchor points, with the client's exact discriminants (VERIFIED static tables -/// `0x81c3b8–0x81c3fc`, `layout.md`): the id is the slot index in the client's `anchorPoints[9]`. +/// The nine anchor points; the discriminant is the client's point id, the slot in its +/// `anchorPoints[9]` (tables at `0x81c3b8..0x81c3fc`). #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Point { @@ -79,13 +47,11 @@ pub enum Point { } impl Point { - /// The point-id (0..=8), which is also the `anchorPoints[9]` slot index. #[inline] pub const fn id(self) -> u8 { self as u8 } - /// Build a [`Point`] from its client id, `None` if out of range. pub const fn from_id(id: u8) -> Option { Some(match id { 0 => Point::TopLeft, @@ -102,11 +68,10 @@ impl Point { } } -/// An opaque handle to an anchor target. This crate does not own the frame arena; the caller maps -/// handles to frames / external rects. (In the client this is the `CAnchor+0xc relativeTo` pointer.) +/// An anchor target, which the caller maps to a frame or an external rect (`CAnchor+0xc`). pub type Handle = u32; -/// A resolved rect in the client's `[bottom, left, top, right]` convention (screen pixels, y-up). +/// A resolved rect, `[bottom, left, top, right]` in screen pixels, y-up. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Rect { pub bottom: f32, @@ -116,7 +81,6 @@ pub struct Rect { } impl Rect { - /// Construct from the four edges in client order. pub const fn new(bottom: f32, left: f32, top: f32, right: f32) -> Rect { Rect { bottom, @@ -126,50 +90,39 @@ impl Rect { } } - /// `[bottom, left, top, right]` — the client's cached-rect layout (`+0x64..0x70`). #[inline] pub const fn to_array(self) -> [f32; 4] { [self.bottom, self.left, self.top, self.right] } - /// From `[bottom, left, top, right]`. #[inline] pub const fn from_array(a: [f32; 4]) -> Rect { Rect::new(a[0], a[1], a[2], a[3]) } - /// `right − left` (`GetWidth` semantics, `layout.md`). #[inline] pub fn width(self) -> f32 { self.right - self.left } - /// `top − bottom` (`GetHeight` semantics; y-up). #[inline] pub fn height(self) -> f32 { self.top - self.bottom } } -/// One anchor of a frame — a `CAnchor` record (`{xOff@4, yOff@8, relativeTo@0xc, relativePoint@0x10}`, -/// `frame-model.md`). `point` is the point *on this frame* being pinned (its `anchorPoints[9]` slot); -/// `relative_point` is the point *on the target* it pins to. +/// One anchor, a client `CAnchor` (`xOff +4`, `yOff +8`, `relativeTo +0xc`, `relativePoint +0x10`): +/// `point` on this frame pins to `relative_point` on the target. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Anchor { - /// The point on *this* frame that is being anchored (its `anchorPoints[9]` slot index). pub point: Point, - /// The target frame/region this anchor is relative to (`CAnchor+0xc`). pub relative_to: Handle, - /// The point on the *target* this anchor pins to (`CAnchor+0x10 relativePoint`). pub relative_point: Point, - /// X offset (`CAnchor+4`), scaled by the requesting frame's `scale`. pub x_off: f32, - /// Y offset (`CAnchor+8`), scaled by `scale`. pub y_off: f32, } impl Anchor { - /// Convenience constructor. pub fn new( point: Point, relative_to: Handle, @@ -187,28 +140,24 @@ impl Anchor { } } -/// A frame's full layout input: its anchors, its optional explicit size, its scale, and the -/// clamp-to-screen flag + extents. Mirrors the client's geometry subobject fields consulted by the -/// resolver (`frame-model.md`, `rf32-composed-resolver.md`): `anchorPoints[9]` (`G+0x28`), `width` -/// (`G+0x50`), `height` (`G+0x54`), `layoutScale` (`G+0x58`), flags bit4 = clamp (`G+0x3c`). +/// A frame's layout input, the geometry fields the client's resolver reads: `anchorPoints[9]` +/// (`G+0x4`), `width` (`G+0x50`), `height` (`G+0x54`), `layoutScale` (`G+0x58`) and the clamp flag +/// (bit 4 of `G+0x3c`). #[derive(Clone, Debug, PartialEq)] pub struct LayoutInput { - /// The frame's anchors. Placed into the nine `anchorPoints` slots by their `point`; if two share - /// a `point`, the *last* wins (the client's `SetPoint 0x767c70` overwrites the slot). + /// Slotted by `point`, the later of two on one point winning (`SetPoint`, `0x767c70`). pub anchors: Vec, - /// Explicit width (`G+0x50`); **`0.0` ⇒ derive from opposing anchors** (the no-explicit-size - /// sentinel `DAT_007ffd74`, `layout.md`). + /// `0.0` is the no-size sentinel (`0x7ffd74`): the axis derives from the anchors. pub width: f32, - /// Explicit height (`G+0x54`); `0.0` ⇒ derive from opposing anchors. pub height: f32, - /// `layoutScale` = effective scale (`parentScale · ownScale`, ε-gated, `propagation.md`); the - /// px↔coord multiplier applied to offsets and the size span. Default `1.0`. + /// The effective scale, parent times own (`0x76ac90`), applied only to the anchor offsets and + /// the size span. pub scale: f32, - /// Clamp the assembled rect into `[0, extent]` per axis (`G` flags bit4, `layout.md` Assemble). + /// Clamp the rect into `[0, extent]` on each axis. pub clamp: bool, - /// Screen X extent, only consulted when `clamp` (client `0x41ae60` return, default `0.8`). + /// The clamp's X extent; the client's default is `0.8` (`0x41ae60`). pub extent_x: f32, - /// Screen Y extent, only consulted when `clamp` (client `0x41ae70` return, default `0.6`). + /// The clamp's Y extent; the client's default is `0.6` (`0x41ae70`). pub extent_y: f32, } @@ -238,32 +187,18 @@ impl LayoutInput { } } -/// The outcome of resolving one frame's rect. #[derive(Clone, Copy, Debug, PartialEq)] pub enum ResolveOutcome { - /// Every edge resolved → the cached rect `[bottom, left, top, right]` (`assemble` returned 1). Resolved(Rect), - /// At least one edge stayed `+Inf` — under-constrained (`assemble 0x767a20` returns 0, caller - /// sets flags bit3 "resolution-failed"). + /// An edge stayed `+Inf`: the client's `assemble` returns 0 and its caller sets flags bit 3. Unresolvable, } -// ───────────────────────────────────────────────────────────────────────────────────────────── -// Leaf float kernels — the owned-fidelity math (layout.md). Transcribed from w5875-ui::layout, -// binary-diffed. Each returns its coordinate in `f64` (the un-narrowed x87 `st0` observable); -// callers narrow to `f32` at the documented store sites. -// ───────────────────────────────────────────────────────────────────────────────────────────── +// ── Leaf kernels: each returns the un-narrowed `f64` ───────────────────────────────────────── -/// `combineEdge 0x767440(center, opp, span)` (`layout.md`): resolve one edge from the opposite -/// edge, the axis center, and the signed `size·scale` span, in that fallback order. -/// -/// - opposite known and non-zero span → `opp + span` (edge = opposite ± size·scale); -/// - else center known: non-zero span → `span·0.5 + center` (edge = center ± half-size); -/// else opposite known → `(center − opp) + center` (= 2·center − opposite, the mirror); -/// - else → `+Inf` (unresolvable). -/// -/// The `+Inf`/`0.0` sentinels are compared with IEEE equality, matching the binary's `fcomp` -/// against the `.data` slots (a NaN operand is unordered ⇒ "not equal to the sentinel"). +/// `combineEdge` (`0x767440`): an edge from the opposite edge and the signed `size * scale` span, +/// else from the axis center. The sentinels compare by IEEE equality, as the client's `fcomp` +/// does, so a NaN never matches one. #[allow(clippy::float_cmp)] pub fn combine_edge(center: f32, opp: f32, span: f32) -> f64 { let (c, o, s) = (f64::from(center), f64::from(opp), f64::from(span)); @@ -281,12 +216,7 @@ pub fn combine_edge(center: f32, opp: f32, span: f32) -> f64 { UNSET } -/// `combineCenter 0x7672d0(lo, hi, span)` (`layout.md`): resolve an axis center from the low edge, -/// the high edge, and the signed `size·scale` span. -/// -/// - low known: high known → `(lo+hi)·0.5`; else non-zero span → `span·0.5 + lo`; -/// - else high known and non-zero span → `hi − span·0.5`; -/// - else → `+Inf`. +/// `combineCenter` (`0x7672d0`): an axis center from the low and high edges and the signed span. #[allow(clippy::float_cmp)] pub fn combine_center(lo: f32, hi: f32, span: f32) -> f64 { let (l, h, s) = (f64::from(lo), f64::from(hi), f64::from(span)); @@ -304,16 +234,9 @@ pub fn combine_center(lo: f32, hi: f32, span: f32) -> f64 { UNSET } -/// `CAnchor::ResolveX 0x7a2f90(scale)` (`layout.md`): resolve this anchor's X coordinate against its -/// `relativeTo`'s cached rect. -/// -/// `rect` = the target's cached rect `[bottom, left, top, right]` when `GetRect` succeeded (cache -/// valid), `None` when it failed ⇒ `+Inf`. `mirror` = the target's geometry-vtable `+0x24` -/// normalize predicate (RTL/local-origin): when set, X edges are translated so `left → 0`, -/// `right → right − left` (each stored back as `f32`, the binary's `fstp m32`). For an ordinary -/// frame the predicate returns 0, so `mirror` is `false` (VERIFIED `0x46ff60 = xor eax,eax; ret`, -/// `rf32-composed-resolver.md`). `coord = xOff·scale + edge` for the relativePoint's column -/// (center = mid-X + xOff·scale). `rel_point > 8` ⇒ `+Inf`. +/// `CAnchor::ResolveX` (`0x7a2f90`): the anchor's X against its target's cached `rect`, which is +/// `None` when `GetRect` fails. `mirror` is the target's normalize predicate (vtable `+0x24`, 0 for +/// an ordinary frame at `0x46ff60`), which shifts the edges to start at 0, each stored as `f32`. pub fn anchor_resolve_x( scale: f32, x_off: f32, @@ -340,9 +263,7 @@ pub fn anchor_resolve_x( } } -/// `CAnchor::ResolveY 0x7a3070(scale)` — the Y twin of [`anchor_resolve_x`] (`layout.md`). `mirror` -/// normalizes the Y edges (`bottom → 0`, `top → top − bottom`); `coord = yOff·scale + edge` for the -/// relativePoint's row (center = mid-Y + yOff·scale). `rect` = `[bottom, left, top, right]`. +/// `CAnchor::ResolveY` (`0x7a3070`), the Y counterpart of [`anchor_resolve_x`]. pub fn anchor_resolve_y( scale: f32, y_off: f32, @@ -369,21 +290,16 @@ pub fn anchor_resolve_y( } } -/// The assembled (and possibly clamped) rect + whether every edge resolved. +/// [`assemble_rect`]'s result: `ok` when every edge resolved. #[derive(Clone, Copy, Debug, PartialEq)] pub struct AssembleRect { pub ok: bool, - /// `[bottom, left, top, right]` (meaningful only when `ok`). + /// `[bottom, left, top, right]`, meaningful only when `ok`. pub rect: [f32; 4], } -/// `assemble 0x767a20` (`layout.md`): given the four resolved edge coordinates `[bottom, left, top, -/// right]` (each `+Inf` ⇒ unresolvable), fail if any is `+Inf`; else optionally clamp into -/// `[0, extent]` per axis. -/// -/// Clamp (geom flags bit4): a low edge `< 0` shifts to 0 carrying the span to the high edge; a high -/// edge past the screen extent shifts back carrying to the low edge, high edge = extent. Each store -/// is narrowed to `f32`. +/// `assemble` (`0x767a20`): fails if any edge is `+Inf`, else with `clamp` (flags bit 4) shifts +/// the rect into `[0, extent]` on each axis, low edge first, keeping its size. #[allow(clippy::float_cmp)] pub fn assemble_rect(edges: [f32; 4], clamp: bool, extent_x: f32, extent_y: f32) -> AssembleRect { let [mut bottom, mut left, mut top, mut right] = edges; @@ -420,9 +336,8 @@ pub fn assemble_rect(edges: [f32; 4], clamp: bool, extent_x: f32, extent_y: f32) } } -/// `OnSizeChanged` fire test (`ApplyRect 0x76b580`, `layout.md`): fires iff `|Δwidth| ≥ [`SIZE_EPS`]` -/// OR `|Δheight| ≥ SIZE_EPS`, with `width = right − left`, `height = top − bottom`. Deltas are `f64` -/// of the `f32` edges (the binary's `fsub` of the `fld`'d rect components). +/// `ApplyRect`'s `OnSizeChanged` test (`0x76b580`): width or height moved by at least +/// [`SIZE_EPS`], the deltas taken in `f64` from the `f32` edges. pub fn size_changed(old: Rect, new: Rect) -> bool { let dw = (f64::from(new.right) - f64::from(new.left)) - (f64::from(old.right) - f64::from(old.left)); @@ -431,15 +346,9 @@ pub fn size_changed(old: Rect, new: Rect) -> bool { dw.abs() >= SIZE_EPS || dh.abs() >= SIZE_EPS } -// ───────────────────────────────────────────────────────────────────────────────────────────── -// The composed resolver — recursion-guarded per-edge sequencing over the leaves above. -// Transcribed from w5875-ui::layout (RF-0032, rf32-composed-resolver.md): bit-exact over the -// binary's `assemble 0x767a20` → six per-edge resolvers → anchor scans → leaf kernels. Adds NO new -// arithmetic — only integer control flow + the documented `fstp m32` `f32` narrowing at each site. -// ───────────────────────────────────────────────────────────────────────────────────────────── +// ── The composed resolver: `assemble` over six recursion-guarded edge resolvers ────────────── -/// A resolved anchor for the internal edge tree: like [`Anchor`] but with the target's rect already -/// looked up (`None` ⇒ target had no valid rect ⇒ `GetRect`-fail leg). +/// An [`Anchor`] with its target's rect looked up, `None` where `GetRect` would fail. #[derive(Clone, Copy)] struct AnchorRel { x_off: f32, @@ -449,8 +358,6 @@ struct AnchorRel { mirror: bool, } -/// Internal per-edge resolver input: the nine `anchorPoints` slots (by point-id) with rects -/// resolved, plus the size/scale/clamp fields. struct EdgeInput { anchors: [Option; 9], width: f32, @@ -461,7 +368,7 @@ struct EdgeInput { extent_y: f32, } -// recursion-guard bits (the binary's `G+0x28` bitfield, layout.md). +// The recursion-guard bits of the client's `G+0x28`. const G_LEFT: u32 = 0x01; const G_TOP: u32 = 0x02; const G_RIGHT: u32 = 0x04; @@ -469,7 +376,7 @@ const G_BOTTOM: u32 = 0x08; const G_XC: u32 = 0x10; const G_YC: u32 = 0x20; -// per-edge point-id scan tables (`.rdata` 0x81c3b8..0x81c3f4, rf32-composed-resolver.md). +// The point ids each edge scans (`.rdata` 0x81c3b8..0x81c3f4). const IDS_LEFT: [usize; 3] = [0, 3, 6]; const IDS_RIGHT: [usize; 3] = [2, 5, 8]; const IDS_TOP: [usize; 3] = [0, 1, 2]; @@ -477,8 +384,7 @@ const IDS_BOTTOM: [usize; 3] = [6, 7, 8]; const IDS_XC: [usize; 3] = [1, 4, 7]; const IDS_YC: [usize; 3] = [3, 4, 5]; -/// `anchorScanX 0x7671a0`: over the edge's three point-ids, the first present anchor whose -/// `ResolveX` is non-`+Inf` wins; else `+Inf`. +/// `anchorScanX` (`0x7671a0`): the first of the edge's anchors whose X resolves, else `+Inf`. #[allow(clippy::float_cmp)] fn anchor_scan_x(ids: &[usize; 3], f: &EdgeInput) -> f64 { for &id in ids { @@ -492,7 +398,7 @@ fn anchor_scan_x(ids: &[usize; 3], f: &EdgeInput) -> f64 { UNSET } -/// `anchorScanY 0x7671f0` — the Y twin. +/// `anchorScanY` (`0x7671f0`). #[allow(clippy::float_cmp)] fn anchor_scan_y(ids: &[usize; 3], f: &EdgeInput) -> f64 { for &id in ids { @@ -506,18 +412,10 @@ fn anchor_scan_y(ids: &[usize; 3], f: &EdgeInput) -> f64 { UNSET } -/// `width·scale` / `height·scale` — the `layout_767_span` PRIMITIVE (`rf32-composed-resolver.md`): -/// operands are `f32` fields, the product narrowed to `f32` at the `fstp [esp]` site, negated for -/// LEFT/BOTTOM. -/// -/// **`size` is not always the authored field, and this is the trap** (decision 1349, wow-re -/// `region-size-fallback.md`): each caller's `call [eax+0x1c]` is **virtual**. A plain frame lands on -/// `0x768420 fld [G+0x50]` — the flat read this signature assumes — but a `CSimpleTexture` lands on -/// `0x770720` and a `CSimpleFontString` on `0x772930`, both of which substitute a **content-derived** -/// extent when the authored value is exactly `0.0` (the texture's own texel size, one texel per -/// FrameXML unit; the string's measured text, floored at one unit). benilla feeds this the authored -/// number only, so a zero-authored *texture* axis yields a zero span here where the client yields a -/// real one. 1349 §4 carries the change and its scope; do not read this kernel as the whole law. +/// The edge resolvers' span, `size * scale` narrowed to `f32`, negated for LEFT and BOTTOM. The +/// client reads `size` through a virtual getter: a frame's is the authored field (`0x768420`), but +/// a texture (`0x770720`) and a font string (`0x772930`) replace an authored `0.0` with their +/// content's extent, so a region's [`LayoutInput`] must already carry that extent. fn size_span(size: f32, scale: f32, negate: bool) -> f32 { let span = (f64::from(size) * f64::from(scale)) as f32; if negate { @@ -527,7 +425,7 @@ fn size_span(size: f32, scale: f32, negate: bool) -> f32 { } } -/// `LEFT 0x7673d0` — direct anchor scan, else `combineEdge(Xcenter, RIGHT, −width·scale)`. +/// LEFT (`0x7673d0`): its anchor scan, else `combineEdge(Xcenter, RIGHT, -width * scale)`. #[allow(clippy::float_cmp)] fn resolve_left(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_LEFT != 0 { @@ -547,7 +445,7 @@ fn resolve_left(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `RIGHT 0x767540` — direct anchor scan, else `combineEdge(Xcenter, LEFT, +width·scale)`. +/// RIGHT (`0x767540`): its anchor scan, else `combineEdge(Xcenter, LEFT, width * scale)`. #[allow(clippy::float_cmp)] fn resolve_right(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_RIGHT != 0 { @@ -567,7 +465,7 @@ fn resolve_right(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `TOP 0x7674d0` — direct anchor scan, else `combineEdge(Ycenter, BOTTOM, +height·scale)`. +/// TOP (`0x7674d0`): its anchor scan, else `combineEdge(Ycenter, BOTTOM, height * scale)`. #[allow(clippy::float_cmp)] fn resolve_top(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_TOP != 0 { @@ -587,7 +485,7 @@ fn resolve_top(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `BOTTOM 0x7675b0` — direct anchor scan, else `combineEdge(Ycenter, TOP, −height·scale)`. +/// BOTTOM (`0x7675b0`): its anchor scan, else `combineEdge(Ycenter, TOP, -height * scale)`. #[allow(clippy::float_cmp)] fn resolve_bottom(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_BOTTOM != 0 { @@ -607,7 +505,7 @@ fn resolve_bottom(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `Xcenter 0x767260` — direct anchor scan, else `combineCenter(LEFT, RIGHT, +width·scale)`. +/// Xcenter (`0x767260`): its anchor scan, else `combineCenter(LEFT, RIGHT, width * scale)`. #[allow(clippy::float_cmp)] fn resolve_xcenter(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_XC != 0 { @@ -627,7 +525,7 @@ fn resolve_xcenter(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `Ycenter 0x767360` — direct anchor scan, else `combineCenter(BOTTOM, TOP, +height·scale)`. +/// Ycenter (`0x767360`): its anchor scan, else `combineCenter(BOTTOM, TOP, height * scale)`. #[allow(clippy::float_cmp)] fn resolve_ycenter(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_YC != 0 { @@ -647,12 +545,8 @@ fn resolve_ycenter(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `assemble 0x767a20` composed: resolve all four edges (each `fst`'d to the `f32` out-rect), -/// returning [`ResolveOutcome::Unresolvable`] if any stayed `+Inf`, else the assembled (and, when -/// `clamp`, screen-clamped) rect. The four top-level resolves share a fresh guard; the binary -/// clears each edge's bit on return, so order is immaterial — we keep the binary's LEFT→BOTTOM→ -/// RIGHT→TOP order. The `+Inf` test is on the un-narrowed `f64` edge (the binary's `fcomp` before -/// the `fst`). +/// `assemble` (`0x767a20`) composed, resolving LEFT, BOTTOM, RIGHT, TOP in the client's order; the +/// `+Inf` test reads the un-narrowed `f64` edge, as the client's `fcomp` precedes its `fst`. #[allow(clippy::float_cmp)] fn resolve_edges(f: &EdgeInput) -> ResolveOutcome { let mut guard = 0u32; @@ -668,13 +562,8 @@ fn resolve_edges(f: &EdgeInput) -> ResolveOutcome { ResolveOutcome::Resolved(Rect::from_array(a.rect)) } -/// Resolve one frame's rect. `target_rect` supplies each anchor target's already-resolved rect -/// (`None` ⇒ the target has no valid rect ⇒ that anchor contributes `+Inf`, the `GetRect`-fail -/// leg). Anchors are placed into the nine `anchorPoints` slots by their `point`; on a duplicate the -/// later anchor wins (the client's `SetPoint` overwrite). -/// -/// This is the fidelity surface: it builds the client's `anchorPoints[9]` view and runs the -/// verified composed `assemble` (`rf32-composed-resolver.md`). +/// Resolve one frame's rect; `target_rect` gives each anchor target's resolved rect, and `None` +/// makes that anchor `+Inf`, as a failed `GetRect` does. pub fn resolve_rect( frame: &LayoutInput, target_rect: impl FnMut(Handle) -> Option, @@ -682,9 +571,6 @@ pub fn resolve_rect( resolve_edges(&build_edge_input(frame, target_rect)) } -/// Build the client's `anchorPoints[9]` view + size/scale/clamp fields for `frame`, resolving each -/// anchor target's rect through `target_rect`. Shared by [`resolve_rect`] (all-or-nothing) and -/// [`resolve_rect_edges`] (per-edge). fn build_edge_input( frame: &LayoutInput, mut target_rect: impl FnMut(Handle) -> Option, @@ -696,8 +582,7 @@ fn build_edge_input( y_off: a.y_off, rel_point: a.relative_point.id() as u32, rel_rect: target_rect(a.relative_to).map(Rect::to_array), - // Ordinary frames' normalize predicate returns 0 (rf32-composed-resolver.md); RTL/mirror - // frames are not modeled here (the leaf `anchor_resolve_*` covers that leg). + // An ordinary frame's normalize predicate returns 0 (`0x46ff60`). mirror: false, }); } @@ -712,12 +597,8 @@ fn build_edge_input( } } -/// Resolve one frame's four edges, returning each as `Some(edge)` when the anchor graph + explicit -/// size determined it, or `None` when it stayed `+Inf` (unset). The per-edge view **regions** need: -/// a region inherits any edge its anchors + size don't pin from its owner frame (decision 0068's v1 -/// region model — see [`crate::script`]'s region resolution), so it must see *which* edges resolved, -/// not the all-or-nothing verdict [`resolve_rect`] gives. Order matches [`Rect`]: `[bottom, left, -/// top, right]`. No `assemble`/clamp is applied (regions never clamp to screen in v1). +/// Resolve one frame's edges, `[bottom, left, top, right]`, each `None` where it stayed unset; +/// `assemble` and its clamp are not applied. The region pass reads it axis by axis. #[allow(clippy::float_cmp)] pub fn resolve_rect_edges( frame: &LayoutInput, @@ -733,55 +614,27 @@ pub fn resolve_rect_edges( [cvt(bottom), cvt(left), cvt(top), cvt(right)] } -// ───────────────────────────────────────────────────────────────────────────────────────────── -// Graph driver — resolve a set of frames in dependency order (anchor targets before dependents), -// detecting cycles. This is benilla's orchestration, not client fidelity math: the client resolves -// lazily-on-read + per-frame-batched via a dependency-ordered deferred queue (layout.md "Resolve / -// dirty / cache flow"); we express the same dependency ordering as an explicit topological pass. -// -// ## Why a dense, reusable solver rather than a fresh map-backed graph -// -// The caller re-solves the whole widget set every round of its fixpoint, and the fixpoint runs -// every frame. A map-backed graph rebuilt per round paid, per round: one `LayoutInput` clone -// (heap `Vec`) per frame, a `BTreeMap` insert per node, and a `BTreeMap` probe per anchor -// target during the solve. Measured on the shipped default UI (1881 frames + 5478 regions, -// release): 1.36 ms per resolve, of which ~0.25 ms was graph construction and ~0.64 ms the solve -// itself — paid on a completely quiet frame. -// -// [`Handle`]s are the caller's own dense id space (benilla's `Model::next_id`, monotonic from 1 -// with frames and regions sharing the counter), so every per-node map collapses to an array index -// and every buffer can live across calls. `begin` clears only the slots the previous round -// touched, so a round costs O(nodes), never O(id space). Iteration order is kept **ascending by -// handle** — identical to the `BTreeMap` it replaces — because cycle members are resolved -// best-effort in that order and the result depends on it. -// ───────────────────────────────────────────────────────────────────────────────────────────── +// ── Graph driver ───────────────────────────────────────────────────────────────────────────── -/// A reusable dependency solver over a set of anchored frames plus *external* pre-resolved rects -/// (the screen root — the client's `CSimpleTop`, whose rect is the physical screen — and any fixed -/// targets, such as already-resolved regions). -/// -/// One solver is kept alive across frames so its buffers are reused. A round is: -/// [`begin`](Self::begin) → [`set_external`](Self::set_external) / -/// [`set_frame`](Self::set_frame) → [`solve`](Self::solve) → [`rect`](Self::rect). +/// Resolves anchored frames in dependency order, a driver of benilla's own: the client resolves +/// lazily through a dependency-ordered deferred queue (`0x7680e0`, ordered by `0x7681b0`). Handles +/// index its arrays directly, so they must be dense. A round: [`begin`](Self::begin), +/// [`set_external`](Self::set_external) and [`set_frame`](Self::set_frame), [`solve`](Self::solve). #[derive(Clone, Debug, Default)] pub struct LayoutSolver { - /// Per-handle layout input. Persistent storage: [`set_frame`](Self::set_frame) overwrites in - /// place so the anchors `Vec` keeps its allocation across rounds. + /// Overwritten in place, so each anchors `Vec` keeps its allocation. input: Vec, - /// Is `input[h]` a frame to solve this round? is_frame: Vec, - /// Per-handle rect: externals as seeded, frames as they resolve. `None` = not (yet) known. + /// Externals as seeded, frames as they resolve. rect: Vec>, - /// Kahn in-degree, and the reverse edges (each inner `Vec` is cleared, never freed). + /// Kahn in-degree and reverse edges; each inner `Vec` is cleared, never freed. indeg: Vec, dependents: Vec>, - /// Handles whose slots were written this round — `begin` resets exactly these. + /// The handles written this round, the ones `begin` resets. touched: Vec, - /// Is `h` already in `touched`? marked: Vec, - /// The frames to solve, ascending (the `BTreeMap` iteration order this replaces). + /// Ascending: cycle members resolve in this order, and the result depends on it. live: Vec, - /// Kahn scratch + output. queue: VecDeque, emitted: Vec, order: Vec, @@ -790,12 +643,10 @@ pub struct LayoutSolver { } impl LayoutSolver { - /// A new, empty solver. pub fn new() -> LayoutSolver { LayoutSolver::default() } - /// Grow the per-handle arrays to cover `h`. fn ensure(&mut self, h: Handle) { let need = h as usize + 1; if self.input.len() < need { @@ -809,7 +660,7 @@ impl LayoutSolver { } } - /// Mark `h` as written this round (so [`begin`](Self::begin) will reset its slot). + /// Record `h` for [`begin`](Self::begin) to reset. fn touch(&mut self, h: Handle) { let i = h as usize; if !self.marked[i] { @@ -818,7 +669,7 @@ impl LayoutSolver { } } - /// Start a round: reset every slot the previous round touched (O(nodes), not O(id space)). + /// Start a round, resetting only the slots the last round touched. pub fn begin(&mut self) { for &h in &self.touched { let i = h as usize; @@ -837,17 +688,15 @@ impl LayoutSolver { self.unresolvable.clear(); } - /// Seed a pre-resolved rect at `h` — an anchor target this solve does not compute (the screen - /// root, an already-resolved region). Also used to publish a rect mid-sweep so later nodes in - /// the same pass see it. + /// Seed a rect this solve does not compute, such as the screen root (`CSimpleTop`); also + /// publishes one mid-sweep for later nodes of the same pass. pub fn set_external(&mut self, h: Handle, rect: Rect) { self.ensure(h); self.touch(h); self.rect[h as usize] = Some(rect); } - /// Register a frame to solve, copying `src` into the solver's persistent slot (the anchors - /// `Vec` is refilled in place, so a steady round allocates nothing). + /// Register a frame to solve, copying `src` into its slot; a steady round allocates nothing. pub fn set_frame(&mut self, h: Handle, src: &LayoutInput) { self.ensure(h); self.touch(h); @@ -859,9 +708,7 @@ impl LayoutSolver { self.live.push(h); } - /// [`set_frame`](Self::set_frame) with `src`'s anchors replaced by exactly `anchor` — the - /// ScrollFrame child override (decision 0112), which otherwise cost a full input clone plus a - /// one-element `Vec` per scroll child per round. + /// [`set_frame`](Self::set_frame) with `anchor` as the only anchor: a ScrollFrame child's. pub fn set_frame_anchored(&mut self, h: Handle, src: &LayoutInput, anchor: Anchor) { self.ensure(h); self.touch(h); @@ -873,7 +720,6 @@ impl LayoutSolver { self.live.push(h); } - /// The non-anchor fields, copied verbatim. fn copy_fields(dst: &mut LayoutInput, src: &LayoutInput) { dst.width = src.width; dst.height = src.height; @@ -883,18 +729,13 @@ impl LayoutSolver { dst.extent_y = src.extent_y; } - /// Resolve every registered frame in dependency order (each anchor target before its - /// dependents), detecting cycles. Cycle members are still resolved best-effort, ascending by - /// handle, against whatever rects were available — matching the map-backed driver this - /// replaces. + /// Resolve every registered frame, anchor targets first; cycle members then resolve + /// best-effort, ascending by handle, against the rects known by then. pub fn solve(&mut self) { self.live.sort_unstable(); - // Frame→frame anchor edges. A dependency on an external or an unknown handle does not - // constrain ordering (its rect is fixed / missing, not produced by this pass); only - // frame→frame edges do. A self-anchor counts as an edge, so a self-referential frame is - // never emitted ⇒ reported as a cycle. Duplicate targets count once (an anchor slot set is - // at most 9, so the dedupe is a linear scan, not a set). + // Only frame-to-frame anchors order the pass. A self-anchor counts, so a self-anchored + // frame lands in `cycle`; a target named twice counts once. for idx in 0..self.live.len() { let h = self.live[idx]; let mut seen: [Handle; 9] = [0; 9]; @@ -939,7 +780,7 @@ impl LayoutSolver { self.resolve_one(h); } - // Frames never emitted are on (or behind) a cycle; resolve them best-effort, ascending. + // A frame never emitted is on or behind a cycle. for idx in 0..self.live.len() { let h = self.live[idx]; if !self.emitted[h as usize] { @@ -949,7 +790,6 @@ impl LayoutSolver { } } - /// Resolve one frame against the rects known so far, recording failure. fn resolve_one(&mut self, h: Handle) { let outcome = { let input = &self.input[h as usize]; @@ -962,20 +802,18 @@ impl LayoutSolver { } } - /// The rect known at `h`: an external as seeded, or a frame's resolved rect. `None` if the - /// handle is unknown or its frame was under-constrained. + /// The rect at `h`: an external as seeded, a frame as resolved. #[inline] pub fn rect(&self, h: Handle) -> Option { self.rect.get(h as usize).copied().flatten() } - /// Frames on (or behind) an anchor dependency cycle — no valid topological position. Ascending. + /// Frames on or behind an anchor cycle, ascending. pub fn cycle(&self) -> &[Handle] { &self.cycle } - /// Frames whose `assemble` returned unresolvable (under-constrained, or depending on an - /// unresolvable/missing target). May overlap [`cycle`](Self::cycle). + /// Frames that did not resolve; may overlap [`cycle`](Self::cycle). pub fn unresolvable(&self) -> &[Handle] { &self.unresolvable } @@ -985,10 +823,8 @@ impl LayoutSolver { mod tests { use super::*; - // Screen-root handle used across tests; its rect is the physical screen (y-up). const SCREEN: Handle = 0; fn screen_rect() -> Rect { - // [bottom, left, top, right] Rect::new(0.0, 0.0, 600.0, 800.0) } @@ -1010,13 +846,10 @@ mod tests { } // ── Leaf kernels ───────────────────────────────────────────────────────────────────────── - // Input probes transcribed from wow-5875-re/crates/ui/tests/difftest/layout.rs (the explicit - // per-leg probes; binary-diffed there). Expected outputs are hand-computed from the layout.md - // formulas — this crate has no WoW.exe emulator, so these are spec-derived, not binary-diffed. + // Expected values are hand-computed from the formulas, not taken from the client. #[test] fn combine_edge_legs() { - // (center, opp, span) assert_eq!(combine_edge(10.0, 50.0, 7.0), 57.0); // opp+span assert_eq!(combine_edge(10.0, f32::INFINITY, 7.0), 7.0 * 0.5 + 10.0); // span/2+center assert_eq!(combine_edge(10.0, 50.0, 0.0), (10.0 - 50.0) + 10.0); // 2c-opp = -30 @@ -1047,14 +880,13 @@ mod tests { #[test] fn anchor_resolve_x_columns() { let r = [3.0f32, 100.0, 480.0, 260.0]; // [bottom, left, top, right] - // relLeft, center, relRight (point-ids 0,1,2), scale 1, xOff 5 + // point ids 0, 1, 2 (left, center, right), xOff 5 assert_eq!(anchor_resolve_x(1.0, 5.0, 0, Some(r), false), 5.0 + 100.0); assert_eq!( anchor_resolve_x(1.0, 5.0, 1, Some(r), false), (260.0 + 100.0) * 0.5 + 5.0 ); assert_eq!(anchor_resolve_x(1.0, 5.0, 2, Some(r), false), 5.0 + 260.0); - // scale multiplies the offset assert_eq!( anchor_resolve_x(2.0, 5.0, 0, Some(r), false), 2.0 * 5.0 + 100.0 @@ -1116,8 +948,7 @@ mod tests { // ── Composed resolver ───────────────────────────────────────────────────────────────────── - /// VERIFIED numeric vector from wow-5875-re/system/ui/scratch/rf32-composed-resolver.md - /// "Worked oracle (probe 1)" — binary-diffed there against WoW.exe's `assemble 0x767a20`. + /// The rect the running reference client's `assemble` (`0x767a20`) produced. #[test] fn oracle_single_topleft_explicit_size() { let f = LayoutInput::sized( @@ -1131,16 +962,14 @@ mod tests { 200.0, 50.0, ); - // screen root [0,0,600,800] (probe uses root top=600, right=800) + // The reference run's screen root: top 600, right 800. let got = resolve_rect(&f, |_| Some(Rect::new(0.0, 0.0, 600.0, 800.0))); assert_rect(got, Rect::new(545.0, 10.0, 595.0, 210.0)); } #[test] fn single_anchor_all_nine_points_explicit_size() { - // For each own-point p, anchor p -> the same point on screen, xOff/yOff 0, w100 h40. - // The anchored corner/edge pins directly; the rest derive from size. We assert the pinned - // coordinate and the resulting width/height, computed by hand from layout.md. + // Each point pinned to the same point on the screen: every leg must keep the 100x40 size. let screen = screen_rect(); for &p in &[ Point::TopLeft, @@ -1159,7 +988,6 @@ mod tests { ResolveOutcome::Resolved(r) => r, ResolveOutcome::Unresolvable => panic!("{p:?} unresolvable"), }; - // width/height always preserved assert_eq!(r.width().to_bits(), 100.0f32.to_bits(), "{p:?} width"); assert_eq!(r.height().to_bits(), 40.0f32.to_bits(), "{p:?} height"); } @@ -1195,7 +1023,6 @@ mod tests { #[test] fn scale_interaction() { - // scale 2 -> offsets and size span doubled. let f = LayoutInput { anchors: vec![Anchor::new( Point::TopLeft, @@ -1271,8 +1098,7 @@ mod tests { #[test] fn unset_sentinel_propagates() { - // Anchor to a target with no valid rect (GetRect fail) -> that edge is +Inf -> frame - // unresolvable (single anchor, so opposite edges can't derive). + // The one anchor's target has no rect, so its edges are +Inf and nothing else pins them. let f = LayoutInput::sized( vec![Anchor::new( Point::TopLeft, @@ -1335,7 +1161,6 @@ mod tests { s.solve(); assert!(s.cycle().contains(&A)); assert!(s.cycle().contains(&B)); - // neither can resolve (each depends on the other's rect, unavailable) assert!(s.unresolvable().contains(&A)); assert!(s.unresolvable().contains(&B)); } @@ -1360,18 +1185,15 @@ mod tests { #[test] fn size_changed_epsilon() { let r = Rect::new(0.0, 0.0, 40.0, 100.0); - // sub-epsilon width change -> not fired + // 1e-7 is under SIZE_EPS. let tiny = Rect::new(0.0, 0.0, 40.0, 100.0 + 1e-7); assert!(!size_changed(r, tiny)); - // supra-epsilon -> fired let big = Rect::new(0.0, 0.0, 40.0, 100.001); assert!(size_changed(r, big)); } - /// The solver's buffers live across rounds, so `begin` must leave NO trace of the previous - /// one — a stale rect, in-degree, dependent edge, or `is_frame` flag would silently corrupt - /// the next solve. Round 2 registers a *disjoint* frame set that anchors to a handle round 1 - /// used, and must see it as unknown (not round 1's rect). + /// The buffers live across rounds, so `begin` must leave nothing behind: round 2 anchors to a + /// handle only round 1 registered and must find it unknown. #[test] fn begin_leaves_no_state_from_the_previous_round() { const A: Handle = 1; @@ -1407,8 +1229,7 @@ mod tests { let a_first = s.rect(A).expect("A resolved in round 1"); assert!(s.rect(B).is_some()); - // Round 2: ONLY B, still anchored to A — but A is no longer registered and no external - // seeds it, so B must be unresolvable rather than reusing round 1's stale A rect. + // Round 2: only B, whose target A is no longer registered. s.begin(); s.set_external(SCREEN, screen_rect()); s.set_frame( @@ -1431,8 +1252,7 @@ mod tests { assert!(s.unresolvable().contains(&B)); assert!(s.cycle().is_empty(), "cycle list must be cleared too"); - // Round 3: the original pair again — identical results to round 1 (no accumulated - // in-degree or dependent edges from the rounds between). + // Round 3: the original pair again, which must match round 1. s.begin(); s.set_external(SCREEN, screen_rect()); s.set_frame( @@ -1465,9 +1285,8 @@ mod tests { assert!(s.unresolvable().is_empty()); } - /// A frame registered with the ScrollFrame override keeps its size/scale but drops its own - /// anchors for exactly the override (decision 0112) — and the slot's anchor `Vec` is reused, - /// so the previous round's anchors must not leak through. + /// The override keeps the size and scale but replaces the anchors, and the reused anchors `Vec` + /// must not leak the last round's. #[test] fn set_frame_anchored_replaces_only_the_anchors() { const A: Handle = 1; @@ -1491,8 +1310,7 @@ mod tests { bits(Rect::new(0.0, 0.0, 600.0, 800.0)) ); - // Overridden: the two authored anchors are gone, replaced by one TOPLEFT pin at (10, -5), - // so the explicit 100x40 size now decides the other two edges. + // Overridden: one TOPLEFT pin at (10, -5), so the 100x40 size decides the other edges. s.begin(); s.set_external(SCREEN, screen_rect()); s.set_frame_anchored( diff --git a/crates/benilla-ui/src/lib.rs b/crates/benilla-ui/src/lib.rs index 4bcb1add..4af31f01 100644 --- a/crates/benilla-ui/src/lib.rs +++ b/crates/benilla-ui/src/lib.rs @@ -1,25 +1,5 @@ -//! The engine-free core of benilla's UI engine (decision 0068): the WoW interface is data + a -//! runtime — TOC manifests, FrameXML documents, and Lua — and this crate owns everything about -//! that runtime which needs neither Bevy nor a GPU: parsing, template expansion, the anchor/layout -//! resolver, and the draw-order key. The Bevy side (rendering, input, the game-state API bindings) -//! lives in the app; the Lua VM is embedded by the app and calls back into this crate's model. -//! -//! Built currently: [`toc`] — the addon/FrameXML manifest parser; [`framexml`] — the FrameXML -//! document parser (order-preserving tree, template expansion, `$parent` substitution); -//! [`bindings_xml`] — the per-addon `Bindings.xml` parser (the third file format, feeding -//! [`script::keybind`]'s table); [`layout`] -//! — the anchor/layout resolver (anchor graph → resolved rects), transcribed bit-faithfully from -//! wow-5875-re's binary-verified spec; [`widget`] — the frame arena and its show/hide/strata/level/ -//! scale/alpha propagation mutations; [`order`] — the strata/draw-layer vocabulary, the packed -//! total-order key, and the visible-tree traversal that realizes the client's painter order; -//! [`script`] — the engine-free Lua host (mlua 5.1) binding the arena/layout/order model to the -//! FrameScript object model + WoW stdlib so FrameXML/addon Lua runs against it (decision 0068); -//! [`loader`] — the FrameXML loader that joins the two, walking a parsed document to materialize live -//! frames in a running [`script::UiScript`] by driving the object model exactly as an addon does; -//! [`markup`] — the single owner of WoW's inline-markup grammar (`|cAARRGGBB`, `|H…|h…|h`, `|n`, -//! `||`): the client's token decoder and the per-byte class map its cursor law is built on; -//! [`source`] — what a UI source file *is* before any parser sees it: bytes, BOM-stripped, decoded -//! to text only where a Rust parser forces it (decision 1193). +//! The engine-free core of benilla's UI: the `.toc`, FrameXML and `Bindings.xml` parsers, the +//! layout resolver, the frame arena, draw order and the Lua host FrameXML runs in; no Bevy, no GPU. pub mod bindings_xml; pub mod civil; diff --git a/crates/benilla-ui/src/loader/backdrop.rs b/crates/benilla-ui/src/loader/backdrop.rs index ea6c70d8..9b22381a 100644 --- a/crates/benilla-ui/src/loader/backdrop.rs +++ b/crates/benilla-ui/src/loader/backdrop.rs @@ -5,11 +5,9 @@ use crate::framexml::Element; use super::{abs_value, children_named, color_of, Loader}; impl Loader<'_> { - /// `` + children ``/``/``/ - /// ``/`` (rf24 LoadXML `0x77e6c0`; backdrop-mechanism.md §1). Builds the same - /// table Lua `SetBackdrop` reads and installs it, then applies the two colors — the XML `` - /// parser defaults a *present* element's missing channels to **black** (r=g=b=0, a=1 via - /// [`color_of`]), distinct from the ctor white a table `SetBackdrop` leaves. + /// `` (LoadXML `0x77e6c0`): the table Lua `SetBackdrop` takes, then the two colours. + /// A present `` defaults a missing channel to black, alpha 1 (`0x6f23d0`), not the + /// white a table `SetBackdrop` leaves. pub(super) fn apply_backdrop(&mut self, el: &Element, wrapper: &Table, dbg: &str) { let Some(bd) = children_named(el, "Backdrop").next() else { return; @@ -32,10 +30,8 @@ impl Loader<'_> { } } - /// Build the `SetBackdrop` table from a `` element: `bgFile`/`edgeFile`/`tile` attrs, - /// ``/`` scalars ([`abs_value`]), and `` (an `` - /// child or inline `left`/`right`/`top`/`bottom` attrs). Missing keys are simply absent — the - /// object-model `SetBackdrop` leaves the ctor default (tileSize 0, edgeSize 32, insets 0). + /// The `SetBackdrop` table for a ``. A key the element omits stays absent, so + /// `SetBackdrop` keeps the ctor's default (tileSize 0, edgeSize 32, insets 0). pub(super) fn build_backdrop_table(&self, bd: &Element) -> mlua::Result { let t = self.lua().create_table()?; if let Some(f) = bd.attr("bgFile") { diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index df2d2a7f..84d3a756 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -6,35 +6,15 @@ use super::regions::FontAttrs; use super::{abs_dim, children_named, Loader}; impl Loader<'_> { - /// LoadXML attribute handling (rf24 `0x769820`): the subset the v1 object model exposes, plus a - /// warn-once for the attributes whose methods don't exist yet (a next-phase gap, not a workaround). + /// A frame element's LoadXML attributes and simple children (`0x769820`), and a model pane's + /// own (`0x76cac0`). pub(super) fn apply_attrs(&mut self, el: &Element, wrapper: &Table, dbg: &str) { if el.attr_bool("hidden") { self.call(wrapper, "Hide", (), dbg); } - // **An unrecognised `frameStrata=` WARNS and skips; it does not raise** (decision 2160). - // - // The two doors are genuinely different in the reference and this is the one that is - // quiet. `CSimpleFrame::LoadXML 0x769820` resolves the name through `0x6f17d0`, whose miss - // arm returns `eax = 0` without writing the caller's out-parameter (`0x6f17ff`); the miss - // leg then pushes `0x878658 "Frame %s: Unknown frame strata: %s"` at severity 1 into the - // document's `CStatus` sink (`0x7699a4 call [edx+0xc]`, which returns — all five `Add` - // implementations image-wide are raise-free) and **reconverges with the hit path at - // `0x7699ad`**, carrying straight on into `frameLevel` and the rest of the subtree. - // `SetFrameStrata 0x76a470` is called only on the hit arm (`0x769971`), so the frame keeps - // whatever stratum it already had — its template's, its parent's, or the ctor's MEDIUM - // (`[+0xc0] = 3` at `0x7690c2`). - // - // The **Lua** binding is the loud one and stays as it is: `0x774360`'s miss reaches - // `0x774456 call 0x6f4940` (`luaL_error`), whose callee chain `luaG_errormsg 0x6fc780` / - // `luaD_throw 0x6f5d80` contains no `ret` at all — the epilogue after it is dead code. - // - // (wow-re `ui/scratch/frame-strata-miss-law.md`, §5 trio + orchestrator arbitration.) - // - // Resolving here rather than letting `SetFrameStrata` raise is what reproduces that split: - // routed through `self.call`, a bad value became a `report.errors` row, which is a script - // error the player sees — and it cost `EQL3` its whole `EQL3_Log.xml`, on a `` (a draw-LAYER name) the real client shrugs at. + // An unknown `frameStrata=` is logged (`0x7699a4`) and skipped, not raised as the Lua + // binding would (`0x774360`): `SetFrameStrata` runs only on a hit (`0x769971`), so the + // frame keeps the strata it had. if let Some(strata) = el.attr("frameStrata") { if crate::script::object::strata_from_str(strata).is_some() { self.call(wrapper, "SetFrameStrata", strata.to_string(), dbg); @@ -54,20 +34,8 @@ impl Loader<'_> { self.call(wrapper, "SetAlpha", a, dbg); } } - // `scale=` on a MODEL pane is the model's own scale, not the frame's: `CSimpleModel:: - // LoadXML` (`0x76cac0`) writes it into `+0x3a0` at `76cb61` — the field `SetModelScale` - // writes — and raises `Frame %s: Invalid model scale: %s` at `76cb92` for `≤ 0` (a raise, - // not a clamp; wow-re `modelframe-render-law.md` §2). Every `scale=` in the shipped - // FrameXML sits on a model pane (the cooldown indicator's 0.75, the autocast shine's - // 1.2/1.22, the pings' 0.4, the dressing room's 2.0), and until decision 2007 the loader - // read none of them. Whether the generic frame loader (`0x769820`) reads a `scale` - // attribute of its own is not carved; a plain frame's `scale=` is left as it was. - // `file=` on a model pane is `SetModel` (`CSimpleModel::LoadXML` `0x76cac0` installs the - // file into the widget, resident or streaming — decision 2013). Until 2013 no XML-declared - // pane ever held a file: the loader read `file=` only to turn the cooldown indicator's - // pane into a native widget of ours (retired by 2019), and the pings, the shine and the - // item card were bare panes to the engine. + // A model pane's `file=` is `SetModel` (`0x76cac0`). let model_kind = super::model_kind_tag(&el.tag); if model_kind { if let Some(file) = el.attr("file") { @@ -75,6 +43,9 @@ impl Loader<'_> { self.call(wrapper, "SetModel", text, dbg); } } + // A model pane's `scale=` is the model's scale, the field `SetModelScale` writes + // (`0x76cb61`); a value of 0 or less is refused, never clamped (`0x76cb92`). A plain + // frame's `scale=` is ignored; whether `0x769820` reads one is untraced. if let Some(scale) = el.attr("scale") { if model_kind { match scale.trim().parse::() { @@ -86,12 +57,8 @@ impl Loader<'_> { } } } - // The model pane's own fog attributes and `` child (`CSimpleModel::LoadXML` - // `0x76cac0`, render law §5.4). `fogNear`/`fogFar` are **clamped at `≥ 0`** here and only - // here (`76cbbb`-`76cbd2` / `76cbf3`-`76cc0a`: `0.0 fcomp value ; jne store ; else store - // 0.0`) — the Lua setters store raw. The `` child writes the packed colour AND - // arms the fog bit, so it is `SetFogColor` in every respect; nothing in XML touches the - // light. Decision 2027. + // A model pane's fog: `fogNear`/`fogFar` clamp at 0 here only (`0x76cbbb`, `0x76cbf3`), + // where the Lua setters store raw; `` is `SetFogColor`, fog bit included. if model_kind { for (attr, verb) in [("fogNear", "SetFogNear"), ("fogFar", "SetFogFar")] { if let Some(v) = el.attr(attr).and_then(|v| v.trim().parse::().ok()) { @@ -117,22 +84,14 @@ impl Loader<'_> { self.call(wrapper, "SetID", n, dbg); } } - // `clampedToScreen="true"` → SetClampedToScreen (rf24 `0x768cc0` — geometry flags bit4, - // the layout resolve's screen clamp; GameTooltip frames carry it by construction). + // `clampedToScreen`: the layout's screen clamp, geometry flag bit 4 (`0x768cc0`). if el.attr_bool("clampedToScreen") { self.call(wrapper, "SetClampedToScreen", true, dbg); } - // `enableMouse` lands as the real EnableMouse call (the hit test gates on it — - // pointer.rs): a ref frame authored mouse-blocking (StaticPopup, BlackoutWorld, the - // minimap cluster) must actually capture. The loader ignored it for a while after the - // native landed — the stale "v1 gap" note here — which left those frames click-through - // and the minimap deaf to its ping click (0434 phase 6c's root cause). if el.attr_bool("enableMouse") { self.call(wrapper, "EnableMouse", true, dbg); } - // `` (also accepted - // inline on the element) → SetHitRectInsets: the frame's MOUSE rect, inset from its - // resolved rect. Absent sides read 0, so a partial element insets only what it names. + // ``: the mouse rect, inset from the frame's; a side it omits is 0. if let Some(ins) = children_named(el, "HitRectInsets").next() { let src = children_named(ins, "AbsInset").next().unwrap_or(ins); let side = |k: &str| { @@ -147,70 +106,33 @@ impl Loader<'_> { dbg, ); } - // `…` → the resize - // quad (rf24 `0x769820`'s child-loop arm at `0x769baa`; wow-re - // `system/ui/scratch/resize-bounds-and-button-fontstring.md` §4). Three clauses from that - // carve rather than from the shape of the element: - // - // · **Both pairs are written unconditionally once `` matches**, each side - // defaulting to `0` before its lookup — so a block carrying only `` RESETS - // the max pair to unbounded. That is why this writes both calls, never just the one it - // found. - // · `x` is the WIDTH and `y` the HEIGHT, through the same logical→internal transform - // `` uses — the same `abs_dim` reader, and the same inline-or-`AbsDimension` pair - // of forms. - // · `` and `` are consumed by the base-class `CLayoutFrame::LoadXML` - // *before* the child loop is entered at all, so they always precede this structurally - // whatever the document order — and neither path clamps, so an authored `` - // outside the authored bounds survives load unchanged. - // - // The shipped 1.12.1 FrameXML uses it exactly once (`FloatingChatFrame.xml:223`, - // min 296×75 / max 608×400) and never calls the Lua setters at all. + // `` (`0x769baa`): both pairs are always written, an absent one as 0, so a + // lone `` resets the max to unbounded. A `` outside them is not clamped. if let Some(rb) = children_named(el, "ResizeBounds").next() { for (tag, verb) in [("minResize", "SetMinResize"), ("maxResize", "SetMaxResize")] { let (x, y) = children_named(rb, tag).next().map_or((None, None), abs_dim); self.call(wrapper, verb, (x.unwrap_or(0.0), y.unwrap_or(0.0)), dbg); } } - // `movable`/`resizable` → SetMovable/SetResizable — the same flag word the methods write - // (`0x76a3c0` with mask 0x100 / 0x200; wow-re `rf24-framexml-loader.md` records the loader - // calling that very setter). Both were in the gap list below until the movable family - // landed; leaving them there would have left every `movable="true"` reference window - // undraggable while `SetMovable` worked from Lua. + // `movable`/`resizable`: flag bits 0x100/0x200, via the setter Lua uses (`0x76a3c0`). if el.attr_bool("movable") { self.call(wrapper, "SetMovable", true, dbg); } if el.attr_bool("resizable") { self.call(wrapper, "SetResizable", true, dbg); } - // `toplevel` → SetToplevel — the third bit of that same flag word (`0x76a3c0` mask `0x1`, - // XML site `0x7698ec`; wow-re `ui/scratch/toplevel-raise.md`), and the third attribute to - // graduate out of the gap list below for the same reason: the raise law is built - // (`script::object::toplevel`), so accepting the attribute now means the behaviour, not - // silence. 82 corpus addons and thirteen of our own frames declare it. + // `toplevel`: bit 0x1 of the same flag word (`0x7698ec`). if el.attr_bool("toplevel") { self.call(wrapper, "SetToplevel", true, dbg); } - // `enableKeyboard="true"` — the XML half of the flag, which enables BOTH key kinds - // (`scripts-auto-enable.md` §1-2). The flag is real, and `script::keyboard`'s delivery - // walk is what reads it (1319). + // `enableKeyboard` enables both key kinds (`0x769ae8`, `0x769af3`). if el.attr_bool("enableKeyboard") { self.call(wrapper, "EnableKeyboard", true, dbg); } } - /// `` → SetWidth/SetHeight (rf24 `0x767800`). Accepts either `` - /// or the inline `` form; a dimension that's absent is left untouched (the client's - /// "0 = derive"). ALL `` children apply, in document order — template expansion appends the - /// instance's children after the template's ([`crate::framexml::expand`]), and the client simply - /// processes each child in turn, so an instance's own `` overwrites its template's. (Taking - /// only `.next()` here silently pinned every templated frame to the TEMPLATE's size; caught on - /// the quest log's Abandon button — 125×21 in the instance XML, 80×22 on screen.) - /// `` (or one with its own ``/``): the - /// frame's drag handle, built through the same `CreateTitleRegion` the Lua API exposes — so - /// the element and a later `frame:CreateTitleRegion()` name ONE object (the verb is - /// idempotent; wow-re `widget-api-batch-benilla.md` Q6) — then laid out like any region. - /// The stock `TutorialFrame.xml` declares one over its whole plate (1976). + /// ``: the drag handle, built through the idempotent `CreateTitleRegion` so the + /// element and a later Lua call name one object (`0x769b2a` takes `0x773910`'s path). pub(super) fn apply_title_region( &mut self, el: &Element, @@ -233,6 +155,8 @@ impl Loader<'_> { self.apply_region_layout(tr, ®ion, self_name, dbg, FontAttrs::Own); } + /// `` (`0x767800`). Every one applies in document order, and expansion puts an + /// instance's children after its template's, so the instance's own `` wins. pub(super) fn apply_size(&mut self, el: &Element, wrapper: &Table, dbg: &str) { for size in children_named(el, "Size") { let (x, y) = abs_dim(size); @@ -245,12 +169,9 @@ impl Loader<'_> { } } - /// `` → SetPoint per `` - /// (rf24 `0x767800`). `relativePoint` defaults to `point`; `relativeTo` is `$parent`-substituted - /// (rf27) and passed by name (the object model resolves it, falling back to the parent when - /// absent/unresolved); a missing `point` is skipped with a warning ("Invalid anchor point"). - /// The `setAllPoints="true"` shorthand applies first, like the region path — a frame carrying - /// only the attribute (WorldMapFrame's chrome layers) pins TOPLEFT+BOTTOMRIGHT to its parent. + /// `` (`0x767800`), after the `setAllPoints` shorthand: `relativePoint` defaults to + /// `point`, and `relativeTo` expands `$parent` to the nearest named ancestor's name, else + /// `Top` (`0x76c5b0`). pub(super) fn apply_anchors( &mut self, el: &Element, @@ -290,8 +211,7 @@ impl Loader<'_> { args, dbg: dbg.to_string(), }; - // The XML path's own law, which resolves the name itself and defers a target - // the enclosing frame's subtree has not built yet (`Loader::apply_anchor`). + // Deferred while the enclosing subtree has not built its target yet. self.apply_anchor(d, true); } } diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index 2b462226..435bbff9 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -1,58 +1,12 @@ -//! The FrameXML **loader** (decision 0068): the join between the document layer ([`crate::framexml`]) -//! and the runtime ([`crate::script`]). It walks a parsed FrameXML document and *materializes* it — -//! turning ``/`