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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-15 15:01:14 -06:00
co-authored by Claude Opus 5
parent 706cd717a3
commit 81d96af35d
1019 changed files with 85223 additions and 130908 deletions
+5 -3
View File
@@ -50,9 +50,10 @@ awk -F'\t' -v ref="$REF" -v show_missing="$show_missing" -v show_beyond="$show_b
if (o == "engine" || o == "lua") have++
else if (o == "framexml") transcribable[$1] = 1
else {
# Categorize the superset. Only the last bucket is an API-target question; the first is
# benilla being benilla, and the second is our Lua runtime being 5.1 where 1.12 is 5.0.
# Categorize the superset. Only the last bucket is an API-target question; the first two are
# benilla being benilla, and the third is our Lua runtime being 5.1 where 1.12 is 5.0.
if ($1 ~ /^Benilla/) bridge[$1] = 1
else if ($1 ~ /^__/) internal[$1] = 1
else if ($1 ~ /^(_G|_VERSION|coroutine|print|select)$/) lua51[$1] = 1
else api[$1] = 1
}
@@ -65,7 +66,7 @@ awk -F'\t' -v ref="$REF" -v show_missing="$show_missing" -v show_beyond="$show_b
n_ref["engine"] + n_ref["framexml"] + n_ref["lua"]
printf " engine %5d functions, %5d other <- benilla implements these in Rust\n", \
n_ref_fn["engine"], n_ref["engine"] - n_ref_fn["engine"]
printf " framexml %5d functions, %5d other <- the stock FrameXML defines these\n", \
printf " framexml %5d functions, %5d other <- benilla transcribes these into assets/ui\n", \
n_ref_fn["framexml"], n_ref["framexml"] - n_ref_fn["framexml"]
printf " lua %5d functions, %5d other <- mlua provides these\n", \
n_ref_fn["lua"], n_ref["lua"] - n_ref_fn["lua"]
@@ -77,6 +78,7 @@ awk -F'\t' -v ref="$REF" -v show_missing="$show_missing" -v show_beyond="$show_b
printf "\nbeyond 1.12, by kind — every one of these is a deliberate exception or a bug:\n"
printf " %3d benilla host bridge Benilla*, called only by our own FrameXML\n", length(bridge)
printf " %3d VM internals __benilla_*, pushed by the tick\n", length(internal)
printf " %3d Lua 5.1 past 1.12'\''s 5.0 ", length(lua51); dump(lua51, "")
printf " %3d WoW API past 1.12 the phase-5 list\n", length(api)
printf " %3d ours in Rust that 1.12 defines in FrameXML\n", length(transcribable)
+3 -4
View File
@@ -95,7 +95,7 @@ esac
pflags=""
for p in $pkgs; do pflags="$pflags -p $p"; done
echo "check: scope = $pkgs"
echo "check: (changed crates + everything that depends on them; the full chain runs once, at land)"
echo "check: (changed crates + everything that depends on them; full chain still runs at sync→land)"
log="$(mktemp "${TMPDIR:-/tmp}/benilla-check.XXXXXX")"
skips="$(mktemp "${TMPDIR:-/tmp}/benilla-check-skips.XXXXXX")"
@@ -113,8 +113,7 @@ run() {
run fmt cargo fmt --all -- --check
run clippy cargo clippy $pflags --all-targets -- -D warnings
# --no-fail-fast: one red test binary does not hide the failures in the ones after it.
run test env BENILLA_SKIP_LOG="$skips" cargo test --no-fail-fast $pflags
run test env BENILLA_SKIP_LOG="$skips" cargo test $pflags
# Data-gated tests skip without the install or the addon corpus and libtest swallows their line,
# so each skip is also logged to $BENILLA_SKIP_LOG and counted here.
if [ -s "$skips" ]; then
@@ -124,4 +123,4 @@ if [ -s "$skips" ]; then
fi
echo "CHECK GREEN (scoped: $pkgs)"
echo " (the full chain, scripts/gates.sh, runs once on the tree that lands; a run before it repeats it)"
echo " (land still pays the full chain once: scripts/gates.sh — memoized, so an already-gated tree is free)"
+14 -18
View File
@@ -1,12 +1,11 @@
#!/usr/bin/env bash
# The land gate chain: every gate in order, stopping at the first failure.
#
# fmt, clippy, test, test-no-install, doc-links, pass-span-lint, player-tests, enforcer,
# enforcer-no-install. A failure prints the gate's log tail and exits nonzero; a green
# fmt, clippy, test, test-no-install, doc-links, pass-span-lint, player-build, player-tests,
# enforcer, enforcer-no-install. A failure prints the gate's log tail and exits nonzero; a green
# run stamps the tree, which then re-greens without running. GATES_FORCE=1 runs the chain anyway,
# WOW_DATA picks the install (set and empty: none) and TMPDIR holds the scratch files; the script
# sets BENILLA_REQUIRE_DATA, BENILLA_ADDON_CORPUS, BENILLA_SKIP_LOG and WOW_WORLDVIEW_CHECK for
# its gates.
# sets BENILLA_REQUIRE_DATA, BENILLA_SKIP_LOG and WOW_WORLDVIEW_CHECK for its gates.
# `.github/workflows/ci.yml` runs the gates that need neither the install nor a display on a fork's
# pull request: a gate added here goes there too, if it can run on a bare Linux runner.
#
@@ -123,12 +122,12 @@ else
"corpus=$([ -d "$root/wow-addons-vanilla" ] && echo yes || echo no)):" \
"data-gated tests skip silently on this machine"
fi
run test env ${require_data:+BENILLA_REQUIRE_DATA=1} BENILLA_SKIP_LOG="$skips" cargo test --no-fail-fast --workspace
run test env ${require_data:+BENILLA_REQUIRE_DATA=1} BENILLA_SKIP_LOG="$skips" cargo test --workspace
report_skips test
# test-no-install: refuses a test that reads the install without `wow_data_or_skip!`. `WOW_DATA=`
# (set, empty) is the resolver's "no install"; no build reads it, so the test binaries are reused.
run test-no-install env -u BENILLA_REQUIRE_DATA WOW_DATA= cargo test --no-fail-fast --workspace
run test-no-install env -u BENILLA_REQUIRE_DATA WOW_DATA= cargo test --workspace
# doc-links: refuses a doc link to a `crate::`, `super::`, `self::` or `Self::` path whose leaf is
# declared nowhere in the workspace. Narrow on purpose: every other unresolved link is a convention.
@@ -138,18 +137,15 @@ run doc-links scripts/doc-links.py
# wgpu validation error on Vulkan and a silent no-op on Metal and DX12, which no macOS run sees.
run pass-span-lint scripts/pass-span-lint.py
# player-tests: the player build, `benilla-app` without `dev`, which compiles out the debug panel,
# perf HUD, inspector, capture harness and probes, so it fails when any other code names one of
# them. Its test build compiles every line of the player graph but the `benilla` launcher, a shim
# with no `dev` seam, so a `cargo build -p benilla --no-default-features` would add only the shim
# and a link. It runs the unit tests of the `cfg(not(feature = "dev"))` code (the resolver skips
# the source tree; the state folder sits beside the binary), which no other gate runs. WOW_DATA, which
# a player build reads too, goes in for the shipped-UI tests that read FrameXML off the install.
# Without `dev` only $BENILLA_ADDON_CORPUS finds the addon corpus, so the link goes in by that name,
# and BENILLA_REQUIRE_DATA refuses a skip here exactly where it does in `test`.
run player-tests env ${wow_data:+WOW_DATA="$wow_data"} \
${corpus:+BENILLA_ADDON_CORPUS="$root/wow-addons-vanilla"} \
${require_data:+BENILLA_REQUIRE_DATA=1} BENILLA_SKIP_LOG="$skips" \
# player-build: `benilla` without `dev`, which compiles out the debug panel, perf HUD, inspector,
# capture harness and probes, so it fails when any other code names one of them.
run player-build cargo build -p benilla --no-default-features
# player-tests: the unit tests of the `cfg(not(feature = "dev"))` code (the resolver skips the
# source tree; the state folder sits beside the binary), which no other gate runs. WOW_DATA, which
# a player build reads too, goes in for the shipped-UI tests that read FrameXML off the install; no
# BENILLA_REQUIRE_DATA, since without `dev` only $BENILLA_ADDON_CORPUS finds the addon corpus.
run player-tests env ${wow_data:+WOW_DATA="$wow_data"} BENILLA_SKIP_LOG="$skips" \
cargo test -p benilla-formats -p benilla-app --no-default-features --lib
report_skips player-tests
-212
View File
@@ -1,212 +0,0 @@
#!/usr/bin/env python3
"""Regenerate `reference/1.12-glue-surface.tsv`: every Lua function the glue screens' VM has in `_G`.
scripts/gen-reference-glue-surface.py [--shapes FILE] [--globals FILE] [--out FILE]
The inputs are vendored under `reference/`, so this runs from any clone and reproduces the
committed table byte for byte.
The glue builder (`0x46a7b0`) starts from the bare base set `0x7039e0`: a rebuild resets the Lua
state (`0x703b80` -> `0x7039e0`, at `0x46a87b`), and the first build (`0x46a505`, passing 0) skips
that reset (`je` at `0x46a879`) and takes the state the startup init left (`0x402aee`). It then
calls the glue registrar `0x46abb0`, which installs seven registrar tables and nothing else:
0x51bb20 table 0x8503f4 6 shared: in-game, `0x51bb60` calls it before its own table
0x706350 table 0x872e74 4 shared: in-game at `0x490251`; then the 17 widget method tables
0x46ddf0 table 0x8373b8 60 glue core
0x4736c0 table 0x838578 11 character select
0x471a20 table 0x8380f8 24 character create
0x46f480 table 0x837ea0 10 realm list
0x4587f0 table 0x835a50 4 shared: in-game at `0x4902a6`
Each registrar is a `0x704120` loop over its table with the bound read from its own `cmp`, the
counts above. `0x7039e0`, FrameScript's Lua init, is the base set both states share: the 36-entry
base array `0x811e28` (`1.12-shapes.tsv`'s `baselib` table), the `string`, `table`, `math` and
`bit` libraries (fields, not globals, so not rows here), `math`'s global `__pow` ([`POW`]), and
the compat chunk `0x8722e8` run at `0x703b26`. The chunk's 39 function assignments are
[`COMPAT`]: 32 bind a library function under a bare name (`tinsert = table.insert`), each row
taking that binding's `fn` and table, and 7 wrap one in a degree-converting Lua closure (`sin`,
`acos`, ...), `fn` `lua`. Its one other assignment, `PI = math.pi`, is a number. Every engine
function `1.12-globals.tsv` has that no `global` or `baselib` row of the shapes table names must be
one of these, so a chunk the list missed fails the generator.
Columns:
scope base (0x7039e0) | shared (a registrar the in-game state calls too) | glue.
registrar the function that installs the row.
fn, table_va the binding and its table; a compat alias carries the library binding it names,
or fn `lua` and table `-` for a closure; `__pow` has no table.
arity the shapes table's return arity, `?` for a closure.
world_fn a glue row's in-game namesake from any other `global` table, else `-`. The in-game
state never sees a glue row, but the name is shared: a glue verb benilla builds is a
different binding from the in-game one of the same name.
note `shape-differs` where both shapes are exact and the argument count or the return
arity differs between the glue binding and its in-game namesake.
"""
import argparse
import os
import sys
# (registrar, table_va, rows, scope), in the order `0x46abb0` calls them.
REGISTRARS = [
(0x51BB20, 0x8503F4, 6, "shared"),
(0x706350, 0x872E74, 4, "shared"),
(0x46DDF0, 0x8373B8, 60, "glue"),
(0x4736C0, 0x838578, 11, "glue"),
(0x471A20, 0x8380F8, 24, "glue"),
(0x46F480, 0x837EA0, 10, "glue"),
(0x4587F0, 0x835A50, 4, "shared"),
]
BASE_INIT = 0x7039E0
BASE_TABLE = 0x811E28
BASE_ROWS = 36
# The libraries `0x7039e0` opens, by the name the compat chunk reaches them under.
LIBRARIES = {"table": 0x822D40, "math": 0x822C60, "string": 0x822D88, "bit": 0x822C18}
# The compat chunk's function assignments, in its order: (global, "library.field"), or None for a
# Lua closure that converts degrees around the `math` function of the same name.
COMPAT = [
("foreach", "table.foreach"), ("foreachi", "table.foreachi"), ("getn", "table.getn"),
("tinsert", "table.insert"), ("tremove", "table.remove"), ("sort", "table.sort"),
("abs", "math.abs"), ("acos", None), ("asin", None), ("atan", None), ("atan2", None),
("ceil", "math.ceil"), ("cos", None), ("deg", "math.deg"), ("exp", "math.exp"),
("floor", "math.floor"), ("frexp", "math.frexp"), ("ldexp", "math.ldexp"), ("log", "math.log"),
("log10", "math.log10"), ("max", "math.max"), ("min", "math.min"), ("mod", "math.mod"),
("rad", "math.rad"), ("random", "math.random"), ("randomseed", "math.randomseed"),
("sin", None), ("sqrt", "math.sqrt"), ("tan", None),
("strbyte", "string.byte"), ("strchar", "string.char"), ("strfind", "string.find"),
("format", "string.format"), ("gsub", "string.gsub"), ("strlen", "string.len"),
("strlower", "string.lower"), ("strrep", "string.rep"), ("strsub", "string.sub"),
("strupper", "string.upper"),
]
# `luaopen_math` 0x7fb470 also sets the global `__pow` to 0x7fb050 (`0x7fb4af`-`0x7fb4d2`), which
# Lua 5.0 calls for `^`.
POW = ("__pow", 0x7FB050)
REFERENCE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "reference"))
def rows(path, header):
if not os.path.exists(path):
sys.exit(f"no table at {path}")
for line in open(path, encoding="utf-8"):
if line.startswith("#"):
continue
f = line.rstrip("\n").split("\t")
if f and f[0] == header:
continue
yield f
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--shapes", default=os.path.join(REFERENCE, "1.12-shapes.tsv"))
ap.add_argument("--globals", default=os.path.join(REFERENCE, "1.12-globals.tsv"))
ap.add_argument("--out", default=os.path.join(REFERENCE, "1.12-glue-surface.tsv"))
a = ap.parse_args()
# name, fn, pair_va, table_va, table_kind, argc, argc_conf, arity, arity_conf, ...
shapes = [f for f in rows(a.shapes, "name") if len(f) >= 9]
by_table = {}
for f in shapes:
by_table.setdefault(int(f[3], 16), []).append(f)
out = []
def take(table_va, n, registrar, scope):
got = by_table.get(table_va, [])
if len(got) != n:
sys.exit(f"table 0x{table_va:x}: the registrar loops {n} rows, the shapes table has {len(got)}")
for f in got:
out.append({"f": f, "scope": scope, "registrar": registrar})
take(BASE_TABLE, BASE_ROWS, BASE_INIT, "base")
for registrar, table_va, n, scope in REGISTRARS:
take(table_va, n, registrar, scope)
# The base set's Lua side: the compat chunk's aliases and `__pow`, each a binding of a library
# the shapes table carries, or a Lua closure.
fields = {}
for lib, table_va in LIBRARIES.items():
for f in by_table.get(table_va, []):
fields[f"{lib}.{f[0]}"] = f
aliases = []
for name, target in COMPAT:
if target is None:
aliases.append((name, "lua", "-", "?"))
continue
f = fields.get(target)
if f is None:
sys.exit(f"compat alias {name} = {target}: no such library binding in {a.shapes}")
aliases.append((name, f[1], f[3], f[7]))
aliases.append((POW[0], f"0x{POW[1]:x}", "-", "1"))
# A 1.12 engine function no registrar row names is one of the aliases, or the list is short.
registered = {f[0] for f in shapes if f[4] in ("global", "baselib")}
alias_names = {x[0] for x in aliases}
for f in rows(a.globals, "name"):
if len(f) >= 3 and f[1] == "function" and f[2] in ("engine", "lua"):
if f[0] not in registered and f[0] not in alias_names:
sys.exit(f"{f[0]} is a 1.12 engine function no registrar and no compat alias names")
# In-game namesakes: rows of every other `global` table, which only the in-game state installs.
ours = {t for _, t, _, _ in REGISTRARS}
world = {}
for f in shapes:
if f[4] == "global" and int(f[3], 16) not in ours:
world.setdefault(f[0], []).append(f)
lines = []
differs = 0
for r in out:
f = r["f"]
name, fn, table_va, argc, argc_conf, arity, arity_conf = f[0], f[1], f[3], f[5], f[6], f[7], f[8]
world_fn, note = "-", ""
if r["scope"] == "glue" and name in world:
w = world[name]
world_fn = "|".join(x[1] for x in w)
notes = []
for x in w:
if argc_conf == "exact" and x[6] == "exact" and argc != x[5]:
notes.append(f"argc {argc} here, {x[5]} in-game ({x[1]})")
if arity_conf == "exact" and x[8] == "exact" and arity != x[7]:
notes.append(f"arity {arity} here, {x[7]} in-game ({x[1]})")
if notes:
note = "shape-differs: " + "; ".join(notes)
differs += 1
lines.append((r["scope"], f"0x{r['registrar']:x}", name, fn, table_va, arity, world_fn, note))
for name, fn, table_va, arity in aliases:
lines.append(("base", f"0x{BASE_INIT:x}", name, fn, table_va, arity, "-", ""))
names = [l[2] for l in lines]
if len(names) != len(set(names)):
sys.exit("a name appears twice in the glue state; the table is keyed by name")
count = {s: sum(1 for l in lines if l[0] == s) for s in ("base", "shared", "glue")}
path = os.path.normpath(a.out)
with open(path, "w", encoding="utf-8") as fh:
fh.write(
"# The glue screens' Lua surface in the 1.12.1 client (build 5875): every function in `_G`\n"
"# once the glue builder 0x46a7b0 has installed its registrars, before GlueXML loads.\n"
"# Generated by scripts/gen-reference-glue-surface.py from reference/1.12-shapes.tsv and\n"
"# reference/1.12-globals.tsv; the generator's header carries the registrars and the method.\n"
"#\n"
"# scope base = FrameScript's Lua init 0x7039e0, which both states run; shared = a\n"
"# registrar the in-game state calls too (0x51bb20, 0x706350, 0x4587f0); glue =\n"
"# a glue-only registrar table (0x46ddf0, 0x4736c0, 0x471a20, 0x46f480).\n"
"# fn, table_va, arity as 1.12-shapes.tsv has them. A base alias the compat chunk\n"
"# 0x8722e8 defines carries the library binding it names (`tinsert` is table\n"
"# 0x822d40's `insert`), or fn `lua` for a degree-converting closure; math's\n"
"# global `__pow` (0x7fb050) has no table.\n"
"# world_fn a glue row's in-game namesake, a different binding; `-` if none.\n"
"# note shape-differs = both shapes exact, and the glue binding takes or returns a\n"
"# different count than its in-game namesake.\n"
f"# {len(lines)} functions: {count['base']} base, {count['shared']} shared, {count['glue']} glue;"
f" {differs} glue rows differ in shape from their in-game namesake.\n"
"scope\tregistrar\tname\tfn\ttable_va\tarity\tworld_fn\tnote\n"
)
for l in lines:
fh.write("\t".join(l) + "\n")
print(f"wrote {path}: {len(lines)} functions, {differs} shape differences")
if __name__ == "__main__":
main()
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
# Import addon settings (SavedVariables) from a real WoW install's WTF folder into benilla.
#
# scripts/import-wtf.sh <WTF-dir> <ACCOUNT> [<Realm> <Character>] # dry run, prints a plan
# scripts/import-wtf.sh <WTF-dir> <ACCOUNT> <Realm> <Character> --apply
#
# scripts/import-wtf.sh ~/Games/Octo-wow/WTF GROMGOSH "C'Thun" Gromgosh --apply
#
# Why a script and not a copy: benilla does not use the WTF layout. It keeps saved variables in
# `benilla-config/saved/`, and the two differ in both shape and naming:
#
# WTF/Account/<ACC>/SavedVariables/<Addon>.lua → saved/<Addon>.lua
# WTF/Account/<ACC>/<Realm>/<Char>/SavedVariables/<A>.lua → saved/<Realm>-<Char>/<A>.lua
#
# and the per-character directory name is sanitised the way the client does it
# (`local_state.rs`'s `file_token`: every non-alphanumeric character becomes `_`), so the realm
# `C'Thun` is the directory `C_Thun`. Getting that wrong leaves the files in place but unread,
# which looks exactly like "the import did nothing".
#
# Key bindings come across too, and they are NOT a copy — the two formats are transposed:
#
# bindings-cache.wtf bind <KEY> <COMMAND> one line per key
# bindings/account.txt bind <COMMAND> <key...> one line per command
#
# So `bind W MOVEFORWARD` copied verbatim is read by benilla as "the command W, bound to the key
# MOVEFORWARD" — it parses without complaint and binds nothing usable. This transposes properly:
# it groups every key by the command it runs and emits one line per command, key order preserved.
# benilla stores a diff against its own defaults and an absent command keeps those defaults, so a
# full snapshot is safe to write; the one asymmetry is that a command you *deliberately unbound*
# in WoW is simply absent from the cache, and will come back at benilla's default rather than
# staying unbound.
#
# Flags:
# --apply actually write (default is a dry run that changes nothing)
# --all import settings for every addon in the WTF folder, not just the ones installed
# under benilla-config/AddOns. Default is installed-only: benilla ignores saved
# variables for an addon it does not load, so the rest are clutter.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
dest="$root/benilla-config/saved"
wtf="${1:-}"; account="${2:-}"; realm="${3:-}"; character="${4:-}"
apply=0; all=0
for a in "$@"; do
[ "$a" = "--apply" ] && apply=1
[ "$a" = "--all" ] && all=1
done
if [ -z "$wtf" ] || [ -z "$account" ]; then
sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 2
fi
acct_dir="$wtf/Account/$account"
[ -d "$acct_dir" ] || { echo "import: no account dir at $acct_dir" >&2; exit 1; }
# `file_token`: every non-alphanumeric character becomes `_`, matching local_state.rs exactly.
token() { printf '%s' "$1" | sed 's/[^[:alnum:]]/_/g'; }
installed() {
# An addon counts as installed if benilla-config/AddOns has a directory of that name. The
# saved-variable file is named after the addon, which is the same key.
[ "$all" = "1" ] && return 0
[ -d "$root/benilla-config/AddOns/$1" ]
}
copied=0; skipped=0; backed=0
tmp_known="$(mktemp)"
trap 'rm -f "$tmp_known"' EXIT
do_one() {
local src="$1" out="$2" addon
addon="$(basename "$src" .lua)"
case "$src" in *.bak) return 0;; esac
if ! installed "$addon"; then
skipped=$((skipped+1)); return 0
fi
if [ "$apply" = "1" ]; then
mkdir -p "$(dirname "$out")"
# Back up rather than clobber: benilla may already hold settings from play sessions, and
# this import is the kind of thing a person runs twice by accident.
if [ -f "$out" ]; then
cp -p "$out" "$out.pre-import.bak"; backed=$((backed+1))
fi
cp -p "$src" "$out"
fi
copied=$((copied+1))
printf ' %-34s → %s\n' "$addon" "${out#$root/}"
}
echo "import: account-wide (## SavedVariables)"
if [ -d "$acct_dir/SavedVariables" ]; then
for f in "$acct_dir/SavedVariables"/*.lua; do
[ -e "$f" ] || continue
do_one "$f" "$dest/$(basename "$f")"
done
fi
if [ -n "$realm" ] && [ -n "$character" ]; then
key="$(token "$realm")-$(token "$character")"
echo "import: per-character (## SavedVariablesPerCharacter) → $key"
cdir="$acct_dir/$realm/$character/SavedVariables"
if [ -d "$cdir" ]; then
for f in "$cdir"/*.lua; do
[ -e "$f" ] || continue
do_one "$f" "$dest/$key/$(basename "$f")"
done
else
echo " (no such character dir: $cdir)" >&2
fi
fi
# ── key bindings ──────────────────────────────────────────────────────────────────────────────
# Transpose `bind <KEY> <COMMAND>` into `bind <COMMAND> <key...>`, grouping by command and keeping
# the order the keys appeared in, which is the order WoW itself lists a command's primary and
# secondary binding.
# benilla's command registry, read from the source so this cannot drift from it.
known_commands() {
local f="$root/crates/benilla-app/src/bindings/commands.rs"
# `spec!("NAME", ...)` on one line, plus the rustfmt-wrapped form with the name on the next.
{ grep -ohE 'spec!\("[A-Z0-9_]+"' "$f" | grep -oE '"[A-Z0-9_]+"' | tr -d '"'
awk '/spec!\($/{getline; gsub(/[ \t",]/,""); print}' "$f"; } | sort -u
}
# Transpose `bind <KEY> <COMMAND>` into `bind <COMMAND> <key...>`, grouping by command and keeping
# the order the keys appeared in — the order WoW itself lists a command's primary then secondary.
#
# Two things here are load-bearing:
#
# * `gsub(/\r/, "")` — `bindings-cache.wtf` is a Windows file with CRLF endings, so every command
# name carries a trailing CR. Without this, *nothing* matches the registry and the import looks
# like it worked while binding nothing (verified: 0 of 161 matched before stripping, 147 after).
# * the registry filter — benilla does not implement all 161 commands, and `store.rs`'s `resolve`
# *keeps* an unknown command's row (for addon commands registered later at world entry) after
# having already stolen its keys from whatever held them. So importing a command benilla lacks
# takes a key away from a working binding and gives it to something that never exists. The
# stock snapshot contains exactly that hazard: `CAMERAORSELECTORMOVE <- BUTTON1` and
# `TURNORACTION <- BUTTON2` would steal left- and right-click mouselook.
transpose_bindings() {
known_commands > "$tmp_known"
awk -v knownfile="$tmp_known" '
BEGIN { while ((getline k < knownfile) > 0) known[k] = 1 }
{ gsub(/\r/, "") }
$1 == "bind" && NF >= 3 {
cmd = $3
if (!(cmd in known)) { skipped[cmd] = 1; next }
if (!(cmd in keys)) { order[++n] = cmd; keys[cmd] = $2 }
else { keys[cmd] = keys[cmd] " " $2 }
}
END {
print "# benilla key bindings — imported from bindings-cache.wtf (transposed)"
for (i = 1; i <= n; i++) print "bind " order[i] " " keys[order[i]]
ns = 0; for (c in skipped) ns++
if (ns > 0) {
printf "# %d command(s) in the source file are not in benilla'"'"'s registry and were\n", ns
print "# left out on purpose: importing one steals its key from a working binding."
for (c in skipped) print "# " c
}
}
' "$1"
}
bind_one() {
local src="$1" out="$2" label="$3" lines
[ -f "$src" ] || return 0
lines=$(awk '$1 == "bind" && NF >= 3' "$src" | wc -l)
cmds=$(transpose_bindings "$src" | grep -c '^bind ' || true)
unknown=$(transpose_bindings "$src" | grep -c '^# ' || true)
printf ' %-22s %s key line(s) → %s command(s)%s → %s\n' \
"$label" "$lines" "$cmds" \
"$([ "$unknown" -gt 0 ] && printf ', %s not in registry (listed in the file)' "$unknown")" \
"${out#$root/}"
if [ "$apply" = "1" ]; then
mkdir -p "$(dirname "$out")"
[ -f "$out" ] && { cp -p "$out" "$out.pre-import.bak"; backed=$((backed+1)); }
transpose_bindings "$src" > "$out"
fi
}
echo "import: key bindings (transposed, not copied)"
bind_one "$acct_dir/bindings-cache.wtf" "$root/benilla-config/bindings/account.txt" "account-wide"
if [ -n "$realm" ] && [ -n "$character" ]; then
bind_one "$acct_dir/$realm/$character/bindings-cache.wtf" \
"$root/benilla-config/bindings/$(token "$realm")-$(token "$character").txt" \
"$character"
fi
echo
echo "import: $copied file(s)$([ "$all" = "1" ] || echo ", $skipped skipped as not-installed")$([ "$backed" -gt 0 ] && echo ", $backed existing backed up to *.pre-import.bak")"
if [ "$apply" != "1" ]; then
echo "import: DRY RUN — nothing written. Re-run with --apply to do it."
fi
+6 -16
View File
@@ -104,15 +104,6 @@ printf '%s' "$plain" | grep -q "logout-smoke: empty roster" &&
fail "$user has no characters — the re-entry leg cannot run (make one, or rig it: WOW_RIG=…)"
printf '%s' "$plain" | grep -q "logout-smoke: done" ||
fail "the round trip never completed (no 'logout-smoke: done')"
# The logout returns to character select on the link the world was entered by and re-lists there
# (`0x5aaeb0` → `0x46b880`): a roster on that world session, and one realmd logon for the walk.
printf '%s' "$plain" | grep -q "logout-smoke: FAILED" &&
fail "$(printf '%s\n' "$plain" | sed -n 's/.*logout-smoke: FAILED — //p' | tail -1)"
printf '%s' "$plain" | grep -q "logout-smoke: roster after the logout on the same link" ||
fail "no roster came on the same link after the logout"
logons="$(printf '%s\n' "$plain" | grep -cF "net: parked at the realm list")"
[ "$logons" -eq 1 ] ||
fail "$logons realmd logons in the round trip, expected 1 — the logout logged on again"
# The re-entered character must be drivable: the leg lists what would block movement (a /logout
# root must end with its session), and anything but `none` fails.
@@ -138,25 +129,24 @@ panics="$(printf '%s\n' "$plain" | grep -cE 'panicked at')"
[ "$errors" -ne 0 ] && fail "$errors ERROR line(s)"
# A marker counts once per edge it fires on. Each world entry builds a world VM beside the
# character screen's glue VM (the reference's `0x490bd0`/`0x48fbf0` pair), so the VM marker counts
# character screen's glue VM (the reference's `0x490bd0`/`0x48fbf0` pair), so per-VM markers count
# twice per login; half that means an entry adopted the glue VM and its spent `VmMemo`s.
sessions=2 # world entries in this walk
vms=$((sessions * 2)) # …and the Lua states they cost: a glue VM and a world VM each
for marker in "new VM, session"; do
for marker in "Fonts.xml loaded"; do
n="$(printf '%s\n' "$plain" | grep -cF "$marker")"
printf ' %-24s %s\n' "$marker" "$n"
[ "$n" -eq "$vms" ] ||
fail "'$marker' happened $n time(s), expected $vms — one per VM built, two per login; \
$sessions would mean the entry adopted the character screen's VM and inherited its spent VmMemos"
done
# The in-game UI, `FrameXML.toc`'s `Fonts.xml` included, and the key-binding commands (the core's
# `Bindings.xml`, loaded after the toc) build once per login; one count for two logins means the
# second reused the first's frame tree.
for marker in "Fonts.xml loaded" "UIParent.xml loaded" "commands registered"; do
# The in-game UI and the keybinding table (`seed_bindings_for_vm`, run on the world-entry edge)
# build once per login; one count for two logins means the second reused the first's frame tree.
for marker in "UIParent.xml loaded" "commands registered"; do
n="$(printf '%s\n' "$plain" | grep -cF "$marker")"
printf ' %-24s %s\n' "$marker" "$n"
[ "$n" -eq "$sessions" ] ||
fail "'$marker' happened $n time(s), expected $sessions — once per login: the UI and its binding commands rebuilt per login"
fail "'$marker' happened $n time(s), expected $sessions — once per login: the UI rebuilt per login and the keybinding table seeded on the entry edge"
done
# The shutdown tail runs once per session: at the /logout, and at the exit, which closes the window