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")
-
+
-
+
@@ -2755,13 +1954,8 @@
-
+
@@ -2772,17 +1966,14 @@
-
+
-
+
-
+
-
+
@@ -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 @@
-
+
@@ -5156,24 +3807,12 @@
-
+
-
+
@@ -5199,8 +3838,7 @@
-
+
@@ -5211,8 +3849,7 @@
-
+
-
if this:GetText() == "" then
BenillaOptionsFrameSearchBoxInstructions:Show()
@@ -5252,8 +3888,7 @@
-
+
-
+
-
+
BenillaOptionsFrame_UpdateScale()
BenillaOptionsFrame_SelectCategory(this.selectedCategory or "Controls")
if UpdateMicroButtons then UpdateMicroButtons() end
-
+
BenillaOptionsFrameSearchBox:SetText("")
OptionsPage_DiscardPending()
@@ -5299,70 +3926,16 @@
-
+
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 @@
-
+
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 @@