benilla is one repo: the working tree moves in, restructured

Until now this repo was an export of a private tree, pushed as squashed snapshots. From here on
it is the working tree itself, and the private repo is archived.

The root holds AGENTS.md and the Rust and GitHub files only. Everything an agent needs to orient
is in docs/: METHOD.md (the rules and how the project is built, 123 lines, project method only),
MAP.md (generated from the code at every land) and CONTRIBUTING.md (what gets in and how a
change is judged). One skill, `probe`, holds the procedure for running the client unattended
against the local server.

Brought over because the project needs it: reference/ (the 1.12 name catalogues 34 source
files read; the export failed its own tests without them), the gate chain and the pool
(scripts/), the hooks that enforce the install rule and the worktree rule, the full .gitignore,
the `cargo play` alias, and the original wording of eleven source files the export had reworded.

Left out: the decision records (archived with the private repo), the export tooling, the
operator's Discord and triage loop, the lab-laptop scripts and the instruments nothing
references, which live outside the repo now. What read the records is gone from the tooling:
no corpus lint, no records index, no `reserve` verb, no number-collision check.

Three hooks are gone: fmt-at-commit and the file-size nudge were redundant with the gates, and
the assets/ui guard is now a test (`assets_ui_does_not_grow`) that fails for everyone, not only
for a Claude Code session. The remaining pool hooks stand down on a machine without a pool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 15:56:57 +07:00
co-authored by Claude Fable 5.1
parent dd196298b0
commit 3e12fa1d7f
99 changed files with 30532 additions and 81 deletions
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# The addon-API coverage instrument (decisions 1178 §, 1188 phase 0, 1189) — how much of the
# 1.12.1 client's global surface benilla presents, measured on any day.
#
# scripts/api-coverage.sh # the report
# scripts/api-coverage.sh --missing # + every engine global we do not have
# scripts/api-coverage.sh --beyond # + every global we have that 1.12 does not
#
# **Both sides are asked, not remembered.** The 1.12 side is `reference/1.12-globals.tsv`, the
# running reference client's own in-world `_G` (regenerate: scripts/gen-reference-globals.py). Our
# side is a real `UiScript::new()` dumped through `pairs(_G)`. The previous version of this script
# inferred both by pattern-matching source — that is how the arc got "54 C_Container references"
# (a grep over Rust, not API surface) and the 124-then-41 corpus estimates, all wrong. When the
# question is what a running system exposes, ask the running system.
#
# It also no longer measures Bagnon/Questie/WeakAuras out of the Era install: 1188 settled the
# target as 1.12.1 and the vanilla ecosystem, so an Era addon's call sites are the wrong client's
# demand. The real-addon half of the question belongs to the vanilla addon harness (1188 phase 6).
#
# **The addon corpora are not 1.12 codebases, and that shapes what a grep over them means.**
# `~/dev/wow-addons-top20` and `~/dev/wow-addons-vanilla` hold addons that RUN on 1.12 — which is
# not the same as addons written for 1.12 and nothing else. Most of the big ones ship one codebase
# for several clients and pick a path at load: pfUI registers every module with a version list
# (`RegisterModule("loot", "vanilla:tbc", …)`, matched against `pfUI.expansion`), pfQuest keeps a
# whole `compat/client.lua` off `GetBuildInfo`, and `libs/` directories carry vendored libraries
# with their own client targets. Addons also come in several versions of themselves — a corpus is one snapshot of each.
#
# So a call site found by grep may be TBC-era code that a 1.12 client reaches and raises on, an
# addon calling a name it defines itself under its own namespace, or genuinely 1.12 demand. The
# three read very differently and look identical to `grep -rn`. Decision 2146 is the case that
# made the point: six 2.0 globals were kept here for years on the strength of call sites that
# turned out to be all of the first two kinds.
#
# **Never quote the percentage undifferentiated** (1178's rule, and 1188 restates it). A missing
# global is one of three different things, and only reading the list tells you which:
# · a verb for a feature benilla has not built at all → not a gap, a backlog item
# · a verb missing from a feature benilla ships → a real hole, fix it
# · a name we have that 1.12 does not → a superset, and not free
# The last one is the one this script insists on printing in full: an addon that feature-detects
# (`if strmatch then`) takes a path we cannot honour, and the failure surfaces far from the cause.
set -u
cd "$(dirname "$0")/.."
REF="reference/1.12-globals.tsv"
[ -f "$REF" ] || { echo "no $REF — run scripts/gen-reference-globals.py" >&2; exit 1; }
show_missing=0
show_beyond=0
for a in "$@"; do
case "$a" in
--missing) show_missing=1 ;;
--beyond) show_beyond=1 ;;
*) echo "usage: $0 [--missing] [--beyond]" >&2; exit 2 ;;
esac
done
ours=$(mktemp) || exit 1
trap 'rm -f "$ours"' EXIT
echo "asking benilla's VM what it exposes..." >&2
if ! cargo run -q -p benilla-ui --example dump_globals >"$ours" 2>/dev/null; then
echo "dump_globals failed — build the workspace first" >&2
exit 1
fi
awk -F'\t' -v ref="$REF" -v show_missing="$show_missing" -v show_beyond="$show_beyond" '
# ── the reference side ──────────────────────────────────────────────────────────────────────
FNR == NR {
if ($0 ~ /^#/) next
origin[$1] = $3
n_ref[$3]++
if ($2 == "function") n_ref_fn[$3]++
next
}
# ── our side ───────────────────────────────────────────────────────────────────────────────
{
ours[$1] = $2
n_ours++
o = ($1 in origin) ? origin[$1] : "beyond"
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 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
}
}
END {
surface = n_ref["engine"] + n_ref["lua"]
missing = surface - have
pct = surface ? sprintf("%.0f%%", 100 * have / surface) : "-"
printf "\nthe 1.12.1 surface — %s (%d names: the running client'\''s _G, plus what the\n shipped UI defines behind a LoadOnDemand window — decision 1200)\n", ref, \
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 <- 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"]
printf "\nbenilla'\''s VM — %d globals (UiScript::new(), asked at runtime)\n\n", n_ours
printf "1.12 globals: %d we have: %d (%s) missing: %d beyond-1.12: %d (listed)\n", \
surface, have, pct, missing, length(api)
print " ^ never quote this without the split: unbuilt-feature vs missing-verb vs superset."
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)
dump(transcribable, " ")
if (show_beyond || length(api) <= 30) {
printf "\nWoW API beyond 1.12 (%d):\n", length(api)
dump(api, " ")
}
if (show_missing) {
printf "\nmissing engine globals (%d):\n", missing
for (k in origin)
if ((origin[k] == "engine" || origin[k] == "lua") && !(k in ours)) miss[k] = 1
dump(miss, " ")
}
print ""
}
# Print a set as wrapped, sorted, space-separated names.
function dump(set, indent, k, sorted, i, n, line) {
n = 0
for (k in set) sorted[++n] = k
asort_names(sorted, n)
line = indent
for (i = 1; i <= n; i++) {
if (length(line) + length(sorted[i]) + 1 > 96) { print line; line = indent }
line = line sorted[i] " "
}
if (line != indent) print line
}
# Insertion sort — `asort` is a gawk extension and macOS ships BSD awk.
function asort_names(a, n, i, j, t) {
for (i = 2; i <= n; i++) {
t = a[i]
for (j = i - 1; j >= 1 && a[j] > t; j--) a[j + 1] = a[j]
a[j + 1] = t
}
}
' "$REF" "$ours"
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# check.sh — the ROUND's verify (decision 1822): fmt everywhere (cheap), clippy + test scoped to
# the crates this round's changes can actually affect — the changed crates plus everything that
# depends on them. The workspace-wide chain is `scripts/gates.sh`'s job, paid at sync→land.
#
# Why this verb exists: the fleet was paying the FULL chain per round — measured over the fortnight
# to 2026-09-01 at 3,028 `cargo test --workspace` runs (72 h of wall time) and 1,572 workspace-wide
# clippys (33 h) against only 393 actual gate moments, one session hitting the whole suite 161
# times in a day while editing a single leaf crate. 59 % of edits land in `benilla-app`, which
# nothing but the `benilla` bin depends on: the honest scope of a round is 1–3 crates, not 19.
#
# Scope rule — conservative by construction, unknown means FULL, never "probably fine":
# crates/<dir>/** → that crate (owner read off cargo metadata, not the dir name)
# assets/ui/** → benilla-app (ui_script's shipped-UI tests read it at runtime)
# *.md → no gate can change (same rule as wt.sh docs_only)
# anything else → escalate: exec scripts/gates.sh (workspace manifests, .cargo/,
# rust-toolchain, scripts/, shaders outside crates, …)
# Change set = fork point vs main + staged + unstaged + untracked — the round's work, not the last
# edit. CHECK_FULL=1 skips straight to gates.sh.
#
# Same fail-fast discipline as gates.sh: never compose `cargo … | tail` pipelines by hand — the
# pipe's exit code masks the gate's (that bug bit twice before gates.sh existed).
set -uo pipefail
# Gate the tree you are STANDING IN (gates.sh learned this; same rule here).
here="$(cd "$(dirname "$0")/.." && pwd)"
root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
[ -n "$root" ] && [ -f "$root/scripts/check.sh" ] || root="$here"
cd "$root" || exit 1
if [ "${CHECK_FULL:-}" = "1" ]; then
exec "$root/scripts/gates.sh"
fi
# ── The round's change set ───────────────────────────────────────────────────────────────────────
base="$(git merge-base HEAD main 2>/dev/null || git rev-parse HEAD)"
changed="$( { git diff --name-only "$base" HEAD 2>/dev/null
git diff --name-only HEAD 2>/dev/null
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )"
if [ -z "$changed" ]; then
echo "check: no changes against main and a clean tree — nothing to verify"
exit 0
fi
# ── Map files → crate dirs; anything unmappable escalates to the full chain ──────────────────────
dirs=""
full_reason=""
while IFS= read -r f; do
case "$f" in
docs/* | *.md) ;; # provably gate-inert (wt.sh docs_only)
assets/ui/*) dirs="$dirs benilla-app-DIR:crates/benilla-app" ;;
crates/*/*) d="${f#crates/}"; dirs="$dirs DIR:crates/${d%%/*}" ;;
*) full_reason="$f" ;;
esac
done <<EOF
$changed
EOF
if [ -n "$full_reason" ]; then
echo "check: '$full_reason' is outside the crate map — the whole workspace could be affected."
echo "check: escalating to the full chain: scripts/gates.sh"
exec "$root/scripts/gates.sh"
fi
dirlist="$(printf '%s\n' $dirs | sed 's/.*DIR://' | sort -u)"
if [ -z "$dirlist" ]; then
echo "check: docs-only round ($(printf '%s\n' "$changed" | wc -l | tr -d ' ') file(s)) — no gate can change; fmt only"
cargo fmt --all -- --check || exit 1
echo "check: green (docs only)"
exit 0
fi
# ── Changed crates → reverse-dependency closure, off cargo metadata (dir names are not package
# names here: crates/mpq is benilla-mpq) ──────────────────────────────────────────────────────
pkgs="$(python3 - "$root" $dirlist <<'PY'
import json, subprocess, sys, os
root, dirs = sys.argv[1], set(sys.argv[2:])
meta = json.loads(subprocess.run(
["cargo", "metadata", "--format-version", "1", "--no-deps"],
capture_output=True, text=True, cwd=root, check=True).stdout)
by_dir, deps = {}, {}
names = {p["name"] for p in meta["packages"]}
for p in meta["packages"]:
by_dir[os.path.relpath(os.path.dirname(p["manifest_path"]), root)] = p["name"]
deps[p["name"]] = {d["name"] for d in p["dependencies"] if d["name"] in names}
changed = {by_dir[d] for d in dirs if d in by_dir}
missing = [d for d in dirs if d not in by_dir]
if missing: # a changed dir cargo doesn't know: not our map's to scope
print("FULL " + " ".join(missing)); sys.exit(0)
scope = set(changed)
grew = True
while grew:
grew = False
for name, ds in deps.items():
if name not in scope and ds & scope:
scope.add(name); grew = True
print(" ".join(sorted(scope)))
PY
)" || { echo "check: cargo metadata failed — falling back to the full chain"; exec "$root/scripts/gates.sh"; }
case "$pkgs" in FULL*)
echo "check: changed path(s) not in the workspace map (${pkgs#FULL }) — full chain"
exec "$root/scripts/gates.sh" ;;
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; full chain still runs at sync→land)"
log="$(mktemp -t benilla-check)"
trap 'rm -f "$log"' EXIT
run() {
local name="$1"; shift
if ! "$@" >"$log" 2>&1; then
echo "CHECK FAILED: $name"
tail -30 "$log"
exit 1
fi
echo "check ok: $name"
}
run fmt cargo fmt --all -- --check
run clippy cargo clippy $pflags --all-targets -- -D warnings
run test cargo test $pflags
echo "CHECK GREEN (scoped: $pkgs)"
echo " (land still pays the full chain once: scripts/gates.sh — memoized, so an already-gated tree is free)"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# cine.sh — play one cinematic on the probe and print what happened, as a timeline.
#
# Every cinematic question is the same shape — "what did the client do to the world while the
# camera was away?" — and answering it by hand costs four environment variables, a GM command, a
# move trace and a fistful of greps. Assembled by hand three times in one session (decisions
# 1701/1707/1708), it produced two WORTHLESS runs before a usable one:
#
# * the first parked the body wherever the character was saved, which was inside Thunder Bluff's
# geometry — the body was wedged, sent nothing, and the trace said "held" for reasons that had
# nothing to do with the cinematic;
# * the second read "no movement packet" as proof that input was suppressed, when the body was
# simply in free fall and had no movement to send.
#
# Both are avoidable by construction, so this parks the body on known-flat ground first and always
# prints the control facts (did it fall? did it move?) beside the thing under test.
#
# THE SILENT-BUT-LIVE TRICK, which is the other half of why this exists: `$WOW_NOSOUND` opens no
# audio device, so `zone::start_music_stream` returns before it logs and a silent run can tell you
# NOTHING about music. Instead this points `$BENILLA_HOME` at a throwaway config with
# `MasterVolume = "0"` — the device opens, every stream starts and logs its file, and the room
# stays quiet. That is what made "the Stormwind city-intro stinger is what plays under the human
# narration" a readable fact rather than a guess.
#
# scripts/cine.sh # the human intro (81), parked in Stormwind
# scripts/cine.sh 41 # the dwarf intro
# scripts/cine.sh 81 --key W # …and hold W mid-shot: does the body move?
# scripts/cine.sh 81 --at "-8913,554,94,0"
# scripts/cine.sh 81 --keep # keep the raw log and trace paths, printed at the end
#
# The probe identity is SLOT-KEYED off this worktree's path (`docs/METHOD.md`): pool-N logs in as
# probeN. Never the director's account.
set -euo pipefail
cd "$(dirname "$0")/.."
id=81
park="-8913,554,94,0" # Stormwind Trade District: flat, solid, and a zone with music
key=""
keep=0
while [ $# -gt 0 ]; do
case "$1" in
--at) park="$2"; shift 2 ;;
--key) key="$2"; shift 2 ;;
--keep) keep=1; shift ;;
-*) echo "cine.sh: unknown flag $1" >&2; exit 2 ;;
*) id="$1"; shift ;;
esac
done
# Slot-keyed probe identity (docs/METHOD.md, "The local vmangos server").
slot="$(basename "$PWD")"
n="${slot#pool-}"
case "$n" in
0) word=zero ;; 1) word=one ;; 2) word=two ;; 3) word=three ;; 4) word=four ;;
5) word=five ;; 6) word=six ;; 7) word=seven ;; 8) word=eight ;; 9) word=nine ;;
*) echo "cine.sh: not in a wt.sh pool slot ($slot) — claim one first (docs/METHOD.md)" >&2; exit 2 ;;
esac
char="Probe$word"
work="$(mktemp -d)"
cfg="$work/cfg"
mkdir -p "$cfg"
printf '[cvars]\nMasterVolume = "0"\n' > "$cfg/config.toml"
log="$work/run.log"
trace="$work/move.trace"
IFS=, read -r px py pz pmap <<< "$park"
# The schedule, in probe-clock seconds. Generous rather than tight: a cold slot streams a city
# slowly, and a measurement taken before the world arrived is the wedged-body run all over again.
park_at=8
play_at=20
key_at=$((play_at + 8))
# Shots run 25-102 s; leave room for the end, the resume and the cover to settle.
exit_at=$((play_at + 120))
echo "cine.sh: cinematic $id as $char, parked at $px $py $pz (map $pmap)"
[ -n "$key" ] && echo "cine.sh: holding $key for 3 s at t=${key_at}s"
echo "cine.sh: this takes about $((exit_at + 20)) s — the shot is played in full so the END is measured too"
env BENILLA_HOME="$cfg" WOW_UNATTENDED=1 \
WOW_USER="probe$n" WOW_PASS="pprobe$n" WOW_CHAR="$char" \
WOW_PROBE_CHAT=".go xyz $px $py $pz $pmap;.debug play cinematic $id" \
WOW_PROBE_CHAT_AT="$park_at" WOW_PROBE_CHAT_EVERY="$((play_at - park_at))" \
${key:+WOW_PROBE_KEY="$key@$key_at:3"} \
WOW_MOVE_TRACE="$trace" WOW_MOVE_TRACE_TAGS="move,snd" \
WOW_PROBE_EXIT_AT="$exit_at" \
cargo run -q -p benilla > "$log" 2>&1 || true
strip() { sed -E 's/\x1b\[[0-9;]*m//g'; }
echo
echo "── timeline ────────────────────────────────────────────────────────────────"
strip < "$log" | grep -E \
"cinematic:|screen fade:|cinematic voice:|zone music:|ambience:|loading screen:|body held|probe-key:" \
| sed -E 's/^[0-9-]+T([0-9:.]+)Z +INFO +[a-z_:]+: /\1 /' || true
echo
echo "── the two control facts ───────────────────────────────────────────────────"
if [ -s "$trace" ]; then
# Everything before the park is the login/teleport arc and says nothing about the cinematic.
after=$(awk -v t="$park_at" '$1=="t=" || $2+0 >= t' "$trace" 2>/dev/null || cat "$trace")
low=$(printf '%s\n' "$after" | grep -oE 'pos=\[[-0-9.]+,[-0-9.]+,[-0-9.]+\]' \
| sed -E 's/.*,//; s/\]//' | sort -n | head -1)
echo "did it FALL? lowest z after the park: ${low:-<none>} (park z was $pz)"
verbs=$(printf '%s\n' "$after" | grep -oE 'snd [A-Za-z_]+' | sort | uniq -c | tr '\n' ' ')
echo "did it MOVE? wire verbs sent: ${verbs:-<none>}"
echo " (a StartForward means the body walked; only Heartbeats means it did not)"
else
echo "no move trace written — the run did not reach the world"
fi
echo
if [ "$keep" = 1 ]; then
echo "log: $log"
echo "trace: $trace"
else
rm -rf "$work"
fi
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# crosscheck — compile benilla for the platforms the gates cannot see.
#
# Why it exists (decision 1920, bug B356): `scripts/gates.sh` runs on the developer's Mac, so a
# file behind `cfg(not(target_os = "macos"))` is not merely unverified — it is INVISIBLE. Decision
# 1857 shipped a non-macOS audio device layer that opened nothing, every Linux and Windows build
# went silent in the 09-02 sync, and nothing in this repo could have said so, because nothing here
# had ever compiled `benilla-app` for another target. This is that missing compile.
#
# scripts/crosscheck.sh [linux|windows|all] (default: all)
#
# Run it whenever platform-conditional code changes — a `cfg(target_os)`, a `[target.'cfg(…)']`
# dependency, a `#[link]`, an `extern "system"`. It is NOT part of `gates.sh`: it costs a container
# and a second dependency graph, and most commits touch no platform seam. It is a land-time check
# for the ones that do.
#
# Scope is `-p benilla-app --lib`, deliberately: that is the crate every platform seam lives in and
# the one players run, and checking it pulls benilla-world/-assets/-formats/-ui/-protocol with it.
#
# What each arm needs, once:
# linux docker (colima is fine) — the container installs its own ALSA/udev headers.
# The source is rsynced to ~/.benilla-crosscheck/src first: a worktree on an external
# volume is not visible inside the VM, and $HOME is. The container is pinned to THIS
# machine's rustc version, not to `rust-toolchain.toml`'s `stable`: the image's stable
# ran ahead of the host's on the first run and reported eight `chunks_exact_to_as_chunks`
# findings across four crates — a newer clippy's opinion, nothing to do with Linux. The
# question this script asks is "does OUR toolchain build this for another platform".
# windows rustup target add x86_64-pc-windows-gnu + brew install mingw-w64
# (blake3 assembles for the target, so `cargo check` needs the cross assembler even
# though it never links. The *-gnu target is used rather than *-msvc for the same
# reason: `ml64.exe` has no substitute here, and nothing we compile differs between
# the two ABIs at the type level.)
#
# A missing prerequisite is a FAILURE, not a skip. The whole point is that a platform nobody
# compiled is a platform nobody supports; a green line for an arm that never ran would recreate
# exactly the hole this script closes.
set -uo pipefail
arm="${1:-all}"
here="$(cd "$(dirname "$0")/.." && pwd)"
root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
[ -n "$root" ] && [ -f "$root/scripts/crosscheck.sh" ] || root="$here"
cd "$root" || exit 1
echo "cross-checking: $root"
# ── Green-stamp memoization (decision 2331; the shape of gates.sh's, 1822) ─────────────────────
# `wt.sh land` runs this script on the landing tree whenever the diff touches a platform seam, and
# a session that already ran it on the same tree should not pay the container twice. The key is
# what decided the verdict: the WORKING TREE's true content hash (a throwaway index, so `target/`
# and the install links stay ignored), the arm, this script, and rustc. CROSSCHECK_FORCE=1 re-runs.
stamp="target/.crosscheck-green"
tree_key() {
local tmpidx t
tmpidx="$(mktemp -t benilla-xcheck-idx)" && rm -f "$tmpidx" || return 1
t="$( (export GIT_INDEX_FILE="$tmpidx"
git read-tree HEAD && git add -A . && git write-tree) 2>/dev/null )"
rm -f "$tmpidx"
[ -n "$t" ] || return 1
printf '%s|%s|%s|%s' "$t" "$arm" "$(rustc -V 2>/dev/null)" \
"$(shasum "$root/scripts/crosscheck.sh" 2>/dev/null | cut -d' ' -f1)"
}
# A docs-only delta keeps the verdict, as in gates.sh (2049): a record or a stub landing on the
# stamped tree changes nothing any compiler reads.
docs_only_delta() { # $1 = stamped tree, $2 = current tree
git cat-file -e "$1^{tree}" 2>/dev/null && git cat-file -e "$2^{tree}" 2>/dev/null || return 1
[ -z "$(git diff-tree -r --name-only "$1" "$2" 2>/dev/null | grep -vE '^(docs/|[^/]*\.md$)' || true)" ]
}
key_start="$(tree_key 2>/dev/null || true)"
if [ "${CROSSCHECK_FORCE:-}" != "1" ] && [ -n "$key_start" ] && [ -f "$stamp" ]; then
old="$(cat "$stamp" 2>/dev/null)"
memo=""
if [ "$old" = "$key_start" ]; then
memo="this exact tree already passed"
elif [ "${old#*|}" = "${key_start#*|}" ] && docs_only_delta "${old%%|*}" "${key_start%%|*}"; then
memo="this tree differs from one that passed only in top-level *.md"
fi
if [ -n "$memo" ]; then
echo "CROSS-CHECK GREEN ($arm — memoized: $memo; CROSSCHECK_FORCE=1 re-runs)"
exit 0
fi
fi
WIN_TARGET=x86_64-pc-windows-gnu
LINUX_IMAGE=rust:1-bookworm
STAGE="$HOME/.benilla-crosscheck/src"
failed=0
note() { printf '\ncrosscheck: %s\n' "$*"; }
fail() { printf '\nCROSSCHECK FAILED: %s\n' "$*"; failed=1; }
run_windows() {
note "windows — cargo clippy --target $WIN_TARGET"
if ! rustup target list --installed 2>/dev/null | grep -qx "$WIN_TARGET"; then
fail "windows: target $WIN_TARGET is not installed (rustup target add $WIN_TARGET)"
return
fi
if ! command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then
fail "windows: no x86_64-w64-mingw32-gcc (brew install mingw-w64) — blake3 needs it"
return
fi
if cargo clippy -p benilla-app --lib --target "$WIN_TARGET" -- -D warnings; then
note "windows OK"
else
fail "windows: clippy -D warnings"
fi
}
run_linux() {
note "linux — cargo clippy in $LINUX_IMAGE"
if ! docker info >/dev/null 2>&1; then
fail "linux: docker is not running (colima start)"
return
fi
mkdir -p "$STAGE" || { fail "linux: cannot create $STAGE"; return; }
# Anchored excludes ('/target/', not 'target/'): an unanchored pattern also matches
# `crates/benilla-app/src/target/`, and the missing module then reports as four unrelated
# type errors deep in bevy. Cost the first run of this script twenty minutes.
if ! rsync -a --delete \
--exclude '/target/' --exclude '/.git' --exclude '/WoW' --exclude '/WoW-era' \
--exclude '/reference/' "$root"/ "$STAGE"/; then
fail "linux: rsync to $STAGE"
return
fi
local version
version="$(rustc -V | awk '{print $2}')"
[ -n "$version" ] || { fail "linux: could not read this machine's rustc version"; return; }
note "linux — pinned to rustc $version (this machine's)"
docker volume create benilla-xcheck-target >/dev/null 2>&1
docker volume create benilla-xcheck-cargo >/dev/null 2>&1
docker volume create benilla-xcheck-rustup >/dev/null 2>&1
if docker run --rm \
-v "$STAGE":/src \
-v benilla-xcheck-target:/xtarget \
-v benilla-xcheck-cargo:/usr/local/cargo/registry \
-v benilla-xcheck-rustup:/usr/local/rustup \
-w /src -e CARGO_TARGET_DIR=/xtarget -e CARGO_BUILD_JOBS="${CROSSCHECK_JOBS:-4}" \
-e RUSTUP_TOOLCHAIN="$version" \
"$LINUX_IMAGE" bash -c '
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null 2>&1
apt-get install -y -qq pkg-config libasound2-dev libudev-dev >/dev/null 2>&1
rustup toolchain install -c clippy --profile minimal "$RUSTUP_TOOLCHAIN"
rustc -V
cargo clippy -p benilla-app --lib -- -D warnings
'; then
note "linux OK"
else
fail "linux: clippy -D warnings"
fi
}
case "$arm" in
linux) run_linux ;;
windows) run_windows ;;
all) run_windows; run_linux ;;
*) echo "usage: $0 [linux|windows|all]"; exit 2 ;;
esac
if [ "$failed" -eq 0 ]; then
# Stamp only a tree nobody edited under the run (gates.sh's rule): a changed key is no stamp.
key_end="$(tree_key 2>/dev/null || true)"
if [ -n "$key_end" ] && [ "$key_end" = "$key_start" ]; then
mkdir -p target && printf '%s\n' "$key_end" > "$stamp" 2>/dev/null || true
fi
echo
echo "CROSS-CHECK GREEN ($arm)"
echo " (compiling is not sounding: WOW_AUDIO_LIVE=1 cargo test -p benilla-app --lib sound::output::"
echo " runs the output layer against the machine's real device — decision 1920)"
exit 0
fi
echo
echo "CROSS-CHECK RED"
exit 1
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""doc-links — fail on a doc link whose target does not exist ANYWHERE in the workspace.
WHY THIS IS NARROW, AND WHY THE OBVIOUS VERSION IS WRONG (decision 1925). The docs are this
project's knowledge base, so a doc link that goes nowhere is real rot — decision 1903 found a
`UNIT_FLAG_STUNNED` comment still pointing at a `MovementState::stunned` that a later decision had
deleted, and nothing caught it because nothing runs rustdoc.
The obvious fix is `-D rustdoc::broken_intra_doc_links`. Measured, it does not survive contact with
this codebase: **941 warnings, 264 of them unambiguous `crate::`/`super::`/`self::` paths** — and
almost none of that is rot.
* **195** name a target that IS declared in its crate. They do not resolve because the target is
a *private* module named through its parent (`crate::net::apply`, where `mod apply;` is private
to `net`) — not a rustdoc quirk but Rust's own name resolution, so the link never worked.
Making them resolve means widening visibility for a documentation reason, trading encapsulation
for navigation.
* **~50** are one crate naming another's module with `crate::` — `benilla-world` writing
`crate::entities`, which is benilla-app's and which benilla-world cannot depend on. Those can
never resolve; the only "fix" is de-linking prose that reads fine as prose.
Both are conventions this codebase writes on purpose. A gate on them would be 249 edits of churn
that improve nothing, and would then have to stay green forever.
What IS unambiguous rot is the residue: a link whose **leaf is declared nowhere in the workspace**
— the item is gone, so no reading of the link can be right. That was 15 sites when this was
written (1925 fixed them); this script keeps it at zero.
scripts/doc-links.py # the gate: exit 1 on any dead-target link
scripts/doc-links.py --report # + the benign counts, for anyone re-opening the question
The leaf test is a declaration grep, deliberately loose: a name declared anywhere counts, because
the failure this catches is "the thing was deleted", not "the path is spelled right".
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def run_rustdoc() -> str:
out = subprocess.run(
["cargo", "doc", "--workspace", "--no-deps", "--document-private-items"],
cwd=ROOT,
capture_output=True,
text=True,
)
return out.stderr + out.stdout
def declared_names() -> set[str]:
"""Every name the workspace declares, in ONE pass.
A grep per link is what the first version did, and at ~900 links over a 19-crate tree it does
not finish inside a gate's patience. One scan, then set lookups.
"""
names: set[str] = set()
# `fn` gets its own pattern: a single alternation swallows the `const` of `const fn foo` and
# captures "fn" as the name, so `foo` is never indexed and the gate reports a live item as
# deleted. That bug shipped for one run of this script and is the reason the two passes exist.
pats = [
re.compile(r"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)"),
re.compile(r"\b(?:struct|enum|trait|type|const|static|union|mod)\s+([A-Za-z_][A-Za-z0-9_]*)"),
re.compile(r"\bmacro_rules!\s+([A-Za-z_][A-Za-z0-9_]*)"),
]
for rs in (ROOT / "crates").rglob("*.rs"):
try:
text = rs.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for pat in pats:
names.update(pat.findall(text))
names.discard("fn")
return names
def main() -> int:
report = "--report" in sys.argv
text = run_rustdoc()
found = re.findall(r"warning: unresolved link to `([^`]+)`\n\s*--> ([^\s:]+):(\d+)", text)
names = declared_names()
dead, benign = [], 0
for target, path, line in found:
# ONLY paths the author asserted are in THIS crate. A bare `[`SystemParam`]` or
# `[`KeyboardInput`]` names a bevy type that is simply not imported for linking — real,
# not rot — and there is no honest way to tell those from a deleted local name. A
# `crate::`/`super::`/`self::`/`Self::` path carries the assertion, so its absence is
# unambiguous.
if not re.match(r"^(crate|super|self|Self)::", target):
benign += 1
continue
leaf = target.split("::")[-1].split("(")[0].strip()
if not leaf or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", leaf):
benign += 1
continue
if leaf in names:
benign += 1
else:
dead.append((target, path, line))
if report:
print(f"doc-links: {len(found)} unresolved-link warnings")
print(f"doc-links: {benign} name something that still exists (see this file's header)")
if not dead:
print(f"doc-links ok: no doc link points at a deleted item ({len(found)} benign)")
return 0
print(f"doc-links FAILED: {len(dead)} doc link(s) point at something that no longer exists:\n")
for target, path, line in dead:
print(f" {path}:{line}\n [`{target}`]")
print(
"\nEach names an item declared nowhere in the workspace. Repoint it at whatever replaced\n"
"the thing, or drop the brackets — a dangling link promises navigation that is not there."
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env bash
# The commit gates (docs/METHOD.md "Gates"), one vetted runner: fmt-check →
# clippy -D warnings → workspace tests (skips refused where the data is) → doc-links →
# pass-span-lint → the player build and its own tests → the enforcer. Each is introduced where it
# runs, below. Fail-fast, nonzero exit on ANY failure, the failing log's tail printed.
#
# Exists because ad-hoc gate chains keep masking failures: `cargo … | tail` reports the PIPE's
# exit code, and `grep -c` exits nonzero on a zero count — both bit real sessions (phase-1a of
# the social arc, then again at the 6a land). Run this instead of composing pipelines.
set -uo pipefail
# Gate the tree you are STANDING IN, not the one this file happens to live in. Every session works
# in its own worktree (docs/METHOD.md) and reaches for the gates by absolute path — `scripts/gates.sh`
# resolved from `$0`, which is the primary checkout. That silently gated the PRIMARY: fmt-red work
# in a worktree reported ALL GATES GREEN, because the primary is always clean.
#
# So: the git toplevel of $PWD, as long as it is a checkout of THIS repo (it has this script) — a
# stray invocation from a sibling repo like wow-5875-re falls back rather than gating that repo with
# benilla's gates. And print the tree either way: the silence is what made the old bug invisible.
here="$(cd "$(dirname "$0")/.." && pwd)"
root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
[ -n "$root" ] && [ -f "$root/scripts/gates.sh" ] || root="$here"
cd "$root" || exit 1
echo "gating: $root"
# ── Green-stamp memoization (decision 1822) ──────────────────────────────────────────────────────
# A green chain stamps target/.gates-green with a key of exactly what determined the verdict: the
# WORKING TREE's true content hash (untracked files included — hashed through a throwaway index, the
# real one untouched, so `target/`, `WoW` and `.wt-claimed` stay excluded by the ignore rules), the
# toolchain, this script itself, and where the install resolver points. Re-running on an unchanged
# tree is then instant instead of ~5–8 min — which is what a land after an already-gated sync, or a
# "once more to be sure", actually costs the machine. GATES_FORCE=1 runs the chain regardless.
# The install path is compared lazily (its resolver is a `cargo run`, instant only on a warm
# target): a tree that doesn't match skips that probe entirely.
stamp="target/.gates-green"
tree_key() {
local tmpidx t
tmpidx="$(mktemp -t benilla-gates-idx)" && rm -f "$tmpidx" || return 1
t="$( (export GIT_INDEX_FILE="$tmpidx"
git read-tree HEAD && git add -A . && git write-tree) 2>/dev/null )"
rm -f "$tmpidx"
[ -n "$t" ] || return 1
printf '%s|%s|%s' "$t" "$(rustc -V 2>/dev/null)" \
"$(shasum "$root/scripts/gates.sh" 2>/dev/null | cut -d' ' -f1)"
}
resolve_wow() { cargo run -q -p benilla-formats --example where 2>/dev/null || true; }
# **A docs-only delta keeps the verdict** (decision 2049). The stamped tree and the current one are
# both real tree objects (write-tree puts them in the object store), so git can say exactly what
# differs. If every differing path is a top-level `*.md` — wt.sh's
# `docs_only` rule (0979): nothing compiles, tests, formats or `include_str!`s those, checked — then
# the chain's outcome on this tree IS the stamped one, by construction rather than by optimism. This
# is what lets `wt.sh land`'s worker land a tree the session already gated after a rebase brought in
# after its own map regeneration, without paying the chain again.
# A stamped tree that the object store has since pruned simply fails the check, and the chain runs.
docs_only_delta() { # $1 = stamped tree, $2 = current tree
git cat-file -e "$1^{tree}" 2>/dev/null && git cat-file -e "$2^{tree}" 2>/dev/null || return 1
[ -z "$(git diff-tree -r --name-only "$1" "$2" 2>/dev/null | grep -vE '^(docs/|[^/]*\.md$)' || true)" ]
}
key_start="$(tree_key || true)"
if [ "${GATES_FORCE:-}" != "1" ] && [ -n "$key_start" ] && [ -f "$stamp" ]; then
old="$(cat "$stamp" 2>/dev/null)"
old_key="${old%|*}" # tree|rustc|script — the part that must match (bar the tree, see below)
memo=""
if [ "$old_key" = "$key_start" ]; then
memo="this exact tree already passed"
elif [ "${old_key#*|}" = "${key_start#*|}" ] && docs_only_delta "${old_key%%|*}" "${key_start%%|*}"; then
memo="this tree differs from one that passed only in top-level *.md, which no gate reads (0979/2049)"
fi
if [ -n "$memo" ] && [ "${old##*|}" = "$(resolve_wow)" ]; then
echo "ALL GATES GREEN (memoized — $memo; GATES_FORCE=1 re-runs)"
[ -d target ] && printf '%s\t%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" \
"$(git rev-parse --short HEAD 2>/dev/null || echo '-')" chain 0 memo >>target/.gates-timing 2>/dev/null
echo " (a clean run is the fourth gate: scripts/smoke.sh — live logout/re-login round trip)"
exit 0
fi
fi
log="$(mktemp -t benilla-gates)"
trap 'rm -f "$log"' EXIT
# ── Timing (decision 2265 §C1) ──────────────────────────────────────────────────────────────────
# Every gate's wall seconds, printed beside its verdict and appended to `target/.gates-timing`
# (gitignored with the rest of `target/`; one tab-separated line per gate per run: UTC time, tree
# short-sha, gate, seconds, verdict). 1822 measured the chain as the machine's single biggest load
# and scoped the per-round verify; its successor question — WHICH gate is the eleven minutes a
# land costs, and how that moves as the tree grows — could not be asked, because the only log was
# `rm -f`'d on exit and `run()` printed no duration. A memo hit and a skipped gate are recorded
# too, at 0 s, so a run's shape reads off the file whole.
timing="target/.gates-timing"
chain_t0=$SECONDS
tree_sha="$(git rev-parse --short HEAD 2>/dev/null || echo '-')"
note_timing() { # $1 = gate, $2 = seconds, $3 = verdict
[ -d target ] || return 0
printf '%s\t%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "$tree_sha" "$1" "$2" "$3" >>"$timing" 2>/dev/null || true
}
run() {
local name="$1"
shift
local t0=$SECONDS
if ! "$@" >"$log" 2>&1; then
echo "GATE FAILED: $name ($((SECONDS - t0)) s)"
note_timing "$name" "$((SECONDS - t0))" "FAILED"
tail -30 "$log"
exit 1
fi
echo "gate ok: $name ($((SECONDS - t0)) s)"
note_timing "$name" "$((SECONDS - t0))" "ok"
}
run fmt cargo fmt --all -- --check
run clippy cargo clippy --workspace --all-targets -- -D warnings
# **Skips are refused where the data is** (decision 2329). `wow_data_or_skip!` and
# `addon_corpus_or_skip!` pass without asserting on a machine that lacks the install or the addon
# corpus, and libtest swallows a passing test's stderr — so on THIS machine a resolver that has
# drifted reads as green, which is how thirty corpus tests skipped at every land for three weeks
# after the pool moved drives. Where both resolve, `BENILLA_REQUIRE_DATA=1` turns a skip into the
# failure it is. Both, not either: with the install alone every corpus test would fail for the
# honest reason. Ask the resolver for the install (the same `where` the player-tests rung and the
# enforcer use); the corpus is the symlink `wt.sh link_wow` lays at the tree's root.
wow_data="$(resolve_wow)"
require_data=""
if [ -n "$wow_data" ] && [ -d "$root/wow-addons-vanilla" ]; then
require_data=1
else
echo "gates: NOTE — install or addon corpus not found here (install='${wow_data:-none}'," \
"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} cargo test --workspace
# **doc-links** (decision 1925) — the docs are the knowledge base, so a doc link pointing at a
# DELETED item is rot, and nothing else here runs rustdoc. Deliberately narrow: it fails only on a
# `crate::`/`super::`/`self::`/`Self::` path whose leaf exists nowhere in the workspace. The broad
# `-D rustdoc::broken_intra_doc_links` was measured and rejected — 818 of its warnings are
# conventions this codebase writes on purpose (docs naming private internals by path, one crate
# naming another's module). The script's own header carries that measurement. ~22 s warm.
run doc-links scripts/doc-links.py
# **pass-span-lint** (decision 2258, bug B390) — one `pass_span` per render pass at a time. bevy's
# pass span is a pipeline-statistics query as well as a timestamp pair, wgpu allows one active at
# a time, and only Vulkan exposes the feature: Metal (this machine) and DX12 (the lab laptop's
# pinned backend) run a nested span as a silent no-op, which is how the 09-15 sync aborted every
# Linux and Vulkan-on-Windows player on their first world frame with every gate green. The runtime
# fact no compile or macOS run can reach, read off the source in a tenth of a second.
run pass-span-lint scripts/pass-span-lint.py
# **The player build** (decision 1173's deliverable; built in 1174). `benilla` is the binary a
# player would run, and `--no-default-features` drops the `dev` feature — so the debug panel, the
# perf HUD, the inspector, the capture harness and the probe fleet are not compiled at all. It
# fails the moment anything outside those roots names one of them.
#
# This line IS the mechanism, not the module boundary above it. 0026 adopted "add no new
# gameplay→dev coupling" in June 2026 with nothing to fail; by August there were 24 references
# across 12 files, because a rule with no failing build is a wish (1173). Same lesson as the
# enforcer below: a tripwire nobody trips is not a tripwire.
#
# `build`, not `test`: the seam's claim is that the player binary LINKS without the instruments.
# The tests are dev-side by construction (they drive fixtures and probes) and stay on the default
# feature set, where `cargo test --workspace` above already runs them.
run player-build cargo build -p benilla --no-default-features
# **The player build's own unit tests** (decision 1175). A build proves the seam LINKS; it cannot
# prove the player configuration BEHAVES. The two facts 1175 rests on are both `#[cfg(not(feature =
# "dev"))]` — the install resolver looks nowhere inside the source tree, and the state folder lands
# beside the binary — so `cargo test --workspace` above, which runs with default features, never
# executes either of them. A falsifier that never runs is 1173's wish with a `#[test]` on it.
#
# `--lib` only: the integration tests and examples are dev-side by construction. Measured at ~7 s
# warm, because these two crates' unit tests are pure.
#
# **`$WOW_DATA` is handed in, and that is not a loophole.** Decision 1751 began sourcing FrameXML
# off the player's own patch chain, so the shipped-UI tests in this same `--lib` set now need an
# install — while the whole point of `--no-default-features` is that the resolver's *source-tree*
# rung is compiled out. The two collided silently: 27 tests here failed for a missing
# `Interface\FrameXML\ContainerFrame.xml` on every machine whose shell did not happen to export
# the variable, and the gate had been red for that reason rather than for anything a commit did.
# `$WOW_DATA` is rung ONE of the ladder, not the dev rung, so both facts 1175 asks this gate to
# falsify still hold: nothing here reads the source tree, and `install.rs`'s own tests take their
# environment as arguments (that is why they do) and cannot see this at all.
#
# Ask the resolver, don't re-derive it — the same rule, and the same `where`, as the enforcer
# below; `$wow_data` was resolved once, ahead of the test gate. No install found: run it bare,
# and let the client-data tests say so themselves (`BENILLA_REQUIRE_DATA` is deliberately NOT
# handed down: rung 2 is compiled out here, and what this rung falsifies is the player ladder,
# not the resolver — a skip here would be the honest one).
run player-tests env ${wow_data:+WOW_DATA="$wow_data"} \
cargo test -p benilla-formats -p benilla-app --no-default-features --lib
# **The enforcer** (decision 1160's second binary, made into a gate by 1164). `benilla-worldview`
# boots the engine plugin set with no server, no login, no UI and no player, and any system whose
# parameters need a gameplay resource reports itself. It is the only check for coupling that
# crosses NO SYMBOL — a `Res<player::Player>` read is invisible to the compiler and to the API wall
# test, and shows up only at runtime.
#
# It is here because it rotted: the binary sat broken on main, failing on frame one, for as long as
# nobody happened to run it. A tripwire nobody trips is not a tripwire.
#
# ~15 s, and it opens a small cornered window (`bgwin`'s no-pixel rule) — it reads the error log,
# not the framebuffer. Skipped, loudly, without the 1.12.1 install: the gate cannot demand an asset
# tree the repo is forbidden to contain.
# **Ask the resolver, don't re-derive it** (decision 1175). This used to read
# `[ -n "$WOW_DATA" ] || [ -d WoW/Data ]` — a second, hand-rolled copy of the rule for where the
# install is, which is the exact duplication that record exists to end. A gate that disagrees with
# the client skips on a machine where the client would have run, or runs where it cannot.
if cargo run -q -p benilla-formats --example where >/dev/null 2>&1; then
run enforcer env WOW_WORLDVIEW_CHECK=10 cargo run -q -p benilla-worldview
else
echo "gate SKIPPED: enforcer (no WoW install found — the engine boot check needs one;"
echo " \`cargo run -p benilla-formats --example where\` says where it looked)"
note_timing enforcer 0 SKIPPED
fi
# **The enforcer again, with NO install** (decision 1451) — the boot every player who unzips
# benilla into the wrong folder takes, and the one nothing on this machine could take: a dev build
# finds the project folder, so `wow_data()` never returned `None` in any run we make. Nobody had
# to *break* that path; it simply drifted, and by August a `Startup` that inserts a resource only
# when there is client data faced an `Update` that took it as a hard `Res`. Bevy's default handler
# panics on a parameter that cannot validate, so the client died on frame one with a system name it
# could not even print. `WOW_DATA=` (set, empty) is `benilla_formats::install`'s spelling of "there
# is no install", and the check's error handler collects every such fault instead of dying on the
# first — so this run names them all, the day they land.
#
# No `if`: this gate is the one that needs no install. ~5 s (nothing to stream — every system is
# offered to the executor, which is what validates its parameters, within the first frames), and
# the same small cornered window as above. It covers the engine crate, which is where the
# world-shaped resources live; the client's own no-install boot is still eyes-on.
run enforcer-no-install env WOW_DATA= WOW_WORLDVIEW_CHECK=5 cargo run -q -p benilla-worldview
echo "ALL GATES GREEN ($((SECONDS - chain_t0)) s; per-gate seconds in $timing)"
note_timing chain "$((SECONDS - chain_t0))" ok
# Stamp the verdict — but only if the tree is still the one the chain actually ran on: parallel
# agents share this working tree (docs/METHOD.md, "Who types what"), and a stamp for a half-edited tree
# would memoize a green nothing verified. A changed key just means no stamp; nothing fails.
key_end="$(tree_key || true)"
if [ -n "$key_end" ] && [ "$key_end" = "$key_start" ] && [ -d target ]; then
printf '%s|%s\n' "$key_end" "$wow_data" > "$stamp" 2>/dev/null || true
fi
# **The fourth gate has a runner too, and it is not this script** (2277). docs/METHOD.md asks for
# `fmt` · `clippy` · `test` · *a clean run*, and for a long time only the first three were a
# command — so "a clean run" was a paragraph in docs/METHOD.md, which a session can simply not have
# read. One did, decided it could not reach the server at all, and said so to the director while
# shipping a change whose entire subject was a session boundary.
#
# Not a rung: it wants the local vmangos, opens a window, and costs ~45 s, so paying it per commit
# is the director's call. A pointer costs nothing and makes the verb impossible to not know about.
echo " (a clean run is the fourth gate: scripts/smoke.sh — live logout/re-login round trip)"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
r"""Generate `crates/benilla-ui/src/messages/catalog.rs` from wow-5875-re's message-catalog.tsv.
The TSV is the sibling repo's byte-exact sweep of build 5875's `0xb4b498` registry -- the table
`CGGameUI::DisplayError 0x496720` indexes by message id. It is *their* artifact (regenerated there
with `re/ui/extract-message-catalog.py`); this script only re-shapes it into a Rust const, so the
table ships with benilla instead of being re-read out of a sibling checkout at build time.
python3 scripts/gen-message-catalog.py ../wow-5875-re/re/ui/message-catalog.tsv
Refuses rather than emits on anything it does not understand: a non-dense id range, a duplicate key,
an unknown kind. A generated table that quietly dropped a row would be worse than no table.
"""
import sys
KINDS = {0: "Chat", 1: "Info", 2: "Error"}
HEADER = """\
//! The 5875 message catalog, row for row.
//!
//! @generated by `scripts/gen-message-catalog.py` from wow-5875-re `re/ui/message-catalog.tsv`
//! (itself generated by that repo's `re/ui/extract-message-catalog.py` out of `WoW.exe`).
//! Do not edit by hand — regenerate.
use super::{MessageRecord, MsgKind};
/// Every row of `0xb4b498`, indexed by message id (the ids are dense `0..465`, so
/// `CATALOG[id]` *is* the reference's `0xb4b498 + 20*id`).
///
/// `rustfmt` is held off so that a regeneration is a no-op diff: one row per line is the shape
/// this script emits, and a formatter that rewrapped it would make the file drift from its own
/// generator.
#[rustfmt::skip]
pub static CATALOG: [MessageRecord; {count}] = [
"""
def main(path: str) -> int:
rows = []
for line in open(path):
if line.startswith("#") or line.startswith("id_hex"):
continue
f = line.rstrip("\n").split("\t")
if len(f) != 8:
raise SystemExit(f"malformed row (expected 8 fields, got {len(f)}): {line!r}")
rows.append(
{
"id": int(f[1]),
"key": f[3],
"kind": int(f[4]),
"sound": f[5],
"type_tag": int(f[6], 16),
"chat_type": int(f[7]),
}
)
for want, r in enumerate(rows):
if r["id"] != want:
raise SystemExit(f"ids are not dense: expected {want}, got {r['id']}")
keys = [r["key"] for r in rows]
if len(set(keys)) != len(keys):
raise SystemExit("duplicate key in the catalog — by_key would be ambiguous")
for r in rows:
if r["kind"] not in KINDS:
raise SystemExit(
f"row {r['id']} ({r['key']}) has kind {r['kind']}, which MsgKind does not model"
)
out = [HEADER.replace("{count}", str(len(rows)))]
for r in rows:
sound = "None" if r["sound"] == "NONE" else f'Some("{r["sound"]}")'
out.append(
f' MessageRecord {{ id: {r["id"]}, key: "{r["key"]}", '
f'kind: MsgKind::{KINDS[r["kind"]]}, sound: {sound}, '
f'type_tag: {r["type_tag"]:#04x}, chat_type: {r["chat_type"]} }},\n'
)
out.append("];\n")
sys.stdout.write("".join(out))
return 0
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit(__doc__)
sys.exit(main(sys.argv[1]))
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Regenerate `reference/1.12-events.tsv` — every FrameScript event the 1.12.1 client can
dispatch, with the ARGUMENTS its producers push.
scripts/gen-reference-events.py [--wow-re DIR] [--out FILE]
**Why this exists** (decision 2140). benilla already gates two halves of the event seam: a stock
chain file listening for an event nothing fires
(`reference_ui::every_event_a_chain_file_registers_has_a_producer`, 1889) and an event we fire that
1.12 does not have (`every_event_we_fire_is_an_event_the_reference_has`, 1883). Both compare
NAMES. Nothing compared the *arguments*, and an event name is a plain string at both ends — so a
fire with the wrong argument count is silent on both sides, exactly as a binding with the wrong
return arity was before 1842. It is the same class of bug one API over, and it had three live
instances: `PLAYERBANKSLOTS_CHANGED`, `UNIT_PET_EXPERIENCE` and `UNIT_PET_TRAINING_POINTS`, the
last of which routes through `PetPaperDollFrame_OnEvent`'s `elseif ( arg1 == "pet" )` catch-all and
so reached nobody at all.
**The oracle is wow-5875-re's own two censuses**, vendored the way `1.12-shapes.tsv` is:
- `re/events/event-catalog.tsv` eventId -> name, off the name-pointer array at `.data 0xbe1198`
- `re/events/event-firesites.tsv` every `call` AND tail-`jmp` to `FrameScript_SignalEvent`
(`0x703e50`, no varargs) and `SignalEvent2` (`0x703f50`,
printf-style), with the FORMAT STRING each site pushes
**Three producer families, because a literal fire site is not the whole picture.** The firesites
census is over the two signal helpers' own call sites, and its own header says in capitals that an
event with no row there is not an event with no producer:
signal a `0x703e50` site — `__fastcall(ecx = id)`, a plain `ret`, ZERO Lua values.
signal2 a `0x703f50` site — the pushed format string is the argument shape.
unit-field-bridge the generic UpdateField -> event bridge (`0x51bbb0` registers one watch per
named unit-window field; `0x51bd50` -> `0x515e50` fans out over the unit
tokens and calls `0x703f50(id, "%s", token)`). Every event id below `0xb6`
that HAS a name is produced there and appears in no firesites row.
Cross-check: the 54 catalog ids under `0xb6` are exactly the id set
wow-re read out of the registration loop by hand
(`system/ui/scratch/unit-field-event-bridge.md` §2.1) — two independent
derivations, no difference.
token-fanout the same `0x515e50` fan-out reached with a LITERAL id in `edx` rather than a
computed one, so also `%s` and also absent from the firesites census. The
fourteen ids are wow-re's own list, in that file's header.
**Confidence, and what a consumer may gate on:**
exact every contributing producer's shape is known. Gate on this.
advisory a contributing site is one of the three whose pushed format DECLARES more varargs than
its caller pushes (`re/audit/signalevent2-fmt-args.py` audits all 149 and finds
exactly these): `0x496230 TRADE_REPLACE_ENCHANT`, `0x5e4527`/`0x5e7960 UPDATE_TICKET`.
Faithful clients must not copy a bug that hands Lua undefined values.
none the name is in the catalog and NO producer family reaches it here. **This is not a
claim that it has none** — see the census caveat above. Never gate on it.
Glue-space rows are dropped: `0x703d90` is called twice with different name populations, so the
same integer names a different event on the glue screen than in the world. wow-re blanks those
rows' names; this drops them by fire-site address as well, from the four TU ranges its header
names, so a future re-derivation that fills a name in cannot leak one through.
**The input lives outside the repo** (the sibling RE repo), so this is a manual regeneration, like
`gen-reference-globals.py` — not something CI can run.
"""
import argparse
import os
import sys
# The `Source\Glue\` TU ranges whose event ids belong to the GLUE name space, from
# `re/events/event-firesites.tsv`'s own header.
GLUE_SITES = [(0x46AA34, 0x46AA34), (0x46BC9D, 0x46C52E), (0x46E73D, 0x46E73D), (0x47461E, 0x47461E)]
# The three sites whose format string declares more varargs than the caller pushes.
DECLARES_MORE_THAN_IT_PUSHES = {0x496230: "TRADE_REPLACE_ENCHANT", 0x5E4527: "UPDATE_TICKET", 0x5E7960: "UPDATE_TICKET"}
# `0x515e50` callers carrying a LITERAL id — the token fan-out reached outside the generic bridge.
TOKEN_FANOUT_IDS = [0x10, 0x16, 0x1C, 0x1D, 0x1E, 0x29, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0x159, 0x197, 0x20A]
# `0x51bbb0`'s walk bound: `cmp esi,0xb6`. Every NAMED unit-window field below it is watched.
UNIT_WINDOW_FIELDS = 0xB6
# The zero-argument format, spelled so the column is never empty.
NONE = "()"
def rows(path):
for line in open(path, encoding="utf-8"):
if line.startswith("#"):
continue
f = line.rstrip("\n").split("\t")
if f and f[0] in ("eventId", "fire_site_va"):
continue
yield f
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--wow-re", default=os.path.expanduser("~/dev/wow-5875-re"))
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "..", "reference", "1.12-events.tsv"))
a = ap.parse_args()
catalog = {}
for f in rows(os.path.join(a.wow_re, "re/events/event-catalog.tsv")):
if len(f) >= 2 and f[1]:
catalog.setdefault(int(f[0]), f[1])
if len(catalog) < 300:
sys.exit(f"event-catalog.tsv gave only {len(catalog)} names — wrong path?")
# name -> {ids, producers, formats, notes}
ev = {}
def add(name, eid, producer, fmt, note=None):
e = ev.setdefault(name, {"ids": set(), "producers": set(), "formats": set(), "notes": []})
e["ids"].add(eid)
e["producers"].add(producer)
e["formats"].add(fmt)
if note and note not in e["notes"]:
e["notes"].append(note)
dropped_glue = 0
for f in rows(os.path.join(a.wow_re, "re/events/event-firesites.tsv")):
if len(f) < 4:
continue
va = int(f[0], 16)
dispatcher, eid, name = f[1], f[2], f[3]
if any(lo <= va <= hi for lo, hi in GLUE_SITES):
dropped_glue += 1
continue
if not name or eid == "dyn":
continue
fmt = NONE if dispatcher == "SignalEvent" else (f[4] if len(f) > 4 and f[4] else NONE)
note = None
if va in DECLARES_MORE_THAN_IT_PUSHES:
note = f"0x{va:x} declares more varargs than it pushes — do not copy"
add(name, int(eid), "signal" if dispatcher == "SignalEvent" else "signal2", fmt, note)
for eid, name in catalog.items():
if eid < UNIT_WINDOW_FIELDS:
add(name, eid, "unit-field-bridge", "%s")
for eid in TOKEN_FANOUT_IDS:
if eid in catalog:
add(catalog[eid], eid, "token-fanout", "%s")
# Every remaining catalog name, so the table is the whole surface and a consumer can tell
# "no producer found" from "not an event".
for eid, name in catalog.items():
if name not in ev:
ev.setdefault(name, {"ids": set(), "producers": set(), "formats": set(), "notes": []})["ids"].add(eid)
out = os.path.normpath(a.out)
with open(out, "w", encoding="utf-8") as fh:
fh.write(
"# The 1.12.1 client's FrameScript events and the ARGUMENTS their producers push.\n"
"# GENERATED — scripts/gen-reference-events.py. Source: wow-5875-re's\n"
"# re/events/event-catalog.tsv + event-firesites.tsv. Decision 2140.\n"
"#\n"
"# arg_formats `|`-separated, one per distinct producer shape. `()` = zero Lua values.\n"
"# Directives: %s string, %d/%u number, %f number. An event with two\n"
"# producers of different shapes carries both, and which one a given\n"
"# transition takes is a question about the transition, not the name.\n"
"# conf exact = gate on it. advisory = a contributing site declares more\n"
"# varargs than it pushes. none = no producer family reaches it HERE,\n"
"# which is not a claim that it has none; never gate on it.\n"
"# producers signal (0x703e50, no varargs) | signal2 (0x703f50, format-driven) |\n"
"# unit-field-bridge (0x51bbb0/0x51bd50 -> 0x515e50, always %s) |\n"
"# token-fanout (0x515e50 with a literal id, also %s).\n"
f"# {len(ev)} events, {sum(1 for e in ev.values() if e['producers'])} with a known producer;"
f" {dropped_glue} glue-space fire sites dropped.\n"
"name\tids\tproducers\targ_formats\tconf\tnote\n"
)
for name in sorted(ev):
e = ev[name]
conf = "none" if not e["producers"] else ("advisory" if e["notes"] else "exact")
fh.write(
"\t".join(
(
name,
"|".join(str(i) for i in sorted(e["ids"])),
"|".join(sorted(e["producers"])),
"|".join(sorted(e["formats"])) or "?",
conf,
"; ".join(e["notes"]),
)
)
+ "\n"
)
print(f"wrote {out}: {len(ev)} events, {sum(1 for e in ev.values() if e['producers'])} produced")
if __name__ == "__main__":
main()
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""Regenerate `reference/1.12-globals.tsv` — the 1.12.1 client's global namespace, with each name
attributed to whoever provides it.
scripts/gen-reference-globals.py [--wow-re DIR] [--out FILE]
**Why this exists** (decision 1189): every wrong number in the addon arc came from remembering a
list of WoW functions instead of asking the client. wow-5875-re already ships an addon
(`W5875Capture`) that dumps the in-world `_G` of the *running* reference client, and its output is
committed there as a fixture. This vendors that capture, attributes every name, and writes the
result as the one artifact benilla measures its API surface against.
**Three origins**, because "is this in 1.12?" and "whose job is it to provide?" are different
questions:
lua Lua 5.0's own runtime. We get these from mlua; neither we nor FrameXML write them.
engine The C client provides it. **This is the list benilla implements in Rust.**
framexml Defined by the shipped UI itself — its Lua, its XML, or a `$parent`-composed child of
one. Runs off the player's own patch chain at runtime (1751), never hardcoded in Rust.
Attribution is by *definition site*, computed from a complete 1.12 shipped-UI corpus:
- a name assigned or `function`-declared in shipped Lua (including a `<Script>` body inside a
FrameXML document) is FrameXML's. Indented assignments count: vanilla FrameXML leaks globals
out of function bodies (`button = getglobal(...)`) and those names really are in `_G`;
- a `name="..."` on any shipped XML element is FrameXML's — including `virtual="true"`, because
a virtual `<Font>` is a real font object and 1.12 registers named virtual frames too;
- a name formed as `<a shipped name><a $parent suffix>` is a composed child object
(`ContainerFrame1Item16IconTexture`), so also FrameXML's;
- LUA_5_0 below wins over all of the above: FrameXML *clobbers* `string` in three files
(`string = getglobal(...)`), which overwrites the stdlib table rather than defining it.
**Inputs live outside the repo** — the director's 1.12 install and the sibling RE repo — so this
is a manual regeneration, like `genmap.sh`, not something CI can run. It needs:
- `<wow-re>/WoW/_w5875_fixtures/item13/W5875Capture.lua` the captured in-world `_G`
- `<wow-re>/WoW/_extracted_framexml/` FrameXML, already extracted there
- `<wow-re>/WoW/Data/*.MPQ` for the twelve `Blizzard_*` addons
**The corpus is the part that is easy to get wrong** (decision 1188 §4 — three attempts were
needed, and the first two produce confidently wrong answers). FrameXML alone is not the shipped
UI: the twelve `Blizzard_*` addons live in the MPQs, and their `.lua` is *not* listed in their
`.toc` — it is pulled in by `<Script file=>` inside their XML. A corpus that misses either half
misattributes real FrameXML functions as the engine's. A complete one is 233 files, and this
script prints the count so a short one is visible rather than silent.
"""
import argparse
import os
import re
import subprocess
import sys
WOWRE_DEFAULT = "/Users/sam/dev/wow-5875-re"
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# The twelve addons that ship inside the MPQs. Their folders exist in a real install carrying only
# a `.pub` signature file, so the install cannot be the source — the archives are.
BLIZZARD_ADDONS = [
"Blizzard_AuctionUI", "Blizzard_BattlefieldMinimap", "Blizzard_BindingUI",
"Blizzard_CombatText", "Blizzard_CraftUI", "Blizzard_GMSurveyUI",
"Blizzard_InspectUI", "Blizzard_MacroUI", "Blizzard_RaidUI",
"Blizzard_TalentUI", "Blizzard_TradeSkillUI", "Blizzard_TrainerUI",
]
# Lua 5.0's own globals, as the 1.12 client actually exposes them. Note what is NOT here and is
# absent from the capture: `_G`, `print`, `require`, `dofile`, `loadfile`, and the `io`/`os`/
# `debug`/`coroutine` tables — the client sandboxes them away, which is a fidelity fact about the
# VM we present to addons, not an accident of the capture.
LUA_5_0 = {
"assert", "collectgarbage", "error", "gcinfo", "getfenv", "getmetatable", "ipairs",
"loadstring", "math", "next", "pairs", "pcall", "rawequal", "rawget", "rawset",
"setfenv", "setmetatable", "string", "table", "tonumber", "tostring", "type",
"unpack", "xpcall",
}
# The capture addon's own footprint, each verified against its source rather than guessed at.
# `W5875CaptureHolder` is its `CreateFrame(..., "W5875CaptureHolder", ...)`, `W5875CaptureDB` its
# `## SavedVariables`, `SLASH_W5875CAP1` its slash token. It ran inside the client to produce the
# fixture, so its globals are in the table it dumped; they are the instrument, not the client.
#
# `__framescript_meta` is deliberately NOT here despite looking like tooling: it is the *client's*
# own shared frame metatable, published to `_G` (wow-5875-re RF-0023, `system/ui/ui.md`), and
# benilla mirrors it at `script/object.rs`. Excluding it on the strength of its underscores made
# our own faithful global read as a superset.
CAPTURE_OWN = {"W5875CaptureDB", "W5875CaptureHolder", "SLASH_W5875CAP1"}
LUA_ASSIGN = re.compile(r"^[ \t]*([A-Za-z_][A-Za-z0-9_]*)\s*=[^=]", re.M)
LUA_FUNC = re.compile(r"^[ \t]*function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", re.M)
LUA_MULTI = re.compile(
r"^[ \t]*([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)+)\s*=[^=]", re.M
)
XML_ELEM = re.compile(r"<(\w+)([^>]*?)/?>", re.S)
XML_NAME = re.compile(r'\bname\s*=\s*"([^"]*)"')
PARENT_SUFFIX = re.compile(r'\bname\s*=\s*"\$parent([A-Za-z0-9_]*)"')
# Lua embedded in a FrameXML document, which only this pass will ever see. Two shapes, and
# missing either misattributes real FrameXML names to the engine: an inline `<Script>…</Script>`
# body (Fonts.xml defines CHAT_FONT_HEIGHTS that way), and every handler element under
# `<Scripts>` — `<OnLoad>`, `<OnEvent>`, `<PreClick>` and kin, which is where MainMenuBar.xml
# assigns SHOW_KEYRING and PERFORMANCEBAR_LOW_LATENCY. Matched by shape rather than by a list of
# handler names so a rarely-used one cannot be forgotten. `<Script file=>` is skipped: it
# references a file already in the corpus.
XML_SCRIPT = re.compile(
r"<((?:On|Pre|Post)\w+|Script)\b(?![^>]*\bfile\s*=)[^>]*>(.*?)</\1>", re.S
)
XML_REF = re.compile(rb'<(?:Script|Include)\s+file\s*=\s*"([^"]+)"', re.I)
def captured_globals(fixture):
"""`["globals"]["keys"]` out of the capture: the in-world `_G`, as (name, lua type)."""
recs, cur_t, inside = [], None, False
with open(fixture, encoding="utf-8", errors="replace") as f:
for line in f:
if not inside:
inside = line.strip() == '["globals"] = {'
continue
if '["count"]' in line:
break
m = re.match(r'\s*\["t"\] = "([^"]*)",', line)
if m:
cur_t = m.group(1)
continue
m = re.match(r'\s*\["name"\] = "(.*)",\s*$', line)
if m:
recs.append((m.group(1), cur_t))
cur_t = None
return recs
def join_ref(base, ref):
"""Resolve a reference against the including file's directory — `loader::join_ref`'s rule."""
ref = ref.replace("\\", "/").strip()
parts = ref[1:].split("/") if ref.startswith("/") else (base.split("/") if base else []) + ref.split("/")
out = []
for p in parts:
if p in ("", "."):
continue
if p == ".." and out and out[-1] != "..":
out.pop()
else:
out.append(p)
return "/".join(out)
def build_corpus(wowre):
"""Every file of the 1.12 shipped UI, as {corpus-relative path: text}."""
archives = [f"{wowre}/WoW/Data/{a}.MPQ" for a in ("patch-2", "patch", "interface", "base")]
archives = [a for a in archives if os.path.exists(a)]
mpqcat = os.path.join(REPO, "target", "debug", "examples", "mpqcat")
if not os.path.exists(mpqcat):
subprocess.run(
["cargo", "build", "-q", "-p", "benilla-mpq", "--example", "mpqcat"],
cwd=REPO, check=True,
)
def mpq_read(archive_path):
for arc in archives:
r = subprocess.run([mpqcat, arc, archive_path], capture_output=True)
if r.returncode == 0:
return r.stdout
return None
files = {}
fx = os.path.join(wowre, "WoW", "_extracted_framexml")
for fn in sorted(os.listdir(fx)):
p = os.path.join(fx, fn)
if os.path.isfile(p):
files[f"FrameXML/{fn}"] = open(p, "rb").read()
print(f" FrameXML: {len(files)} files")
for addon in BLIZZARD_ADDONS:
toc_rel = f"{addon}/{addon}.toc"
toc = mpq_read("Interface\\AddOns\\" + toc_rel.replace("/", "\\"))
if toc is None:
print(f" !! {addon}: no .toc in any archive", file=sys.stderr)
continue
files[toc_rel] = toc
queue, seen = [], set()
for line in toc.decode("utf-8", "replace").splitlines():
line = line.strip()
if line and not line.startswith("#"):
queue.append(join_ref(addon, line))
while queue:
rel = queue.pop(0)
if rel in seen:
continue
seen.add(rel)
data = mpq_read("Interface\\AddOns\\" + rel.replace("/", "\\"))
if data is None:
print(f" !! {addon}: missing {rel}", file=sys.stderr)
continue
files[rel] = data
# The `.lua` is reached from inside the XML, never from the `.toc` — follow it, or
# every Lua-side API in these twelve addons misattributes to the engine.
if rel.lower().endswith(".xml"):
base = rel.rsplit("/", 1)[0] if "/" in rel else ""
for m in XML_REF.finditer(data):
queue.append(join_ref(base, m.group(1).decode("utf-8", "replace")))
print(f" {addon}: {1 + len(seen)} files")
return {k: v.decode("utf-8", "replace") for k, v in files.items()}
def lua_defs(text, into):
into.update(LUA_ASSIGN.findall(text))
into.update(LUA_FUNC.findall(text))
for grp in LUA_MULTI.findall(text):
into.update(n.strip() for n in grp.split(","))
def shipped_names(corpus):
"""Every global the shipped UI defines, plus the `$parent` suffixes children compose with."""
names, suffixes = set(), set()
for rel, text in corpus.items():
if rel.lower().endswith(".lua"):
lua_defs(text, names)
elif rel.lower().endswith(".xml"):
for _tag, body in XML_SCRIPT.findall(text):
lua_defs(body, names)
suffixes.update(s for s in PARENT_SUFFIX.findall(text) if s)
for tag, attrs in XML_ELEM.findall(text):
# `<Binding name="ACTIONBUTTON1">` is a KEY-BINDING command name, not a global —
# it never reaches `_G`. It shares the `name=` attribute with every frame element,
# so a shape-blind harvest pulls all 228 of `Bindings.xml`'s commands in. Harmless
# while this set was only used for *attribution* (none of them is in the capture,
# so none could be misattributed); wrong now that the set also contributes rows
# (decision 1200).
if tag.lower() == "binding":
continue
m = XML_NAME.search(attrs)
if m and "$parent" not in m.group(1):
names.add(m.group(1))
return names, suffixes
def composed(name, roots, suffixes, depth=0):
"""Is `name` `<a shipped name><a $parent suffix>`, recursively? A numeric tail on the parent
side is allowed: `ContainerFrame1` and `Item16` come from Lua's `name..i`."""
if depth > 8:
return False
for i in range(1, len(name)):
head, tail = name[:i], name[i:]
if tail not in suffixes:
continue
if head in roots or head.rstrip("0123456789") in roots:
return True
if composed(head, roots, suffixes, depth + 1):
return True
return False
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--wow-re", default=WOWRE_DEFAULT)
ap.add_argument("--out", default=os.path.join(REPO, "reference", "1.12-globals.tsv"))
args = ap.parse_args()
fixture = os.path.join(args.wow_re, "WoW", "_w5875_fixtures", "item13", "W5875Capture.lua")
if not os.path.exists(fixture):
sys.exit(f"no capture fixture at {fixture} — is --wow-re right?")
print("building the 1.12 shipped-UI corpus...")
corpus = build_corpus(args.wow_re)
print(f" TOTAL: {len(corpus)} files" + ("" if len(corpus) == 233 else " ** expected 233 **"))
recs = [(n, t) for n, t in captured_globals(fixture) if n not in CAPTURE_OWN]
defined, suffixes = shipped_names(corpus)
print(f"captured globals: {len(recs)} shipped definitions: {len(defined)} "
f"$parent suffixes: {len(suffixes)}")
rows, tally = [], {}
captured = set()
for name, t in recs:
captured.add(name)
if name in LUA_5_0:
origin = "lua"
elif name in defined or composed(name, defined, suffixes):
origin = "framexml"
else:
origin = "engine"
rows.append((name, t, origin))
tally[(origin, t)] = tally.get((origin, t), 0) + 1
# ── The LoadOnDemand half the capture could not see (decision 1200) ──────────────────────
#
# The fixture is the in-world `_G` of a *running* client, and the twelve `Blizzard_*` addons
# are LoadOnDemand: unless the player had opened the talent window, the trade-skill window and
# the rest before the dump, their globals are not in it. `TalentFrame`, `MacroFrame`,
# `TradeSkillFrame`, `CraftFrame`, `TrainerFrame`, `InspectFrame` and ~350 of their children
# are real 1.12 names that the capture alone reports as absent — which made benilla's own
# transcriptions of those windows look like they were inventing names.
#
# So the table is the capture UNION every global the shipped UI *defines*. That is the honest
# membership test for the question we actually ask it — "is this a 1.12 name?" — and it is
# strictly better than the capture alone, which answers "was this name live at one moment in
# one session". The two halves stay distinguishable: a name only the corpus knows gets type
# `lod`, so a consumer that cares can tell.
#
# Type inference is deliberately coarse: a `function X(` declaration is a `function`, an XML
# `name=` is a frame (`table`), and a bare assignment could be anything, so it says `lod`
# rather than guessing. Nothing downstream keys on the type of these rows.
lod = 0
for name in sorted(defined - captured):
rows.append((name, "lod", "framexml"))
tally[("framexml", "lod")] = tally.get(("framexml", "lod"), 0) + 1
lod += 1
print(f"shipped-UI definitions the capture did not contain: {lod} (LoadOnDemand + unrealized)")
os.makedirs(os.path.dirname(args.out), exist_ok=True)
with open(args.out, "w") as f:
f.write("# The 1.12.1 client's global namespace. GENERATED — scripts/gen-reference-globals.py.\n")
f.write("# Source: wow-5875-re's W5875Capture fixture (the running client's in-world _G),\n")
f.write("# attributed against the complete 1.12 shipped-UI corpus. Decision 1189.\n")
f.write("# UNION every name that corpus defines but the capture did not contain (type\n")
f.write("# `lod`): the twelve Blizzard_* addons are LoadOnDemand, so a live dump misses\n")
f.write("# them unless the player had opened those windows. Decision 1200.\n")
f.write("# name\ttype\torigin(lua|engine|framexml)\n")
for row in sorted(rows):
f.write("\t".join(row) + "\n")
print(f"\nwrote {args.out} ({len(rows)} names)")
for k in sorted(tally):
print(f" {k[0]:9s} {k[1]:9s} {tally[k]}")
main()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Regenerate `reference/1.12-verb-events.tsv` — every FrameScript event the 1.12.1 client fires
FROM INSIDE A LUA VERB, keyed by the verb.
scripts/gen-reference-verb-events.py [--wow-re DIR] [--out FILE]
**Why this exists.** `reference/1.12-events.tsv` (2140) says what arguments an event carries and
`reference_ui`'s two name gates (1883/1889) say whether *something* fires what a stock file listens
for. None of them says WHO fires it. That gap is the one B389/2244 fell through: the reference's
`SetTrainerServiceTypeFilter` commits through a helper whose whole body is "write the mask, re-run
the finalizer, fire TRAINER_UPDATE", the stock `Blizzard_TrainerUI.lua` therefore never repaints
the list itself, and benilla's verb set the mask and fired nothing. The name gate was green,
because the packet arm fires the same event; the tests were green, because they asserted the mask.
Our own retired `TrainerFrame.xml` had been repainting explicitly, so the behaviour left with the
file that was compensating for it — and the same shape is waiting in every other window that went
stock (1751): a verb the stock Lua calls *for its side effect of an event*.
**What a row means.** `verb` is a registered Lua binding (wow-re's `re/audit/binding-shapes.tsv`)
and `event` is fired on the verb's own call path in the reference — either from the verb's body
(`shape=body`) or from a helper that ONLY registered verbs call (`shape=helper`, `via` = the helper's
address; `0x4d8c90` is the trainer's). A helper with any non-verb caller — a packet handler, the
cursor, the tutorial signal — is excluded by construction: an event fired from there is a *state*
event, which benilla may faithfully fire from its own feeds, and demanding it of the verb would be
the exceptions list nobody wants to maintain. Hubs (`ClearCursor` → CURSOR_UPDATE, 49 callers) fall
out the same way.
**How the enclosing function is found (2257).** A site's function is the greatest start at or
below it, over the union of: every function wow-re's ledgers size (`size=` on the `fn` rows of
`system/*/ledger.tsv`, ~13k verified extents), the `names-5875.tsv` starts, every `call` target in
the full disassembly (`system/ui/scratch/disasm-full.txt`), every registered verb, and every address
that follows a padding run — an `int3` run anywhere, or a `nop` run that follows a `ret` (MSVC's
inter-function padding; a `nop` run after a `jmp` is a loop head and is NOT a boundary, which is
the phantom-start trap the first derivation fell into). Then the extent check: a site that lies at
or past `start + size` of a sized function is left UNATTRIBUTED rather than handed to that
function. Validated against the ledgers: of ~26k padding-derived starts, 49 fall strictly inside a
sized function, all in a handful of oversized rows. The first derivation (2251) had neither the
extents nor the `ret`-`nop` rule, and four of its rows were wrong the same way: a fire site in an
item-cache *callback* reached only by function pointer (`0x4ce2f0`, `0x4cba50`), a site past a
verified extent (`0x4a7835` beyond `0x4a7620`'s 191 bytes), and a fire on a shared stop path past
its worker's 34 bytes (`0x48f095`). The two families dropped explicitly below are unchanged:
glue-space verbs (the GlueXML registrar; 0x46a000..0x476000, the range 2140's `GLUE_SITES` lie in)
and the unit-field bridge / token fan-out ids below 0xb6, which are state events by definition.
**A floor, not a census.** No row for a verb is not a claim that the verb fires nothing: the
fire-site census is over the two signal helpers' literal call sites (its header says so in
capitals), and `shape=helper` stops one call deep on purpose. Gate on what IS here.
The input lives outside the repo (the sibling RE repo), so this is a manual regeneration, like
`gen-reference-events.py` — not something CI can run.
"""
import argparse
import bisect
import collections
import glob
import os
import re
import sys
# The GlueXML registrar's verbs and fire sites (see the header): the same integer names a
# different event on the glue screen than in the world (2140 drops these too).
GLUE = (0x46A000, 0x476000)
# `0x51bbb0`'s walk bound: every named unit-window field below it is the generic bridge (2140).
UNIT_WINDOW_FIELDS = 0xB6
# A helper "only verbs call" is trusted up to this many callers; past it, it is a hub.
MAX_HELPER_CALLERS = 8
LINE = re.compile(r"^\s*([0-9a-f]+):\t(\S+)(?:\s+(.*))?$")
BRANCH = re.compile(r"^(call|jmp)\s+0x([0-9a-f]+)\s*$")
SIZE = re.compile(r"\bsize=(\d+)")
def rows(path):
for line in open(path, encoding="utf-8"):
if line.startswith("#"):
continue
f = line.rstrip("\n").split("\t")
if f and f[0] in ("name", "addr", "eventId", "fire_site_va"):
continue
yield f
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--wow-re", default=os.path.expanduser("~/dev/wow-5875-re"))
ap.add_argument(
"--out",
default=os.path.join(os.path.dirname(__file__), "..", "reference", "1.12-verb-events.tsv"),
)
a = ap.parse_args()
starts = set()
for f in rows(os.path.join(a.wow_re, "re/names/out/names-5875.tsv")):
if len(f) >= 2 and f[1] == "func":
starts.add(int(f[0], 16))
if len(starts) < 1000:
sys.exit(f"names-5875.tsv gave only {len(starts)} functions — wrong path?")
# The verified extents: `size=` on the ledgers' `fn` rows (Ghidra's, via Closure.java).
extent = {} # fn start -> size in bytes
for path in glob.glob(os.path.join(a.wow_re, "system/*/ledger.tsv")):
for line in open(path, encoding="utf-8", errors="replace"):
if not line.startswith("fn\t"):
continue
f = line.rstrip("\n").split("\t")
m = SIZE.search(line)
if m and len(f) > 1 and f[1].startswith("0x"):
fn = int(f[1], 16)
extent[fn] = max(extent.get(fn, 0), int(m.group(1)))
if len(extent) < 5000:
sys.exit(f"the ledgers gave only {len(extent)} sized functions — wrong path?")
starts |= set(extent)
verbs = {} # fn address -> name
for f in rows(os.path.join(a.wow_re, "re/audit/binding-shapes.tsv")):
if len(f) < 2:
continue
fn = int(f[1], 16)
if GLUE[0] <= fn < GLUE[1] or f[0].startswith("Get"):
# A getter that fires an event is the cache-miss re-query (`GetInboxText` asking for
# a body it does not hold yet, `GetAuctionItemInfo` for an item), announced when the
# answer lands. That is a state event, and benilla's feeds fire it off the packet;
# it is not a verb called FOR its event, which is the class this table is for.
continue
verbs.setdefault(fn, f[0])
if len(verbs) < 1000:
sys.exit(f"binding-shapes.tsv gave only {len(verbs)} verbs — wrong path?")
starts |= set(verbs)
sites = [] # (site va, event name)
for f in rows(os.path.join(a.wow_re, "re/events/event-firesites.tsv")):
if len(f) < 4 or not f[0].startswith("0x") or not f[3] or f[2] == "dyn":
continue
va, eid = int(f[0], 16), int(f[2])
if GLUE[0] <= va < GLUE[1] or eid < UNIT_WINDOW_FIELDS:
continue
sites.append((va, f[3]))
if len(sites) < 300:
sys.exit(f"event-firesites.tsv gave only {len(sites)} sites — wrong path?")
# One pass over the disassembly: every call/jmp edge, and every padding boundary — an `int3`
# run anywhere, or a `nop` run right after a `ret`. Not a `nop` run after a `jmp`: that is a
# loop head's alignment inside a function, and taking it as a start manufactured phantom
# functions nothing calls, which silently dropped a third of the table once.
edges = []
after_pad = False
ended = False # the previous real instruction was a `ret`
n_lines = 0
for line in open(os.path.join(a.wow_re, "system/ui/scratch/disasm-full.txt"), encoding="utf-8", errors="replace"):
m = LINE.match(line)
if not m:
continue
n_lines += 1
va, mnem, ops = int(m.group(1), 16), m.group(2), m.group(3) or ""
if mnem == "int3" or (mnem == "nop" and (ended or after_pad)):
after_pad = True
continue
if after_pad:
starts.add(va)
after_pad = False
ended = mnem == "ret"
b = BRANCH.match(f"{mnem} {ops}".strip())
if b:
edges.append((va, b.group(1), int(b.group(2), 16)))
if n_lines < 1_000_000:
sys.exit(f"disasm-full.txt gave only {n_lines} lines — wrong path?")
starts |= {t for _, k, t in edges if k == "call"}
ordered = sorted(starts)
def enclosing(va):
"""The function holding `va`, or None when `va` lies past its candidate's verified extent."""
fn = ordered[bisect.bisect_right(ordered, va) - 1]
if fn in extent and va >= fn + extent[fn]:
return None
return fn
callers = collections.defaultdict(set)
for va, kind, target in edges:
if kind == "call" or target in starts: # a jmp to a function start is a tail call
src = enclosing(va)
if src is not None and src != target:
callers[target].add(src)
# (verb, event) -> {"shape", "via", "sites"}
table = {}
def add(verb, event, shape, via, site):
row = table.setdefault((verb, event), {"shape": shape, "via": via, "sites": set()})
if shape == "body": # a body fire outranks a helper fire for the same pair
row["shape"], row["via"] = "body", "-"
row["sites"].add(site)
unattributed = 0
for va, event in sites:
fn = enclosing(va)
if fn is None:
unattributed += 1
continue
if fn in verbs:
add(verbs[fn], event, "body", "-", va)
continue
cs = callers.get(fn, set())
if cs and len(cs) <= MAX_HELPER_CALLERS and all(c in verbs for c in cs):
for c in cs:
add(verbs[c], event, "helper", f"0x{fn:x}", va)
out = os.path.normpath(a.out)
with open(out, "w", encoding="utf-8") as fh:
fh.write(
"# The 1.12.1 client's FrameScript events fired FROM INSIDE A LUA VERB, keyed by the verb.\n"
"# GENERATED — scripts/gen-reference-verb-events.py. Source: wow-5875-re's\n"
"# re/audit/binding-shapes.tsv + re/events/event-firesites.tsv + re/names/out/names-5875.tsv\n"
"# + system/ui/scratch/disasm-full.txt.\n"
"#\n"
"# shape body = a fire site inside the verb's own extent.\n"
"# helper = a fire site inside a function that ONLY registered verbs call\n"
"# (at most 8 of them); `via` is that helper. A helper with any\n"
"# non-verb caller is a state event's producer and is NOT here.\n"
"# sites the fire sites themselves, for the grep into wow-re.\n"
"# A floor, not a census: no row is not a claim of no fire (see the generator's header).\n"
"# Sites are attributed against the ledgers' verified function extents; a site past its\n"
"# candidate's extent is left out rather than mis-filed (decision 2257).\n"
f"# {len(table)} pairs over {len({v for v, _ in table})} verbs and"
f" {len({e for _, e in table})} events; {unattributed} site(s) past a verified extent.\n"
"verb\tevent\tshape\tvia\tsites\n"
)
for (verb, event), row in sorted(table.items()):
fh.write(
"\t".join(
(
verb,
event,
row["shape"],
row["via"],
"|".join(f"0x{s:x}" for s in sorted(row["sites"])),
)
)
+ "\n"
)
print(
f"wrote {out}: {len(table)} pairs, {len({v for v, _ in table})} verbs,"
f" {len({e for _, e in table})} events"
)
if __name__ == "__main__":
main()
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Generate docs/MAP.md — the "what's built" map — derived entirely from what's on disk so it cannot
# drift (docs/METHOD.md / docs/METHOD.md: "the generated map … regenerated from the code, never
# hand-written"). `wt.sh land` runs it on the rebased tree and commits the file (decision 2049);
# run it by hand only to LOOK, never commit its output yourself, and never edit it by hand.
# Deterministic: identical tree → identical output (no timestamps), so a diff means the structure
# actually changed.
# No `pipefail`/`-e`: greps that legitimately find nothing (a single-file lib, a crate with no bins)
# must not abort the generator.
set -u
cd "$(dirname "$0")/.."
# Extract a quoted `key = "value"` field from a Cargo.toml.
field() { grep -m1 "^$2" "$1" 2>/dev/null | sed -E "s/^$2[[:space:]]*=[[:space:]]*\"//; s/\".*$//"; }
{
echo "# benilla — generated map"
echo
echo "> **GENERATED** by \`scripts/genmap.sh\` from the code — do not edit by hand; rerun the script"
echo "> when what's built changes. *What-is* only: the *what-changed* is in git. No timestamp —"
echo "> identical code yields an identical map."
echo
echo "## Crates"
echo
for ct in crates/*/Cargo.toml; do
name=$(field "$ct" name)
desc=$(field "$ct" description)
# `bevy.workspace = true` is the form every crate here actually uses, and the old pattern
# (`^bevy[[:space:]]*=`) matched only `bevy =` — so workspace-inherited deps read "no Bevy".
# benilla-assets shipped mislabelled long enough to say "(no Bevy) — Bevy AssetSource +
# AssetLoaders" in one line. The trailing class accepts `.`, whitespace and `=`, which covers
# `bevy.workspace =`, `bevy = { … }` and `bevy="…"`, while still rejecting a `bevyfoo` crate.
# Load-bearing beyond tidiness: "does this crate need Bevy" is the seam question 0068 cut
# `benilla-ui` on and 1160 cuts `benilla-world` on, so a map that lies about it misleads
# exactly the work that reads it.
if grep -qE '^(bevy|bevy_egui|avian3d)[.[:space:]=]' "$ct"; then bevy="Bevy"; else bevy="no Bevy"; fi
echo "- **$name** ($bevy) — ${desc:-—}"
done
echo
echo "## App subsystems — Bevy plugins in load order (\`crates/benilla-app/src/lib.rs\`)"
echo
# Accept bare (`add_plugins(FooPlugin)`) and path-qualified (`add_plugins(foo::FooPlugin)`)
# registrations; print the plugin type name either way.
grep -oE 'add_plugins\(([a-z_]+::)*[A-Za-z_]+Plugins?' crates/benilla-app/src/lib.rs \
| sed -E 's/add_plugins\(//; s/([a-z_]+::)+//' | awk '!seen[$0]++ {print "- " $0}'
echo
# The two dev groups are one `add_plugins` line each in `lib.rs` (decisions 1173/1174), so the
# list above names the group and not what is in it. Read their members out of `dev.rs` — a map
# that stops naming the instruments the moment they move behind the `dev` feature is exactly the
# drift this file exists to prevent.
echo "### Behind the \`dev\` feature (\`crates/benilla-app/src/dev.rs\` — compiled out by \`--no-default-features\`)"
echo
grep -oE 'add_plugins\(([a-z_]+::)*[A-Za-z_]+Plugins?' crates/benilla-app/src/dev.rs \
| sed -E 's/add_plugins\(//; s/([a-z_]+::)+//' | awk '!seen[$0]++ {print "- " $0}'
echo
echo "## Modules (top-level, per crate)"
echo
for lib in crates/*/src/lib.rs crates/*/src/main.rs; do
[ -f "$lib" ] || continue
crate=$(echo "$lib" | sed -E 's@crates/([^/]+)/.*@\1@')
mods=$(grep -E '^[[:space:]]*(pub(\([a-z]+\))? )?mod [a-z_0-9]+;' "$lib" \
| sed -E 's/.*mod ([a-z_0-9]+);.*/\1/' | sort | tr '\n' ' ')
[ -n "$mods" ] && echo "- **$crate** ($(basename "$lib")): $mods"
done
echo
echo "## CLI binaries"
echo
# Both spellings cargo accepts: `src/bin/<name>.rs` and the directory form `src/bin/<name>/main.rs`
# (benilla-extract, benilla-world) — the file-only glob listed three of five for months.
for b in crates/*/src/bin/*.rs crates/*/src/bin/*/main.rs; do
[ -f "$b" ] || continue
crate=$(echo "$b" | sed -E 's@crates/([^/]+)/.*@\1@')
case "$b" in
*/main.rs) name=$(basename "$(dirname "$b")") ;;
*) name=$(basename "$b" .rs) ;;
esac
echo "- \`$name\` (in $crate)"
done | LC_ALL=C sort
echo
# Every shader in the tree is compiled into the binary and addressed by the crate that owns it
# (decision 1175), so the crate is part of the shader's name now — list them all, per crate.
echo "## WGSL shaders (\`crates/*/src/shaders/\`, embedded)"
echo
for s in crates/*/src/shaders/*.wgsl; do
[ -f "$s" ] || continue
crate=$(echo "$s" | sed -E 's@crates/([^/]+)/.*@\1@' | tr - _)
echo "- \`embedded://$crate/shaders/$(basename "$s")\`"
done
echo
echo "## Instruments — \`\$WOW_*\` switches (env-var read sites)"
echo
echo "> The headless/dev instrument fleet, discovered from the code: every \`WOW_*\` env var"
echo "> some \`.rs\` reads, and where it's read — the doc comment at the read site is the"
# Don't name individual dev keys here — this line has gone stale twice (it still said 'P perf'
# after 0585 moved P onto the chord, and 'backtick panel' after 1043 moved the last two). The
# keys live in one place, \`debug_panel::DEV_CHORD\` and the panel footer; name the plane only.
echo "> semantics. (The in-window surfaces — the Ctrl+Shift dev-chord overlays — are plugins"
echo "> above; this indexes the switches that don't announce themselves.)"
echo
# Any quoted "WOW_*" literal in code — reads go through env::var but also through helpers
# (`knob("WOW_FX_AGE", …)`), so match the literal, not the call. Doc comments write the
# backticked/`$`-prefixed form, so they don't false-positive.
# The probe registry (`capture/probe_env.rs`, 2266 §A5) names every WOW_PROBE* variable as a
# quoted literal too; it is the table, not a read site, so it is not a place a switch is used.
grep -rHoE '"WOW_[A-Z0-9_]+"' crates --include='*.rs' 2>/dev/null \
| grep -v '^crates/benilla-app/src/capture/probe_env\.rs:' \
| sed -E 's@^crates/@@; s/:"/\t/; s/"$//' \
| awk -F'\t' '{print $2 "\t" $1}' | sort -u \
| awk -F'\t' '$1 != v { if (v) print line; v = $1; line = "- `" $1 "` — " $2; next }
{ line = line ", " $2 }
END { if (v) print line }'
echo
# Every script, with the first sentence of its own header (2331). The scripts are the
# instruments the WOW_* switches are read WITH — a trace reader, a two-client probe, a corpus
# census — and a 2026-09-22 sweep found eight of them referenced by nothing but the record that
# built them: findable only by someone who remembered the name. This is the index; the header
# is the source, so it cannot drift. (`winlab/` is the Windows lab laptop's, 2205/2211.)
echo "## Scripts (\`scripts/\`)"
echo
for f in scripts/*.py scripts/*.sh scripts/*.swift scripts/winlab/*; do
[ -f "$f" ] || continue
# The summary: the header's first non-empty content after the shebang, comment or docstring
# markers stripped, joined until its first blank line, cut at a sentence end or ~150 chars.
summary=$(LC_ALL=C awk '
NR == 1 && /^#!/ { next }
/^@echo off/ { next }
{ line = $0
sub(/^[[:space:]]*(r?"""|#|\/\/|rem)[[:space:]]?/, "", line)
if (line ~ /^[[:space:]]*$/) { if (got) exit; else next }
if (line ~ /^"""/) exit
got = 1; out = out (out ? " " : "") line }
END { print out }' "$f" | sed -E 's/\*\*//g; s/^[A-Za-z0-9_.\/-]+ (—|--) //; s/^([^.]*[.!?])([[:space:]]|$).*/\1/; s/^(.{150}).+/\1…/')
echo "- \`${f#scripts/}\` — $summary"
done
echo
} > docs/MAP.md
echo "genmap: wrote docs/MAP.md"
Executable
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
# leg.sh — the interleaved A/B leg runner (1353 law 1, mechanized).
#
# Runs N rounds of (binary A, binary B) at a pin, one slot, one body, enforcing the leg laws the
# campaign kept re-typing by hand:
# · load guard: waits for 1-min load average < 3 before every leg (1157)
# · lock guard: refuses to start — and stops between legs — while the screen is locked
# (1355: a locked screen throttles drawables at occluded_frames=0; the leg is invalid)
# · occlusion: a leg with occluded_frames != 0 is discarded and re-run, up to 2 retries (0731)
# · the whole FPS_PROBE line is kept per leg (never a grepped subset) under $LEG_DIR
#
# Usage:
# scripts/leg.sh <binaryA> <binaryB> [rounds] [rig]
# Env:
# WOW_USER/WOW_PASS/WOW_CHAR probe credentials (default probe0/pprobe0/Probezero — pool-0)
# LEG_RIG WOW_RIG spec (default: the LBRS pin, 0731)
# LEG_FRAMES / LEG_AT probe window (default 300 / 45)
# LEG_DIR where leg logs land (default: $CLAUDE_JOB_DIR/tmp or /tmp)
#
# Motion legs: export WOW_PROBE_KEY / WOW_PROBE_CAM alongside — they inherit into every leg,
# A and B alike — to measure a MOVING regime, e.g. WOW_PROBE_KEY="W@40:20"
# WOW_PROBE_CAM="30,10@40:25" with the default LEG_AT=45 puts the whole FPS window inside a
# straight walk with the camera panning (1427: parked pins cannot see the play regime the
# director reports). Streaming churn inside the window is the point, not noise — but quote
# motion legs only against motion legs.
#
# Output: one line per kept leg (cpu_ms fps sys_busy), then per-round deltas and the means.
set -euo pipefail
A="${1:?usage: leg.sh <binaryA> <binaryB> [rounds] [rig]}"
B="${2:?usage: leg.sh <binaryA> <binaryB> [rounds] [rig]}"
ROUNDS="${3:-3}"
RIG="${4:-${LEG_RIG:-at:229,24.90,-396.50,48.80}}"
USER_="${WOW_USER:-probe0}"; PASS_="${WOW_PASS:-pprobe0}"; CHAR_="${WOW_CHAR:-Probezero}"
FRAMES="${LEG_FRAMES:-300}"; AT="${LEG_AT:-45}"
DIR="${LEG_DIR:-${CLAUDE_JOB_DIR:-/tmp}/tmp}"; mkdir -p "$DIR" 2>/dev/null || DIR="/tmp"
locked() { ioreg -n Root -d1 -a | grep -A1 CGSSessionScreenIsLocked | grep -q true; }
loadwait() { until [ "$(uptime | sed -E 's/.*averages?: ([0-9]+)[.,].*/\1/')" -lt 3 ]; do sleep 10; done; }
run_leg() { # $1 binary $2 tag → prints "cpu_ms fps sys_busy" or fails
local bin="$1" tag="$2" try line occ fatal
for try in 1 2 3; do
if locked; then echo "leg.sh: screen is LOCKED — legs are invalid (1355); aborting" >&2; exit 2; fi
loadwait
WOW_UNATTENDED=1 WOW_USER="$USER_" WOW_PASS="$PASS_" WOW_CHAR="$CHAR_" WOW_RIG="$RIG" \
WOW_LIVE_FPS="$FRAMES" WOW_LIVE_FPS_AT="$AT" \
timeout 300 "$bin" >"$DIR/leg-$tag-$try.log" 2>&1 || true
line=$(grep -a "FPS_PROBE" "$DIR/leg-$tag-$try.log" | sed 's/\x1b\[[0-9;]*m//g' | tail -1)
# A FATAL is the client's own verdict that the run can never measure (refused login, missing
# character, no world by the boot deadline) — deterministic, so a retry buys the same failure.
# Abort the sitting loudly instead of burning tries × timeout (the 1371 legs sat 3 × 300 s at
# a login screen the account guard had already refused).
fatal=$(grep -a "FATAL" "$DIR/leg-$tag-$try.log" | sed 's/\x1b\[[0-9;]*m//g' | tail -1)
if [ -n "$fatal" ]; then
echo "leg.sh: $tag ABORTING the sitting — $fatal" >&2
echo "leg.sh: full log: $DIR/leg-$tag-$try.log" >&2
exit 4
fi
[ -n "$line" ] || { echo "leg.sh: $tag try $try produced no FPS_PROBE — see $DIR/leg-$tag-$try.log" >&2; continue; }
occ=$(sed -E 's/.*occluded_frames=([0-9]+).*/\1/' <<<"$line")
if [ "$occ" != "0" ]; then echo "leg.sh: $tag try $try occluded ($occ) — discarded unread" >&2; continue; fi
# Regime guard (1388 round 3): one round's legs once read 126.5 vs 60.0 fps under the same
# uncapped present mode — Metal displaySync is off either way, but the WindowServer rails a
# composited window's drawables at its display's rate, and only sometimes. Every campaign
# anchor is a railed leg (1380 "fps locked 60"), so a leg that escaped the rail measures a
# different regime and is discarded here, by machine, instead of by a human reading the
# round's deltas sideways. The probe line's display=...@hz stamp is the rail.
fps=$(grep -oE " fps=[0-9.]+" <<<"$line" | cut -d= -f2)
hz=$(grep -oE " display=[^ ]*@[0-9]+" <<<"$line" | sed 's/.*@//')
if [ -n "$fps" ] && [ -n "$hz" ] && awk -v f="$fps" -v h="$hz" 'BEGIN{exit !(f > h + 3)}'; then
echo "leg.sh: $tag try $try FREE-RUNNING (fps=$fps above the $hz Hz rail) — discarded" >&2; continue
fi
echo "$line" >>"$DIR/legs-kept.log"
grep -oE "cpu_ms=[0-9.]+" <<<"$line" | cut -d= -f2
return 0
done
echo "leg.sh: $tag failed 3 tries" >&2; exit 3
}
echo "leg.sh: $ROUNDS round(s), A=$(basename "$A") B=$(basename "$B"), rig=\"$RIG\", body=$CHAR_"
# Law 6 (1351/1353): the first leg after a build — or after the machine sat idle — is a warm-up,
# not a measurement. One discarded A-leg primes every cache the rounds then share.
# The assignment (not an inline $(...) in the echo) is load-bearing: run_leg's exit runs in the
# substitution subshell, and only an assignment's status is seen by set -e — inline, a warm-up
# abort would be discarded and the rounds would start anyway.
w=$(run_leg "$A" "warmup"); echo " warm-up (discarded): cpu_ms=$w"
declare -a AV BV
for r in $(seq 1 "$ROUNDS"); do
a=$(run_leg "$A" "r${r}A"); AV+=("$a"); echo " round $r A: cpu_ms=$a"
b=$(run_leg "$B" "r${r}B"); BV+=("$b"); echo " round $r B: cpu_ms=$b"
done
awk_list() { local IFS=,; echo "$*"; }
python3 - "$(awk_list "${AV[@]}")" "$(awk_list "${BV[@]}")" <<'EOF'
import sys
a = [float(x) for x in sys.argv[1].split(',')]
b = [float(x) for x in sys.argv[2].split(',')]
ma, mb = sum(a)/len(a), sum(b)/len(b)
print(f"mean A={ma:.2f} mean B={mb:.2f} Δ={mb-ma:+.2f} (rounds: {['%+.2f' % (y-x) for x,y in zip(a,b)]})")
EOF
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""mixsum.py — read a `$WOW_MIX_TAP` capture and say, in numbers, what the mix was doing when.
The tap (decision 1112) records the final mix to a stereo float-32 WAV. Until now the only way to
read one back was to listen to it — which is exactly the loop `docs/METHOD.md` step 4 forbids for a
timing question ("timing and feel are measured, never eyeballed"), and which cannot answer the two
questions a capture is usually taken for:
* **Was there a HOLE, and how long?** — "the login theme dies 2 s into a 6 s loading screen"
(decision 1550) is a silent span with a start and an end, not an impression.
* **Did the level do what the transition says it does?** — a 4.0 s linear fade-to-zero is a
straight line in dBFS; a cut is a cliff; a crossfade has an overlap. All three read off the
envelope.
So: a windowed RMS/peak envelope with a bar, plus a `spans` report that names every silent stretch
and every onset/offset edge. `--json` for a machine, the default for a session log.
scripts/mixsum.py entry.wav # 250 ms windows, the envelope + the spans
scripts/mixsum.py entry.wav --window 0.1 # finer, for a click or a declick ramp
scripts/mixsum.py entry.wav --from 2 --to 12 # just the covered window
scripts/mixsum.py entry.wav --quiet # the spans only, no per-window rows
Pure stdlib on purpose (no numpy on this machine, and an instrument that needs an install is an
instrument nobody runs). Python's `wave` module refuses format 3 (IEEE float), so the 44-byte
header the tap writes is parsed here directly — it is a frozen fact of `sound::mix_tap::wav_header`.
"""
import argparse
import json
import math
import struct
import sys
from array import array
# Below this a window is "silence" for span purposes. The tap is pre-clamp and dither-free, so true
# digital silence reads as -inf; -80 dBFS leaves room for a fade's last samples without calling an
# audible tail silent.
SILENCE_DBFS = -80.0
def read_tap(path):
"""(sample_rate, interleaved float32 array). Tolerates the tap's crash-safe truncated tail."""
with open(path, "rb") as f:
head = f.read(44)
if len(head) < 44 or head[0:4] != b"RIFF" or head[8:12] != b"WAVE":
sys.exit(f"mixsum: {path} is not a RIFF/WAVE file")
fmt, channels, rate, _brate, _align, bits = struct.unpack("<HHIIHH", head[20:36])
if (fmt, channels, bits) != (3, 2, 32):
sys.exit(
f"mixsum: expected stereo float-32 (fmt 3), got fmt={fmt} ch={channels} bits={bits}"
)
if head[36:40] != b"data":
sys.exit("mixsum: no `data` chunk where the tap header puts it")
raw = f.read()
# The declared size is patched per flush; a hard kill can leave the file shorter. Trust the
# bytes actually present, floored to whole frames.
usable = len(raw) - (len(raw) % 8)
samples = array("f")
samples.frombytes(raw[:usable])
if sys.byteorder == "big":
samples.byteswap()
return rate, samples
def db(x):
return -math.inf if x <= 1e-12 else 20.0 * math.log10(x)
def envelope(rate, samples, window_s, t_from, t_to):
"""Per-window (t, rms_dbfs, peak_dbfs) over the requested slice."""
per_win = max(1, int(rate * window_s)) * 2 # interleaved: 2 samples per frame
lo = min(len(samples), int(t_from * rate) * 2)
hi = len(samples) if t_to is None else min(len(samples), int(t_to * rate) * 2)
out = []
for start in range(lo, hi, per_win):
chunk = samples[start : min(start + per_win, hi)]
if not chunk:
break
acc = 0.0
peak = 0.0
for v in chunk:
acc += v * v
a = abs(v)
if a > peak:
peak = a
out.append((start / 2 / rate, db(math.sqrt(acc / len(chunk))), db(peak)))
return out
def spans(env, window_s, floor):
"""Contiguous runs of silence and of sound, as (kind, start, end, duration)."""
runs = []
for t, rms, _peak in env:
kind = "silence" if rms < floor else "sound"
if runs and runs[-1][0] == kind:
runs[-1][2] = t + window_s
else:
runs.append([kind, t, t + window_s])
return [(k, a, b, b - a) for k, a, b in runs]
def bar(rms_dbfs, floor=-72.0, width=40):
if rms_dbfs == -math.inf:
return ""
filled = int(round(max(0.0, (rms_dbfs - floor) / -floor) * width))
return "#" * max(0, min(width, filled))
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("wav", help="a $WOW_MIX_TAP capture")
ap.add_argument("--window", type=float, default=0.25, help="window seconds (default 0.25)")
ap.add_argument("--from", dest="t_from", type=float, default=0.0)
ap.add_argument("--to", dest="t_to", type=float, default=None)
ap.add_argument("--floor", type=float, default=SILENCE_DBFS, help="silence threshold, dBFS")
ap.add_argument("--quiet", action="store_true", help="spans only")
ap.add_argument("--json", action="store_true")
a = ap.parse_args()
rate, samples = read_tap(a.wav)
total = len(samples) / 2 / rate
env = envelope(rate, samples, a.window, a.t_from, a.t_to)
runs = spans(env, a.window, a.floor)
if a.json:
json.dump(
{
"path": a.wav,
"sample_rate": rate,
"duration_s": total,
"window_s": a.window,
"floor_dbfs": a.floor,
"windows": [
{"t": t, "rms_dbfs": None if r == -math.inf else r,
"peak_dbfs": None if p == -math.inf else p}
for t, r, p in env
],
"spans": [{"kind": k, "from": s, "to": e, "secs": d} for k, s, e, d in runs],
},
sys.stdout,
indent=2,
)
print()
return
print(f"mixsum: {a.wav} — {total:.1f}s stereo float-32 @ {rate} Hz, {a.window*1000:.0f} ms windows")
if not a.quiet:
for t, rms, peak in env:
r = " -inf" if rms == -math.inf else f"{rms:6.1f}"
p = " -inf" if peak == -math.inf else f"{peak:6.1f}"
print(f" {t:7.2f}s rms {r} peak {p} {bar(rms)}")
print(f"\nspans (silence = rms < {a.floor:.0f} dBFS):")
for kind, start, end, dur in runs:
print(f" {kind:8} {start:7.2f}s → {end:7.2f}s {dur:6.2f}s")
holes = [r for r in runs if r[0] == "silence" and r[1] > 0.0]
if holes:
longest = max(holes, key=lambda r: r[3])
print(f"\nlongest interior silence: {longest[3]:.2f}s at {longest[1]:.2f}s")
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""pass-span-lint — one `pass_span` per render pass at a time (decision 2258, bug B390).
bevy's `DiagnosticsRecorder::pass_span` is a timestamp pair PLUS a pipeline-statistics query, and
wgpu allows exactly one statistics query active at a time: a second `pass_span` opened on a pass
whose own span is still open is a validation error — on the backends that expose
`PIPELINE_STATISTICS_QUERY`, which in wgpu 27 is Vulkan alone (Metal never had it; wgpu-hal's
DX12 arm has the line commented out). So the gates on this machine (Metal) and the lab laptop
(pinned to DX12) run the nesting as a silent no-op, and the 09-15 sync shipped one: every Linux
player, and every Windows player wgpu had put on Vulkan, aborted on the first world frame (B390).
No compile, no test and no macOS run can see that shape. This check can, in a tenth of a second.
The shape it refuses, per function: `let g = <recorder>.pass_span(&mut P, …)` followed by another
`.pass_span(&mut P, …)` before `g.end(&mut P)`. Timestamp-only spans (`time_span`) nest fine and
are not looked at. Lexical, per file, reset at every `fn` — a span never outlives its function.
Usage: scripts/pass-span-lint.py [path …] (default: every crates/**/*.rs)
Exit: 0 clean, 1 findings.
"""
from __future__ import annotations
import pathlib
import re
import sys
OPEN = re.compile(r"\blet\s+(?:mut\s+)?(\w+)\s*=\s*[\w.]+\.pass_span\(\s*&mut\s+(\w+)\s*,")
ANY_OPEN = re.compile(r"\.pass_span\(\s*&mut\s+(\w+)\s*,")
END = re.compile(r"\b(\w+)\.end\(\s*&mut\s+(\w+)\s*\)")
FN = re.compile(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+\w+")
def lint(path: pathlib.Path) -> list[str]:
findings: list[str] = []
open_spans: list[tuple[str, str, int]] = [] # (guard, pass, line)
for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw.split("//", 1)[0]
if FN.match(line):
open_spans.clear()
m = OPEN.search(line)
if m:
guard, pass_name = m.group(1), m.group(2)
else:
m = ANY_OPEN.search(line)
guard, pass_name = (None, m.group(1)) if m else (None, None)
if pass_name is not None:
for outer_guard, outer_pass, outer_line in open_spans:
if outer_pass == pass_name:
findings.append(
f"{path}:{lineno}: a second pass_span on `{pass_name}` while "
f"`{outer_guard}` (line {outer_line}) is still open — one "
f"pipeline-statistics query at a time (2258, B390)"
)
if guard is not None:
open_spans.append((guard, pass_name, lineno))
e = END.search(line)
if e:
open_spans = [s for s in open_spans if s[0] != e.group(1)]
return findings
def main() -> int:
args = [pathlib.Path(a) for a in sys.argv[1:]] or [pathlib.Path("crates")]
files: list[pathlib.Path] = []
for a in args:
files.extend(sorted(a.rglob("*.rs")) if a.is_dir() else [a])
findings = [f for p in files for f in lint(p)]
for f in findings:
print(f)
print(f"pass-span-lint: {'clean' if not findings else f'{len(findings)} finding(s)'} — "
f"{len(files)} file(s)")
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env bash
# **The fourth gate, as a command.** docs/METHOD.md asks for `fmt` · `clippy` · `test` · **a clean run**,
# and until this existed only the first three had a runner. The fourth was a paragraph in
# `docs/METHOD.md`, which is a different thing from a verb: a session that does not happen to read that
# paragraph concludes it *cannot* run the client, says so to the director, and ships a change whose
# whole subject is a live session boundary without ever having crossed one. That happened (2277).
#
# What it does: boots the real client against the local vmangos on THIS SLOT's probe account, seats
# a body in the world, `/logout`s to character select, **re-enters**, and exits — then reads the log
# back. It is the smallest run that crosses every session edge, which is exactly the set of edges
# the unit tests can only model.
#
# It is deliberately NOT wired into `gates.sh`: it wants a server, opens a window, and costs ~45 s,
# so paying it on every commit is the director's call, not this script's. Run it when a change
# touches a session boundary — and the gates print a pointer at you either way.
#
# scripts/smoke.sh # this slot's probe account
# WOW_SMOKE_KEEP=1 scripts/smoke.sh # keep the log and print its path
set -uo pipefail
# The tree we are standing in, `gates.sh`'s rule verbatim: a session works in its own worktree and
# reaches for scripts by absolute path, so `$0`'s directory is the PRIMARY checkout and gating (or
# running) that instead is the bug that made the old gate chain lie.
root="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$root" ] || [ ! -f "$root/scripts/smoke.sh" ]; then
root="$(cd "$(dirname "$0")/.." && pwd)"
fi
cd "$root" || exit 1
echo "smoke: $root"
# **The smoke declares its whole environment; it does not inherit one.**
#
# Every leg below is a *run definition* — this account, this character or deliberately none, this
# smoke switch — and every one of them was previously composed against whatever `WOW_*` the calling
# shell happened to carry. The realm leg is where that stopped being theoretical: it names
# `WOW_USER`/`WOW_PASS` and pointedly NOT `WOW_CHAR`, because the walk drives *character select* and
# a seated body means there is no roster screen to drive. A session that had exported `WOW_CHAR` for
# its last probe — which is how every probe in `docs/METHOD.md` is launched — handed the leg the one
# thing it must not have, and the client dutifully entered the world while the walk waited for a
# screen that was never coming. The report was `the realm walk did not finish within the timeout`:
# 120 s, no cause, and nothing in the log that looks wrong.
#
# So the scrub is per-VARIABLE and up front, not per-leg: a gate whose result depends on the shell
# it was typed in is not a gate. Kept: `WOW_DATA` (where the install IS — a location, not a dial),
# `WOW_BG` (the director's "let me watch this one", which changes window stacking and nothing else),
# and `WOW_SMOKE_KEEP` (this script's own). Loud, because a session that meant to pass something
# should see it go rather than wonder why it did nothing.
inherited=""
for v in $(env | sed -n 's/^\(WOW_[A-Za-z0-9_]*\)=.*/\1/p'); do
case "$v" in WOW_DATA | WOW_BG | WOW_SMOKE_KEEP) continue ;; esac
inherited="$inherited $v"
unset "$v"
done
[ -n "$inherited" ] &&
echo "smoke: ignoring inherited env —$inherited (each leg names its own; a gate is not shell-dependent)"
# **The probe identity is keyed to the worktree slot** (docs/METHOD.md, "The local vmangos server"): a
# vmangos login KICKS whoever holds the account, so `one` is the director's live session and every
# other `probeN` is another session's. The slot claim is already the session mutex, so deriving the
# account from it makes probe exclusivity automatic. Refuse rather than guess.
slot="$(basename "$root" | sed -n 's/^pool-\([0-9]\)$/\1/p')"
if [ -z "$slot" ]; then
echo "smoke: REFUSING — not in a pool worktree (\`$root\`)."
echo " The probe account is keyed to the slot; logging in as the default account would"
echo " kick the director's live session. Claim one: scripts/wt.sh claim <name>"
exit 1
fi
# Spelled out literally, in docs/METHOD.md's own casing (`pool-0 → Probezero`) rather than derived: BSD
# sed has no `\U`, and deriving it cost a whole run to discover — the client dutifully reported
# `ProbeUzero` was not on the account, and then sat on the roster until the timeout.
names=(zero one two three four five six seven eight nine)
user="probe$slot"
pass="pprobe$slot"
char="Probe${names[$slot]}"
echo "smoke: slot $slot → $user / $char"
# The server, before the client: a refused connection reads as a client bug in the log and costs a
# full build to discover. Loud skip, never a silent pass — `gates.sh`'s posture for the install.
for port in 3724 8085; do
if ! (exec 3<>/dev/tcp/127.0.0.1/$port) 2>/dev/null; then
echo "smoke: SKIPPED — nothing listening on 127.0.0.1:$port (realmd 3724 / mangosd 8085)."
echo " The local vmangos lives at /Users/sam/dev/vmangos-deploy — \`docker compose up -d\`."
exit 0
fi
done
log="$(mktemp -t benilla-smoke)"
stamp="$(mktemp -t benilla-smoke-stamp)"
before="$(mktemp -t benilla-smoke-before)"
# The stamp and the file list always go; the LOG is the one a session may want to keep. Both in one
# trap so the early `fail` exits below cannot strand a temp file.
trap 'rm -f "$stamp" "$before"; [ -n "${WOW_SMOKE_KEEP:-}" ] || rm -f "$log"' EXIT
# **The install is READ-ONLY, and this is where that is measured** (decision 1486). benilla reads a
# WoW install; it never writes to one. The rule is easy to state and impossible to keep by memory —
# one `create_dir_all` under a path derived from `wow_data()` and a player's install has benilla's
# litter in it, on a folder that on this machine is SHARED with the sibling RE repo. So the fourth
# gate measures it: a full run of the real client, across a logout and a re-login, must leave the
# install byte-for-byte as it found it.
#
# Pure POSIX `find` on purpose — `stat`'s format flag is `-f` on BSD and `-c` on GNU, and a check
# that silently no-ops on the other platform is worse than none. The name list catches additions
# and deletions; `-newer` against a stamp touched just before the run catches modifications in
# place. ~7 ms over the 927-file tree.
install_root=""
if [ -n "${WOW_DATA:-}" ]; then
install_root="$WOW_DATA"
elif [ -d "$root/WoW" ]; then
install_root="$root/WoW"
fi
# Who ELSE might write in there. The install is shared — every pool slot symlinks the same tree,
# and the director runs the real 1.12 client against it constantly for RE comparison. That client
# writes its own WTF (Config.wtf, SavedVariables.lua, the per-character caches) while it runs, so a
# whole-tree mtime diff cannot tell "benilla wrote to the install" from "the reference client was
# open at the same time". Record it up front so the report below can name the right cause instead of
# blaming benilla for someone else's writes.
reference_client_before=""
if pgrep -f '[w]ow\.exe' >/dev/null 2>&1; then
reference_client_before=1
fi
if [ -n "$install_root" ]; then
find -L "$install_root" -type f 2>/dev/null | sort >"$before"
echo "smoke: install read-only watch on $install_root ($(wc -l <"$before" | tr -d ' ') files)"
[ -n "$reference_client_before" ] && \
echo "smoke: NOTE — the reference client (wow.exe) is running; the install watch cannot attribute"
fi
# **Build first, THEN time the run.** The timeout below bounds the *client*, and a cold slot's
# build is minutes — folding the two together fails the gate with "the client did not exit", which
# names the wrong thing and sends the next session hunting a hang that is a compile. Build errors
# still fail here, loudly and as themselves.
if ! cargo build -q -p benilla >"$log" 2>&1; then
echo "SMOKE FAILED: the client did not build"
sed -E 's/\x1b\[[0-9;]*m//g' "$log" | tail -30
exit 1
fi
echo "smoke: running the logout/re-login round trip (~45 s, opens a window)…"
# WOW_NOSOUND: the smoke is an agent's run, and `sound/mod.rs` opens no device under it — without
# it every smoke of every session played the login zone's music into the room for 45 s (2006).
WOW_UNATTENDED=1 WOW_NOSOUND=1 WOW_USER="$user" WOW_PASS="$pass" WOW_CHAR="$char" WOW_LOGOUT_SMOKE=1 \
timeout 180 cargo run -q -p benilla >"$log" 2>&1
code=$?
# Strip ANSI once: the tracing level is a coloured field, and matching `ERROR` against the raw line
# also matches `ErrorsFrame.xml` — a false positive that would train everyone to ignore the check.
plain="$(sed -E 's/\x1b\[[0-9;]*m//g' "$log")"
fail() {
echo "SMOKE FAILED: $1"
[ -n "${WOW_SMOKE_KEEP:-}" ] && echo " log: $log"
printf '%s\n' "$plain" | tail -30
exit 1
}
# Named before the timeout, because it IS the timeout's usual cause: an unseatable `WOW_CHAR` parks
# the client on the roster screen forever, and "did not exit" is the least useful way to say so.
printf '%s' "$plain" | grep -q "not on this account" &&
fail "$char is not on $user — the client sat at the roster (make it, or rig it: WOW_RIG=…)"
[ $code -eq 124 ] && fail "the client did not exit within the timeout"
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 second entry has to be DRIVABLE, not merely reached** (B306, decision 1542). The round trip
# crossed this boundary on every run since 2277 and only ever checked that it happened — so the
# report that arrived was a character who re-entered the world and could not move at all: vmangos
# roots you for the `/logout` countdown, we acked the grant, and the state outlived the session that
# was granted it. Nothing here could see that. Now the re-entry leg names whatever would kill WASD
# and this refuses anything but `none`.
sup="$(printf '%s\n' "$plain" | sed -n 's/.*logout-smoke: re-entered.*suppressors: \(.*\), done.*/\1/p' | tail -1)"
printf ' %-24s %s\n' "re-entry suppressors" "${sup:-<unreported>}"
[ "$sup" = "none" ] ||
fail "the re-entered character could not be driven — suppressors: ${sup:-<unreported>} \
(1542: everything the ended session granted its mover dies with it)"
# **Did this run actually cross the ROOTED logout?** It did not, and it cannot: the probe accounts
# are `gmlevel 6` (decision 0530 — probes need GM for `.go`/`.cheat`) and the deploy sets
# `InstantLogout = 1`, so vmangos takes the instant branch of `CMSG_LOGOUT_REQUEST`
# (`MiscHandler.cpp`: resting OR taxi OR security >= the config) and returns without ever calling
# `SetRooted(true)`. B306's precondition is on the OTHER side of that `return`. So the drivable
# check above, for B306 specifically, passes without exercising it — and a check that can pass
# vacuously has to say so out loud, or a green run reads as coverage it does not have. That is the
# same blind spot as the bug: not a wrong answer, an unasked question. B306's own regression lives
# where it can be forced — the unit test at `player::wire_in::session_end_tests`.
if printf '%s\n' "$plain" | grep -q "mover mode Root granted"; then
printf ' %-24s %s\n' "rooted logout" "yes — the drivable check above is a real pass"
else
printf ' %-24s %s\n' "rooted logout" \
"no (instant logout: GM probe / resting) — the check above did not exercise B306"
fi
errors="$(printf '%s\n' "$plain" | grep -cE ' ERROR ')"
panics="$(printf '%s\n' "$plain" | grep -cE 'panicked at')"
[ "$panics" -ne 0 ] && fail "$panics panic(s)"
[ "$errors" -ne 0 ] && fail "$errors ERROR line(s)"
# **The session invariant** (1290): the UI is built per login, so a two-login run must show every
# per-login step exactly twice. One occurrence means the second login re-entered the first login's
# frame tree — the "always Onewarrior" failure, which is invisible in a screenshot and silent in
# every log unless something counts.
#
# **Two counts, not one, since 2226.** This walk builds FOUR Lua states, not two: the client sits
# at the character screen on a boot VM, and the world entry now BUILDS its own rather than adopting
# that one (the reference's `0x490bd0` ↔ `0x48fbf0` pair). So a marker's expected count depends on
# which of the two things it tracks — every VM ever built, or only the VMs that loaded the in-game
# UI. Conflating them is what this loop used to do, and it was right only for as long as the entry
# adopted the glue VM.
#
# Which makes the first group a 2226 regression detector in its own right: **2 there rather than 4
# means a login inherited the character screen's session** — and with it every `VmMemo` that
# session had already spent, which is the login one-shot class (1348, B376) straight back.
sessions=2 # world entries in this walk
vms=$((sessions * 2)) # …and the Lua states they cost: a glue VM and a world VM each (2226)
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 (2226); \
$sessions would mean the entry adopted the character screen's VM and inherited its spent VmMemos"
done
# **The keybinding table is an entry-edge seed since 2241**: `seed_bindings_for_vm` runs inside
# `load_ingame_ui_on_world_entry`, beside the zone channels and the default language, so its
# marker counts once per LOGIN, not once per VM. This loop read it against `$vms` for one day —
# 2226 wrote the count on 09-14, 2241 moved the seed on 09-15 — and every smoke after that landing
# failed here on a tree whose VM count above was exactly right; found bisecting a render change
# that could not have touched it (2258's session). A marker's group is the *edge it fires on*.
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 rebuilt per login (1290), the keybinding table seeded on the entry edge (2241)"
done
# **The shutdown tail ran on BOTH roots** (decision 1528). One write is the `/logout`; the second is
# the exit, and the smoke now exits the way a player does — by closing the window. That is the only
# exit that tests whether the tail is reachable at all: the close button's `AppExit` is written in
# `PostUpdate`, so for as long as the shutdown systems read it in `Update` this count was 1 and
# every saved variable, every addon's file and the camera pose died with the process. Counted off
# the flat file's own line: it is only written when the UI really loaded, which is the property the
# sentinel needs. (It is no longer the tail's FIRST write — 2029 put the layout cache ahead of it,
# in the reference's own slot — but it is still the first that says the session had a UI.)
writes="$(printf '%s\n' "$plain" | grep -cF "saved variables: wrote")"
printf ' %-24s %s\n' "shutdown writes" "$writes"
[ "$writes" -eq "$sessions" ] ||
fail "the shutdown tail wrote $writes time(s), expected $sessions — a session ended without \
saving (1528: the quit root must be observed in \`Last\`, after PostUpdate's exit_on_all_closed)"
# The read-only verdict (decision 1486). Reported by name: "something wrote to the install" is a
# rule violation somebody has to go and find, and the file that appeared is the whole lead.
if [ -n "$install_root" ]; then
after="$(mktemp -t benilla-smoke-after)"
find -L "$install_root" -type f 2>/dev/null | sort >"$after"
appeared="$(comm -13 "$before" "$after")"
vanished="$(comm -23 "$before" "$after")"
touched="$(find -L "$install_root" -type f -newer "$stamp" 2>/dev/null)"
rm -f "$after"
if [ -n "$appeared" ] || [ -n "$vanished" ] || [ -n "$touched" ]; then
if [ -n "$reference_client_before" ] || pgrep -f '[w]ow\.exe' >/dev/null 2>&1; then
echo "SMOKE INCONCLUSIVE: the install changed, but the REFERENCE CLIENT was running."
echo " wow.exe writes its own WTF while it runs, and this watch is a whole-tree mtime"
echo " diff — it cannot tell those writes from benilla's. This is NOT evidence that"
echo " benilla wrote to the install, and it is not evidence that it did not."
echo " Close the reference client and re-run to get a real reading."
else
echo "SMOKE FAILED: the run WROTE TO THE INSTALL — benilla never does (decision 1486)."
echo " Every file benilla persists goes through \`crate::local_state\` into"
echo " \`benilla-config/\` beside the binary (decisions 0954/1175/1486)."
fi
[ -n "$appeared" ] && { echo " added:"; printf '%s\n' "$appeared" | sed 's/^/ /'; }
[ -n "$vanished" ] && { echo " removed:"; printf '%s\n' "$vanished" | sed 's/^/ /'; }
[ -n "$touched" ] && { echo " modified:"; printf '%s\n' "$touched" | sed 's/^/ /'; }
exit 1
fi
echo " install untouched $(wc -l <"$before" | tr -d ' ') files"
fi
# ── The realm-list leg (2069) ────────────────────────────────────────────────────────────────
#
# A second, short run, because it crosses a boundary the round trip above never touches: the realm
# list is a dialog raised **over** character select, and the IO thread has to keep serving it from
# the character park it is already sitting in. The version that ended the cycle there shipped, and
# the failure was invisible to every unit test in the workspace — the app was stranded on one
# screen while the thread walked two parks ahead, and the symptom the director saw was a client
# that hung on "Connecting" with nothing in the log at all. This drives Change Realm → Okay →
# Change Realm → Cancel → Okay against the real server and refuses anything but a completed walk.
echo "smoke: running the realm-list boundary walk (~12 s, opens a window)…"
rlog="$(mktemp -t benilla-smoke-realm)"
# **No `WOW_CHAR` here, deliberately** — an absent variable is invisible, so it is named instead.
# The walk drives character select; the fast path would seat a body and there would be no roster
# screen left to drive. The scrub at the top of this script is what makes the absence real, and
# `realm_select::smoke` refuses on its own if one reaches it anyway.
WOW_UNATTENDED=1 WOW_NOSOUND=1 WOW_USER="$user" WOW_PASS="$pass" WOW_REALM_SMOKE=1 \
timeout 120 cargo run -q -p benilla >"$rlog" 2>&1
rcode=$?
rplain="$(sed -E 's/\x1b\[[0-9;]*m//g' "$rlog")"
[ -n "${WOW_SMOKE_KEEP:-}" ] || rm -f "$rlog"
realm_fail() {
echo "SMOKE FAILED (realm leg): $1"
[ -n "${WOW_SMOKE_KEEP:-}" ] && echo " log: $rlog"
printf '%s\n' "$rplain" | tail -30
exit 1
}
[ $rcode -eq 124 ] && realm_fail "the realm walk did not finish within the timeout"
printf '%s' "$rplain" | grep -q "realm-smoke: FAILED" &&
realm_fail "$(printf '%s\n' "$rplain" | sed -n 's/.*realm-smoke: FAILED — //p' | tail -1)"
printf '%s' "$rplain" | grep -q "realm-smoke: done" ||
realm_fail "the walk never completed (no 'realm-smoke: done')"
rp="$(printf '%s\n' "$rplain" | grep -cE 'panicked at')"
[ "$rp" -ne 0 ] && realm_fail "$rp panic(s)"
printf ' %-24s %s\n' "realm walk" \
"$(printf '%s\n' "$rplain" | sed -n 's/.*realm-smoke: done — //p' | tail -1)"
echo "SMOKE GREEN — ${sessions} logins + the realm walk, 0 errors, 0 panics, install untouched"
[ -n "${WOW_SMOKE_KEEP:-}" ] && echo "log: $log"
exit 0
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""soundprobe.py — read back a `$WOW_SOUND_PROBE` capture and name the mechanism.
A measuring run (decision 1556) leaves three files: `pre.wav` (the mix as the game asked for it),
`post.wav` (what was heard), and `timeline.jsonl` (levels, voices, deadline misses, kit starts, and
the director's F9 marks — all keyed to the sample offset in `pre.wav`).
This reads them together and answers the question the capture was taken for: **of the mechanisms
that sound like a speaker breaking, which one is actually present?** They are distinguishable, but
only by comparing the two WAVs against each other and against the timeline — no single file
separates them:
over-scale sum ............ pre past 1.0, post clean, gain well under 1
limiter not engaging ...... pre past 1.0 AND post past 1.0
underrun / missed deadline both clean; `load>=1`, overruns, block-aligned steps in post
starved stream decoder .... both clean; hard steps to zero in post
non-finite samples ........ NaN/inf anywhere — invisible to every level meter
voice refusal ............. `refused` climbing near the mark
the limiter's own pumping . post clean but gain diving repeatedly (1551 made it worse)
Usage:
scripts/soundprobe.py # the default benilla-config/sound-probe
scripts/soundprobe.py <dir> # a capture directory
scripts/soundprobe.py <dir> --around 2.0 # widen the window read around each mark
scripts/soundprobe.py <dir> --json
Pure stdlib on purpose (no numpy on this machine — an instrument that needs an install is an
instrument nobody runs). Windowed reductions go through `max`/`min` on array slices so the hot
loop stays in C; the per-sample passes run only near the marks, where they are affordable.
"""
import argparse
import json
import math
import os
import struct
import sys
from array import array
# A sample this close to full scale was clipped, or was pushed there by a limiter that had to
# choose. Below the renderer's own clamp of 1.0 so a limiter riding its 0.99 ceiling reads as
# "held", not as "clipped".
CLIPPED = 0.999
# Envelope resolution. 10 ms is finer than any transient we care to place and keeps a ten-minute
# capture to ~60 000 windows.
WINDOW = 0.010
# A sample-to-sample jump this large is not music. Real 44.1 kHz content moves slowly between
# adjacent samples; a hard step is a zero-fill, a cut, or a stepped parameter — the waveform
# signature the level meters cannot see.
STEP = 0.30
# kira's internal block. A discontinuity landing on a multiple of this is a *block-level* failure
# (a starved decoder zero-filling a whole chunk), not a one-off click — the distinction between
# decision 1109's mechanism and a de-click bug.
BLOCK = 128
def read_wav(path):
"""(rate, interleaved float32 array). Tolerates the tap's crash-safe truncated tail."""
with open(path, "rb") as f:
head = f.read(44)
if len(head) < 44 or head[0:4] != b"RIFF" or head[8:12] != b"WAVE":
sys.exit(f"soundprobe: {path} is not a RIFF/WAVE file")
fmt, channels, rate, _br, _al, bits = struct.unpack("<HHIIHH", head[20:36])
if (fmt, channels, bits) != (3, 2, 32):
sys.exit(f"soundprobe: expected stereo float-32, got fmt={fmt} ch={channels} bits={bits}")
raw = f.read()
usable = len(raw) - (len(raw) % 8)
s = array("f")
s.frombytes(raw[:usable])
if sys.byteorder == "big":
s.byteswap()
return rate, s
def envelope(samples, rate, window=WINDOW):
"""Per-window peak |sample|, plus the count of clipped and non-finite samples in each."""
step = max(1, int(rate * window)) * 2
out = []
for i in range(0, len(samples), step):
seg = samples[i : i + step]
if not seg:
break
# NaN/inf first, and via a SUM rather than a comparison: `max`/`min` compare with `>`,
# which is false for every NaN, so a NaN in the window can be silently skipped depending on
# where it sits. A sum propagates it unconditionally — the same trap this whole capture
# exists because of (`f32::max` discards NaN; see sound::meter).
if not math.isfinite(sum(seg)):
nf = sum(1 for v in seg if not math.isfinite(v))
finite = [v for v in seg if math.isfinite(v)]
peak = max(max(finite), -min(finite)) if finite else 0.0
else:
nf = 0
peak = max(max(seg), -min(seg))
clipped = sum(1 for v in seg if abs(v) >= CLIPPED) if peak >= CLIPPED else 0
out.append((i / 2 / rate, peak, clipped, nf))
return out
def brightness(samples, rate, lo, hi):
"""A crude high-frequency energy ratio for [lo,hi): `sum((x[n]-x[n-1])^2) / sum(x[n]^2)`.
NOT a spectrum — it is a one-tap difference filter, which is all that is affordable in stdlib.
But it is enough for the question a mark actually poses: **was that moment harsher, or just
louder?** Distortion of any origin — a clipped waveform, aliasing from a pitch-varied playback
rate, a stepped parameter — puts energy high in the band, and this number rises with it while
staying flat for content that merely got loud. Meaningful only against the same capture's own
baseline, so it is always reported next to one.
"""
a, b = max(0, int(lo * rate)) * 2, min(len(samples), int(hi * rate) * 2)
num = den = 0.0
for ch in (0, 1):
prev = None
for i in range(a + ch, b, 2):
v = samples[i]
if v != v:
prev = None
continue
if prev is not None:
num += (v - prev) * (v - prev)
den += v * v
prev = v
return num / den if den > 1e-12 else 0.0
def steps(samples, rate, lo, hi):
"""Sample-to-sample discontinuities in [lo,hi) seconds: (time, jump, block_aligned)."""
a, b = max(0, int(lo * rate)) * 2, min(len(samples), int(hi * rate) * 2)
found = []
for ch in (0, 1):
prev = None
for i in range(a + ch, b, 2):
v = samples[i]
if prev is not None and v == v and prev == prev:
d = abs(v - prev)
if d >= STEP:
frame = i // 2
found.append((frame / rate, d, frame % BLOCK == 0))
prev = v
found.sort(key=lambda t: -t[1])
return found
def load_timeline(path):
rows = []
if not os.path.exists(path):
return rows
with open(path) as f:
for line in f:
line = line.strip()
if line:
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
pass
return rows
def db(x):
return -math.inf if x <= 0 else 20 * math.log10(x)
def span(rows, kind, lo, hi, key=None):
"""Rows of `kind` whose audio time falls in [lo,hi)."""
return [r for r in rows if r.get("ev") == kind and lo <= r.get("_at", -1) < hi]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("dir", nargs="?", default=None)
ap.add_argument("--around", type=float, default=1.5, help="seconds either side of a mark")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
d = args.dir
if d is None:
for guess in ("benilla-config/sound-probe", "../benilla-config/sound-probe"):
if os.path.isdir(guess):
d = guess
break
if d is None or not os.path.isdir(d):
sys.exit("soundprobe: no capture directory — pass one (see WOW_SOUND_PROBE)")
pre_p, post_p = os.path.join(d, "pre.wav"), os.path.join(d, "post.wav")
if not os.path.exists(pre_p):
sys.exit(f"soundprobe: {pre_p} missing — was the run started with WOW_SOUND_PROBE=1?")
rate, pre = read_wav(pre_p)
_, post = read_wav(post_p) if os.path.exists(post_p) else (rate, array("f"))
rows = load_timeline(os.path.join(d, "timeline.jsonl"))
for r in rows:
r["_at"] = r.get("a", 0) / rate
pre_env, post_env = envelope(pre, rate), envelope(post, rate)
dur = len(pre) / 2 / rate
pre_peak = max((w[1] for w in pre_env), default=0.0)
post_peak = max((w[1] for w in post_env), default=0.0)
pre_clip = sum(w[2] for w in pre_env)
post_clip = sum(w[2] for w in post_env)
pre_nf = sum(w[3] for w in pre_env)
post_nf = sum(w[3] for w in post_env)
ticks = [r for r in rows if r.get("ev") == "tick"]
overruns = sum(r.get("overruns", 0) for r in ticks)
refused = sum(r.get("refused", 0) for r in ticks)
errors = sum(r.get("errors", 0) for r in ticks)
max_load = max((r.get("load", 0.0) for r in ticks), default=0.0)
max_voices = max((r.get("voices", 0) for r in ticks), default=0)
min_gain = min((r.get("gain", 1.0) for r in ticks), default=1.0)
marks = [r for r in rows if r.get("ev") == "mark"]
# A brightness baseline from windows spread across the capture, so "harsher than usual" is a
# statement about this session rather than about audio in general.
base = []
if dur > 1.0:
for k in range(1, 12):
t0 = dur * k / 12.0
b = brightness(post, rate, t0, min(dur, t0 + 0.25))
if b > 0:
base.append(b)
base.sort()
baseline = base[len(base) // 2] if base else 0.0
# ---- the verdict -----------------------------------------------------------------------
verdicts = []
if pre_nf or post_nf:
verdicts.append(
f"NON-FINITE SAMPLES — {pre_nf} pre, {post_nf} post. A NaN/inf is invisible to every "
"level meter and passes the limiter's own test untouched. This is a defect upstream "
"of the output and is almost certainly what you heard."
)
if post_clip:
verdicts.append(
f"THE LIMITER IS NOT HOLDING — {post_clip} clipped sample(s) in what was actually "
f"heard (post peak {post_peak:.3f}). It is installed but something is getting past it."
)
elif pre_clip:
verdicts.append(
f"over-scale sum, caught — the mix asked for {pre_peak:.2f}x full scale "
f"({db(pre_peak):+.1f} dBFS) and the output stayed clean (peak {post_peak:.3f}). "
"1551's mechanism is present and is being handled."
)
if overruns:
verdicts.append(
f"UNDERRUNS — {overruns} missed mix deadline(s), peak load {max_load*100:.0f}% of "
"budget. This is audible as a crack and no limiter can touch it."
)
if errors:
verdicts.append(f"STREAM ERRORS — {errors} reported by the device layer.")
if refused:
verdicts.append(f"{refused} sound(s) refused — the voice arena filled.")
if min_gain < 0.5 and not pre_clip:
verdicts.append(
f"LIMITER PUMPING WITHOUT CAUSE — gain dove to {min_gain:.2f} ({db(min_gain):+.1f} dB) "
"with nothing over full scale. The fix would then be the complaint; try "
"`SoundOutputLimiter 0`."
)
if not verdicts:
verdicts.append(
"Nothing anomalous in levels, deadlines or finiteness. If you heard it in this "
"capture, the mechanism is in the waveform's content — check the marks below for "
"steps, and listen to post.wav at those offsets."
)
if args.json:
print(json.dumps({
"dir": d, "rate": rate, "duration": dur,
"pre": {"peak": pre_peak, "clipped": pre_clip, "nonfinite": pre_nf},
"post": {"peak": post_peak, "clipped": post_clip, "nonfinite": post_nf},
"overruns": overruns, "refused": refused, "errors": errors,
"max_load": max_load, "max_voices": max_voices, "min_gain": min_gain,
"marks": [r["_at"] for r in marks], "verdicts": verdicts,
}, indent=2))
return
print(f"\n capture {d} {dur:.1f}s @ {rate} Hz")
print(f" asked for peak {pre_peak:7.3f} ({db(pre_peak):+6.1f} dBFS) clipped {pre_clip:>8} nan {pre_nf}")
print(f" heard peak {post_peak:7.3f} ({db(post_peak):+6.1f} dBFS) clipped {post_clip:>8} nan {post_nf}")
print(f" limiter deepest gain {min_gain:.3f} ({db(min_gain):+.1f} dB)")
print(f" health overruns {overruns} refused {refused} stream errors {errors}"
f" peak load {max_load*100:.0f}% peak voices {max_voices}")
print("\n VERDICT")
for v in verdicts:
print(f" • {v}")
if not marks:
print("\n No F9 marks in this capture. The worst moments by level:")
for t, pk, cl, _nf in sorted(pre_env, key=lambda w: -w[1])[:5]:
print(f" {t:8.2f}s asked {pk:.2f}x clipped {cl}")
print()
return
print(f"\n {len(marks)} MARK(S) — what was happening when you pressed F9\n")
for m in marks:
at = m["_at"]
lo, hi = max(0.0, at - args.around), at + args.around
pw = [w for w in pre_env if lo <= w[0] < hi]
qw = [w for w in post_env if lo <= w[0] < hi]
t = [r for r in ticks if lo <= r["_at"] < hi]
st = steps(post, rate, lo, hi)[:3]
plays = span(rows, "play", lo, hi)
print(f" ── mark {m.get('n')} at {at:.2f}s " + "─" * 46)
print(f" asked peak {max((w[1] for w in pw), default=0):.3f} clipped {sum(w[2] for w in pw)}")
print(f" heard peak {max((w[1] for w in qw), default=0):.3f} clipped {sum(w[2] for w in qw)}")
print(f" gain {min((r.get('gain',1.0) for r in t), default=1.0):.3f}"
f" voices {max((r.get('voices',0) for r in t), default=0)}"
f" load {max((r.get('load',0.0) for r in t), default=0)*100:.0f}%"
f" overruns {sum(r.get('overruns',0) for r in t)}"
f" refused {sum(r.get('refused',0) for r in t)}")
harsh = brightness(post, rate, lo, hi)
if baseline > 0:
ratio = harsh / baseline
verdict = (
"MUCH harsher than this capture's norm — distortion of some kind"
if ratio >= 2.5
else "harsher than usual" if ratio >= 1.5
else "no harsher than usual — this moment was loud, not dirty"
)
print(f" harsh {harsh:.3f} vs baseline {baseline:.3f} ({ratio:.1f}x) — {verdict}")
if st:
worst = ", ".join(
f"{d_:.2f} at {ts:.3f}s{' [block-aligned]' if al else ''}" for ts, d_, al in st
)
print(f" STEPS {worst}")
if any(al for _, _, al in st):
print(" block-aligned → a starved decoder or a zero-filled chunk,")
print(" not a level problem. The limiter is irrelevant to this.")
else:
print(" steps none above threshold — the waveform is continuous here")
if plays:
names = {}
for p in plays:
names[p.get("name", "?")] = names.get(p.get("name", "?"), 0) + 1
top = sorted(names.items(), key=lambda kv: -kv[1])[:6]
print(f" sounds {len(plays)} started: " + ", ".join(f"{n}×{c}" for n, c in top))
print()
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# soundprobe.sh — start the client in MEASURING MODE (decision 1556).
#
# The output limiter (1551) was proven offline and shipped on, and the director then played a real
# session and heard no change. That gap is not settled by another fix; it is settled by a capture
# from the machine and the encounter in question. This starts a run that records one.
#
# Builds with the `play` profile on purpose — the same profile the director actually plays in
# (decision 1157). A debug build stutters, a stutter is a missed mix deadline, and a missed
# deadline is one of the mechanisms under investigation: measuring on the wrong profile would
# manufacture the very artifact the capture is meant to attribute.
#
# scripts/soundprobe.sh # play normally, press F9 when you hear it
# scripts/soundprobe.py # …then read the capture back
#
# Any extra arguments are passed through to the client.
set -euo pipefail
cd "$(dirname "$0")/.."
cat <<'BANNER'
── benilla · sound measuring mode ─────────────────────────────────────────
Recording two mixes at once: what the game ASKED for (before the limiter)
and what you actually HEARD (after it). One file cannot tell those apart;
two can, and that is the whole question.
▶ PRESS F9 THE MOMENT YOU HEAR IT.
That stamps the exact sample. Without a mark I am scanning ten minutes
of audio guessing which second you meant; with one I read outward from
it. Press it every time — several marks is much better than one.
Then quit the client normally and run: scripts/soundprobe.py
───────────────────────────────────────────────────────────────────────────
BANNER
exec env WOW_SOUND_PROBE=1 cargo run -q --profile play -p benilla "$@"
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# **The two-client live summon probe** (decision 1747) — the instrument that closes the loop on
# being summoned, because nothing smaller can.
#
# The summon flow is the one confirm in the client that a single session cannot exercise at all:
# vmangos has exactly two `SendSummonRequest` callers (`Spell::EffectSummonPlayer` and
# `HandleGroupSummonCommand`), and **both skip you** — the ritual needs a warlock plus two
# clickers, and `.group summon` explicitly `continue`s past the caster. So the question can only
# be asked by somebody else, and the whole seam behind it (apply → feed → VM → drain → wire) is
# exactly the part unit tests cannot reach.
#
# What it does: a summoner client on an unowned probe account invites this slot's probe character
# into a group and runs `.group summon`; the receiver reads its own CONFIRM_SUMMON dialog back
# through `ProbeLog`, presses Accept, and re-reads its zone once the teleport has landed. The
# receiver also writes the `summon` trace tag, so all three links report:
#
# summon recv SMSG_SUMMON_REQUEST summoner=… zone=… delay_ms=… dead_or_ghost=false
# summon fire CONFIRM_SUMMON summoner=… zone=…
# summon SEND CMSG_SUMMON_RESPONSE summoner=… n=1
#
# **Why PROBE9, and why the account guard is waved.** The worktree pool is a hard eight slots
# (0..7, decision 1037), and the probe accounts run 0..9 — so PROBE8 and PROBE9 are keyed to no
# session by construction and logging in as one cannot kick anybody. That is the ONLY thing that
# makes `WOW_ALLOW_ACCOUNT=1` legitimate here, so this script checks it rather than asserting it:
# if a pool-9 slot ever exists, it refuses instead of kicking that session out of the world.
#
# scripts/summon-live.sh # ~90 s, opens two small cornered windows
set -uo pipefail
root="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null || true)"
[ -n "$root" ] && [ -f "$root/scripts/summon-live.sh" ] || root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root" || exit 1
echo "summon-live: $root"
# The receiver is THIS SLOT's probe identity — smoke.sh's rule, and for its reason: a vmangos login
# kicks whoever holds the account, and the slot claim is already the session mutex.
slot="$(basename "$root" | sed -n 's/^pool-\([0-9]\)$/\1/p')"
if [ -z "$slot" ]; then
echo "summon-live: REFUSING — not in a pool worktree (\`$root\`)."
echo " Claim one: scripts/wt.sh claim <name>"
exit 1
fi
names=(zero one two three four five six seven eight nine)
rx_user="probe$slot"; rx_pass="pprobe$slot"; rx_char="Probe${names[$slot]}"
# The summoner rides a probe account NO slot can claim (see the header). Verified, not assumed.
tx_slot=9
if [ -d "$(dirname "$root")/pool-$tx_slot" ]; then
echo "summon-live: REFUSING — pool-$tx_slot exists, so probe$tx_slot is a live session's account"
echo " and logging in on it would kick that session out of the world. The pool grew"
echo " past this script's assumption; pick a higher unowned probe account."
exit 1
fi
tx_user="probe$tx_slot"; tx_pass="pprobe$tx_slot"; tx_char="Probe${names[$tx_slot]}"
echo "summon-live: $tx_char ($tx_user) summons $rx_char ($rx_user)"
for port in 3724 8085; do
if ! (exec 3<>/dev/tcp/127.0.0.1/$port) 2>/dev/null; then
echo "summon-live: SKIPPED — nothing listening on 127.0.0.1:$port (realmd 3724 / mangosd 8085)."
echo " The local vmangos lives at /Users/sam/dev/vmangos-deploy — \`docker compose up -d\`."
exit 0
fi
done
# Build once, so the two `cargo run`s below start together instead of one waiting on the other's
# build lock — the timings are wall-clock from process start (smoke.sh's build-then-time rule).
echo "summon-live: building…"
cargo build -q -p benilla || { echo "summon-live: the client did not build"; exit 1; }
work="$(mktemp -d -t benilla-summon)"
trap '[ -n "${SUMMON_LIVE_KEEP:-}" ] || rm -rf "$work"' EXIT
# The receiver's chunk. `tostring` on every getter on purpose: a missing dialog must REPORT rather
# than raise at line 2, or a failed run says "attempt to concatenate a nil value" and names nothing.
read -r -d '' chunk <<'LUA'
ProbeLog("dialog visible=" .. tostring(StaticPopup1:IsVisible()))
ProbeLog("dialog text=[" .. tostring(StaticPopup1Text:GetText()) .. "]")
ProbeLog("summoner=[" .. tostring(GetSummonConfirmSummoner()) .. "]")
ProbeLog("area=[" .. tostring(GetSummonConfirmAreaName()) .. "]")
ProbeLog("timeleft=" .. tostring(GetSummonConfirmTimeLeft()))
ProbeLog("zone before=[" .. GetZoneText() .. "]")
StaticPopup_OnClick(StaticPopup1, 1)
ProbeLog("accept pressed; dialog visible=" .. tostring(StaticPopup1:IsVisible()))
-- The teleport is OBSERVED, not assumed: a frame that re-reads the zone ten seconds after the
-- Accept, which is the only thing that distinguishes "we sent the packet" from "we were summoned".
local watch = CreateFrame("Frame")
watch.t = 0
watch:SetScript("OnUpdate", function()
this.t = this.t + arg1
if this.t > 10 and not this.said then
this.said = 1
ProbeLog("zone after=[" .. GetZoneText() .. "]")
end
end)
LUA
echo "summon-live: running (~90 s, opens two windows)…"
# **The receiver is parked first, and the run is worthless without it.** Its character starts
# wherever the last run left it — which, after one green run, is the destination — so a
# before/after zone compare silently stops discriminating on the second run. It did exactly that.
# Parking in Stormwind makes "before" the same on every run, whatever the previous one did.
WOW_UNATTENDED=1 WOW_USER="$rx_user" WOW_PASS="$rx_pass" WOW_CHAR="$rx_char" \
WOW_PROBE=partner \
WOW_PROBE_CHAT=".go xyz -8913 554 94" WOW_PROBE_CHAT_AT=10 \
WOW_PROBE_LUA="$chunk" WOW_PROBE_LUA_AT=34 \
WOW_PROBE_EXIT_AT=62 \
WOW_MOVE_TRACE="$work/rx.trace" WOW_MOVE_TRACE_TAGS=summon \
timeout 150 cargo run -q -p benilla >"$work/rx.log" 2>&1 &
rx=$!
# `WOW_ALLOW_ACCOUNT=1` is the account guard's own escape hatch, and the pool-9 check above is what
# earns it. `.group summon` rather than `.summon`: the latter teleports without ever asking.
WOW_UNATTENDED=1 WOW_USER="$tx_user" WOW_PASS="$tx_pass" WOW_CHAR="$tx_char" WOW_ALLOW_ACCOUNT=1 \
WOW_PROBE_CHAT="/invite $rx_char;.group summon" \
WOW_PROBE_CHAT_AT=18 WOW_PROBE_CHAT_EVERY=10 \
WOW_PROBE_EXIT_AT=62 \
timeout 150 cargo run -q -p benilla >"$work/tx.log" 2>&1 &
tx=$!
wait $rx; rx_code=$?
wait $tx; tx_code=$?
strip() { sed -E 's/\x1b\[[0-9;]*m//g' "$1"; }
trace="$(cat "$work/rx.trace" 2>/dev/null)"
plog="$(strip "$work/rx.log" | sed -n 's/.*probe-log: //p')"
echo
echo "── the summoner"
strip "$work/tx.log" | grep -E "probe-chat: sending|server says —|REFUSING" | sed 's/^.*INFO [^:]*: //'
echo
echo "── the receiver: the three links"
printf '%s\n' "$trace"
echo
echo "── the receiver: what the dialog said"
printf '%s\n' "$plog"
fail() { echo; echo "SUMMON-LIVE FAILED: $1"; strip "$work/rx.log" | grep -E "ERROR" | tail -10; exit 1; }
[ $rx_code -eq 124 ] && fail "the receiver did not exit within the timeout"
[ $tx_code -eq 124 ] && fail "the summoner did not exit within the timeout"
printf '%s' "$trace" | grep -q "recv SMSG_SUMMON_REQUEST" ||
fail "no summon request arrived — did the invite land? (see the summoner's lines above)"
printf '%s' "$trace" | grep -q "fire CONFIRM_SUMMON" || fail "the request landed but no event fired"
printf '%s' "$trace" | grep -q "SEND CMSG_SUMMON_RESPONSE" || fail "Accept sent no packet"
printf '%s' "$plog" | grep -q "wants to summon you to" || fail "the dialog text never composed"
# The two that cannot be faked by any amount of client-side bookkeeping: the character actually
# moved, and it moved to **the place the dialog named**. The second is what ties
# GetSummonConfirmAreaName to reality rather than to our own AreaTable lookup agreeing with itself.
before="$(printf '%s' "$plog" | sed -n 's/^zone before=\[\(.*\)\]$/\1/p')"
after="$(printf '%s' "$plog" | sed -n 's/^zone after=\[\(.*\)\]$/\1/p')"
area="$(printf '%s' "$plog" | sed -n 's/^area=\[\(.*\)\]$/\1/p')"
[ -n "$after" ] || fail "the receiver never re-read its zone (the teleport went unobserved)"
[ "$before" != "$after" ] ||
fail "the packet went out but the character never moved (still in $before) — if before and \
after are BOTH the destination, the park at the top of the run did not take"
[ "$after" = "$area" ] ||
fail "the dialog promised $area and the character landed in $after"
echo
echo "SUMMON-LIVE GREEN — asked, shown, accepted, and moved: $before → $after (the dialog's own $area)"
[ -n "${SUMMON_LIVE_KEEP:-}" ] && echo " logs: $work"
exit 0
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Phase-5 visual A/B render harness driver (decisions 0008 + 0010).
#
# Captures deterministic benilla screenshots (one process per scenario, server-less + clutter-off +
# pinned camera, game clock AND frame step — see the `capture` module) and diffs them with
# `benilla-visual`. `selfcheck` is the proof that "deterministic" is true. The intended
# loop: capture `baseline` on the current pipeline, make a render change, then `diff` — the diff is the
# machine check that catches a regression before the director's eye (decision 0008).
#
# Captures contain Blizzard-derived imagery, so they live under the gitignored target/visual/ and are
# NEVER committed. macOS/local only: capture opens a real window (no headless GPU path here).
#
# Usage:
# scripts/visual.sh list # print scenario names
# scripts/visual.sh capture <dir> # capture every scenario into <dir>/
# scripts/visual.sh baseline # capture into target/visual/baseline/
# scripts/visual.sh diff [--fail <mae>] # capture into target/visual/candidate/, diff vs baseline/
# scripts/visual.sh selfcheck # capture the sweep TWICE from ONE build; demand identical
set -euo pipefail
cd "$(dirname "$0")/.."
VIS=target/visual
build() { cargo build -q -p benilla -p benilla-visual; }
# The scenario list is owned by the binary (capture/mod.rs), printed via WOW_CAPTURE=list — so this driver
# never drifts from the code.
scenarios() { WOW_CAPTURE=list cargo run -q -p benilla; }
capture_into() {
local dir="$1"
mkdir -p "$dir"
local s
for s in $(scenarios); do
echo "capture $s -> $dir/$s.png"
WOW_CAPTURE="$s" WOW_CAPTURE_OUT="$dir/$s.png" cargo run -q -p benilla
done
}
cmd="${1:-}"
case "$cmd" in
list)
build
scenarios
;;
capture)
build
capture_into "${2:?usage: visual.sh capture <dir>}"
;;
baseline)
build
capture_into "$VIS/baseline"
echo "baseline written to $VIS/baseline"
;;
diff)
shift
build
capture_into "$VIS/candidate"
echo "--- diff candidate vs baseline ---"
./target/debug/benilla-visual diff-dir "$VIS/baseline" "$VIS/candidate" --out "$VIS/diff" "$@"
;;
selfcheck) # selfcheck [<mae>] — the tolerance defaults to 0 (bit-identical)
# The harness's own tripwire (decision 0723). A golden diff is evidence ONLY if the harness is
# deterministic, so measure that directly: capture the whole sweep twice from one unchanged
# build and demand a bit-identical result (--fail 0 passes only at MAE exactly 0). Before 0723
# this failed at MAE 0.009 / max delta 180 — the same band a real render change lands in, which
# is how 0721's flame pixels ended up unreadable and how 0719 read signal out of churn. Run it
# after touching anything the capture clock feeds, and whenever a diff looks like noise.
build
capture_into "$VIS/self-a"
capture_into "$VIS/self-b"
echo "--- selfcheck: self-a vs self-b, one build, two runs ---"
# The bar is 0 — bit-identical — and the four blessed scenarios meet it (0817 set the bar, 1183
# cut the sweep to two director-framed spots x two day times). Scenarios that could not hold it
# left the sweep rather than being carried as slack in the bar (the old 0.001 was cover for two
# overlook cells at 421 px / max delta 43).
#
# What makes a cell flake is a real renderer defect, not harness drift: draw order follows spawn
# order, and spawn order varies because asset loads complete on a thread pool (0723 "Open, named"
# -> 0815 Open -> 1182, which measured it). Both states are perfectly STABLE, so no amount of
# settling or waiting fixes it. **Do not respond to a failure here by widening the bar or the
# waits** — that mistake has cost several sessions. Anything above 0 is either that defect
# surfacing in a blessed cell or a genuine harness regression; pass a number to probe.
#
# 1182 is also the correction to the obvious first guess: the tie is NOT always a coplanar pair.
# In the canal cells (deleted by 1183) it was two surfaces INTERSECTING, 0.62 yd apart, tying at
# one MSAA sample — invisible with WOW_MSAA=off. Run WOW_PICK through the differing pixels and
# read the PERPENDICULAR gap before assuming which geometry you are chasing.
./target/debug/benilla-visual diff-dir "$VIS/self-a" "$VIS/self-b" --out "$VIS/self-diff" \
--fail "${2:-0}"
echo "selfcheck OK — the harness clock is a pure function of the build"
;;
*)
echo "usage: visual.sh {list | capture <dir> | baseline | diff [--fail <mae>] | selfcheck}" >&2
exit 1
;;
esac
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env bash
# vkleg — run the client on VULKAN before it ships: a Linux container, Mesa's software Vulkan
# driver (lavapipe), a virtual display, and a handful of capture scenes (decision 2344).
#
# Why it exists (2258, B390): the gates run on Metal, and Metal has no pipeline-statistics query
# set, so a render-pass shape wgpu's Vulkan validation refuses ran clean on every gate and
# aborted every Linux and Windows-on-Vulkan player at their first world frame. lavapipe offers
# `pipelineStatisticsQuery`, so the same validation runs here — and this script, pointed at the
# pre-fix commit 4404112d6, reproduces Thalyn's report verbatim ("Query 10 was started while
# query 9 was already active") in its first world frame, while main runs all four scenes clean.
#
# scripts/vkleg.sh [<commit>] build <commit> (default HEAD) and run the scenes
# scripts/vkleg.sh --check [<commit>] exit 0 if <commit>'s tree already passed, 1 if not
#
# It builds COMMITTED content (`git archive`), never a working tree: the question it answers is
# "does this commit run on Vulkan" — the one question the macOS gates cannot ask. `/eod` runs it
# on the commit the day's post covers and drafts no post over a tree that has not passed (2344).
#
# What it needs, once: colima + docker (`brew install colima docker`). It runs in its OWN colima
# profile, `vkleg` (6 CPU, 14 GiB), started on demand and stopped afterwards: a debug client on
# a software rasterizer holds ~2 GB resident and the build wants more, which the default
# profile's 8 GiB — shared with the local servers — cannot spare, and resizing that profile would restart the servers. The
# docker context is put back to what it was. VKLEG_KEEP=1 leaves the VM up for a second run.
#
# A failure is: a scene exits non-zero, writes no PNG, or logs `wgpu error` / `panicked at`.
# Memoized on the commit's tree (a docs-only delta keeps the verdict, as in gates.sh), in
# ~/.benilla-vkleg/green — outside every checkout, so a slot's verdict serves the primary's
# identical tree. VKLEG_FORCE=1 re-runs.
set -uo pipefail
here="$(cd "$(dirname "$0")/.." && pwd)"
root="$(git -C "$here" rev-parse --show-toplevel 2>/dev/null || echo "$here")"
cd "$root" || exit 1
check_only=0
if [ "${1:-}" = "--check" ]; then check_only=1; shift; fi
rev="${1:-HEAD}"
commit="$(git rev-parse --verify -q "$rev^{commit}")" || { echo "vkleg: no such commit: $rev"; exit 2; }
tree="$(git rev-parse "$commit^{tree}")"
HOME_DIR="$HOME/.benilla-vkleg"
GREEN="$HOME_DIR/green"
PROFILE=vkleg
CTX="colima-$PROFILE"
BUILD_IMAGE=rust:1-bookworm
RUN_IMAGE=debian:trixie
SCENES=(${VKLEG_SCENES:-glue-login ui-unitframes water-night inn-interior})
# ── The memo ─────────────────────────────────────────────────────────────────────────────────
docs_only_delta() { # $1 $2 = trees
[ -z "$(git diff-tree -r --name-only "$1" "$2" 2>/dev/null | grep -vE '^(docs/|[^/]*\.md$)' || true)" ]
}
passed() {
[ -f "$GREEN" ] || return 1
local t
while read -r t _; do
[ -n "$t" ] || continue
if [ "$t" = "$tree" ]; then return 0; fi
git cat-file -e "$t^{tree}" 2>/dev/null && docs_only_delta "$t" "$tree" && return 0
done < "$GREEN"
return 1
}
if [ "$check_only" = 1 ]; then
if passed; then echo "vkleg: $(git rev-parse --short "$commit") — its tree passed"; exit 0; fi
echo "vkleg: $(git rev-parse --short "$commit") — its tree has not passed"; exit 1
fi
if [ "${VKLEG_FORCE:-}" != "1" ] && passed; then
echo "VKLEG GREEN ($(git rev-parse --short "$commit") — memoized; VKLEG_FORCE=1 re-runs)"
exit 0
fi
# ── Prerequisites: a missing one is a FAILURE, never a skip (crosscheck.sh's rule) ──────────
command -v colima >/dev/null || { echo "VKLEG FAILED: no colima (brew install colima docker)"; exit 1; }
command -v docker >/dev/null || { echo "VKLEG FAILED: no docker CLI"; exit 1; }
data="${WOW_DATA:-}"
if [ -z "$data" ]; then
install="$(cd "$root/WoW" 2>/dev/null && pwd -P)" || { echo "VKLEG FAILED: no WoW install link at $root/WoW (or set WOW_DATA to its Data folder)"; exit 1; }
data="$install/Data"
fi
[ -d "$data" ] || { echo "VKLEG FAILED: $data is not a directory"; exit 1; }
case "$data" in "$HOME"/*) ;; *) echo "VKLEG FAILED: $data is outside \$HOME, which is all colima mounts"; exit 1 ;; esac
version="$(rustc -V | awk '{print $2}')"
[ -n "$version" ] || { echo "VKLEG FAILED: cannot read this machine's rustc version"; exit 1; }
# ── The VM, on demand ────────────────────────────────────────────────────────────────────────
prev_ctx="$(docker context show 2>/dev/null || true)"
started=0
if ! colima status "$PROFILE" >/dev/null 2>&1; then
echo "vkleg: starting colima profile '$PROFILE'"
colima start "$PROFILE" --cpu 6 --memory 14 --disk 80 --vm-type vz --mount-type virtiofs \
>/dev/null 2>&1 || { echo "VKLEG FAILED: colima start $PROFILE"; exit 1; }
started=1
fi
# `colima start` switches the docker context to the new profile; put the caller's back.
[ -n "$prev_ctx" ] && docker context use "$prev_ctx" >/dev/null 2>&1
finish() {
if [ "$started" = 1 ] && [ "${VKLEG_KEEP:-}" != "1" ]; then
colima stop "$PROFILE" >/dev/null 2>&1 || true
fi
[ -n "$prev_ctx" ] && docker context use "$prev_ctx" >/dev/null 2>&1
}
trap finish EXIT
# ── Stage the commit (committed content only) ────────────────────────────────────────────────
stage="$HOME_DIR/src"
out="$HOME_DIR/out"
rm -rf "$stage" "$out" && mkdir -p "$stage" "$out" || { echo "VKLEG FAILED: cannot stage in $HOME_DIR"; exit 1; }
git archive "$commit" | tar -x -C "$stage" || { echo "VKLEG FAILED: git archive $commit"; exit 1; }
echo "vkleg: $(git rev-parse --short "$commit") on lavapipe — scenes: ${SCENES[*]}"
for v in target cargo rustup; do docker --context "$CTX" volume create "benilla-vkleg-$v" >/dev/null; done
log="$out/vkleg.log"
# Two containers: BUILD on bookworm (the rust image; the target volume stays warm across runs),
# RUN on trixie. The run image is the driver: bookworm's Mesa 22.3.6 lavapipe grows the client by
# ~14 MB a frame until the OOM killer takes it (7.3 GB after 450 frames of ui-unitframes, 13.6 GB
# and killed on water-night); the same binary under trixie's Mesa 25.0.7 holds flat at 1.9 GB and
# runs twice as fast. Metal holds the same scene at 1.3 GB, so the growth was never ours (2344).
# A bookworm-built binary runs on trixie (older glibc, newer loader).
{
docker --context "$CTX" run --rm \
-v "$stage":/src:ro \
-v benilla-vkleg-target:/xtarget \
-v benilla-vkleg-cargo:/usr/local/cargo/registry \
-v benilla-vkleg-rustup:/usr/local/rustup \
-w /src -e CARGO_TARGET_DIR=/xtarget -e CARGO_BUILD_JOBS=6 \
-e CARGO_PROFILE_DEV_DEBUG=0 -e RUSTUP_TOOLCHAIN="$version" \
"$BUILD_IMAGE" bash -c '
set -uo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null 2>&1
apt-get install -y -qq pkg-config libasound2-dev libudev-dev libx11-dev libxkbcommon-dev \
libwayland-dev >/dev/null 2>&1 || { echo "apt failed"; exit 1; }
rustup toolchain install --profile minimal "$RUSTUP_TOOLCHAIN" >/dev/null 2>&1
# Debug info off: the full-debuginfo benilla-app compile was OOM-killed at 8 GiB.
cargo build -p benilla 2>&1 | grep -v -E "^\s*(Compiling|Downloaded|Downloading)" | tail -5
test -x /xtarget/debug/benilla || { echo "BUILD FAILED"; exit 1; }
' || { echo "BUILD FAILED"; exit 1; }
docker --context "$CTX" run --rm \
-v "$stage":/src:ro -v "$data":/wowdata:ro -v "$out":/out \
-v benilla-vkleg-target:/xtarget:ro \
-w /src -e SCENES="${SCENES[*]}" \
"$RUN_IMAGE" bash -c '
set -uo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null 2>&1
apt-get install -y -qq xvfb xauth mesa-vulkan-drivers libvulkan1 libxcursor1 libxrandr2 \
libxi6 libxkbcommon-x11-0 libasound2t64 libudev1 procps >/dev/null 2>&1 \
|| { echo "apt failed"; exit 1; }
cp /xtarget/debug/benilla /tmp/benilla || { echo "no binary"; exit 1; }
fail=0
for s in $SCENES; do
echo "== scene $s"
( WOW_DATA=/wowdata WOW_CAPTURE="$s" WOW_CAPTURE_OUT="/out/$s.png" \
WOW_CAPTURE_DEADLINE=900 WOW_NOSOUND=1 WOW_UNATTENDED=1 WGPU_BACKEND=vulkan \
timeout 1200 xvfb-run -a -s "-screen 0 1280x800x24" /tmp/benilla > "/out/$s.log" 2>&1
echo $? > /tmp/rc ) &
# Resident memory, sampled: on a software rasterizer the GPU memory is RAM too, and a
# scene that outgrows the VM dies by the OOM killer (exit 137) — say so with a number.
peak=0; rm -f /tmp/rc; : > "/out/$s.rss"
while [ ! -f /tmp/rc ]; do
sleep 2
r=$(ps -C benilla -o rss= 2>/dev/null | head -1 | tr -d " ")
[ -n "$r" ] && echo "$r" >> "/out/$s.rss" && [ "$r" -gt "$peak" ] && peak=$r
done
rc=$(cat /tmp/rc)
echo " peak resident $((peak / 1024)) MB"
if [ "$rc" != 0 ]; then echo " FAILED: exit $rc"; fail=1; fi
if [ ! -s "/out/$s.png" ]; then echo " FAILED: no image"; fail=1; fi
if grep -q -E "wgpu error|panicked at" "/out/$s.log"; then
echo " FAILED:"; grep -A8 -E "wgpu error|panicked at" "/out/$s.log" | head -14 | sed "s/^/ /"
fail=1
fi
[ "$rc" = 0 ] && echo " ok"
done
exit $fail
'
} 2>&1 | tee "$log"
rc=${PIPESTATUS[0]}
if [ "$rc" = 0 ]; then
mkdir -p "$HOME_DIR" && printf '%s %s %s\n' "$tree" "$(git rev-parse --short "$commit")" "$(date +%F)" >> "$GREEN"
echo
echo "VKLEG GREEN ($(git rev-parse --short "$commit"); logs and images in $out)"
exit 0
fi
echo
echo "VKLEG RED ($(git rev-parse --short "$commit"); per-scene logs in $out)"
exit 1
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Read a `vpl`/`bub` overlay trace and say WHERE the overlay's motion came from — projection, solve, or snap.
# walk a town with friendly plates on, tracing one line per plate per frame
WOW_WIN=1440x810 WOW_NOSOUND=1 WOW_GM=off \
WOW_UNATTENDED=1 WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> \
WOW_PROBE_CHAT=".go xyz -9465 74 58 0" WOW_PROBE_CHAT_AT=10 \
WOW_PROBE_KEY="Shift@20:3;V@20.5:0.2;W@24:8" \
WOW_MOVE_TRACE=/tmp/vpl.trace WOW_MOVE_TRACE_TAGS=vpl \
WOW_PROBE_EXIT_AT=34 cargo run -q -p benilla
python3 scripts/vplsum.py /tmp/vpl.trace --from 24 --to 32 # the walk leg only
Why it exists (decision 1341): "the plates are jittery when moving" is four different bugs wearing
one coat — a noisy camera, a stale anchor, the anti-overlap solve relocating a plate, or the pixel
snap quantizing its glide — and the eye cannot tell them apart. Every `vpl` line carries the world
anchor, the camera pose, the RAW projected point, the SOLVED rect and the SNAPPED rect, so the
question is arithmetic instead of judgement. It found phantom plates (30% of everything drawn was a
unit off screen, dragged to the border by the seat's clamp) in one run.
Read it in this order:
* **projection smoothness** — the frame-to-frame acceleration of the raw projected point, per unit.
Small (~0.05 px at 60 fps) means the camera and the anchor are innocent and the cause is
downstream. Large means stop reading and go look at the camera or the anchor.
* **off-screen** — plates drawn for a unit that is not on screen. Must be 0.
* **solve jumps** — a plate moving further than its own projection did. Some is the reference's own
anti-overlap bounce (a plate re-solving when a blocker moves); a lot, or hundreds of pixels in a
frame, is a defect.
* **snap** — the extra per-frame displacement the pixel snap adds to a plate's glide. It is bounded
by half a device pixel by construction; anything larger means the snap is on the wrong grid.
The `--from`/`--to` window matters as much as it does for `tracesum.py`: a run is a login, a
teleport, a stand and a walk glued together, and their average describes none of them.
**It reads the CHAT BUBBLE's `bub` lines too** (1398), auto-detected from the file — the bubble is
the plate's sibling overlay (same `plate_basis`/`gx_px`/`device_snap` family, same projector, same
`overhead_anchor`), so "the bubble is jittery when running" is the same four-way question and
deserves the same arithmetic rather than a second script that drifts from this one. The bubble has
no anti-overlap solver, so its lines carry no `solved=` stage and the SOLVE section is skipped; the
ANCHOR section below is the one that matters for it, because the bubble's height rides the *posed*
head attachment and therefore bobs with the run cycle:
WOW_MOVE_TRACE=/tmp/bub.trace WOW_MOVE_TRACE_TAGS=bub \
WOW_PROBE_CHAT="hello" WOW_PROBE_CHAT_AT=20 WOW_PROBE_KEY="W@20:6" ... cargo run -q -p benilla
python3 scripts/vplsum.py /tmp/bub.trace --from 20 --to 26
"""
import argparse
import re
import sys
from collections import defaultdict
# One regex for both overlays: the `solved=` stage is the plate's anti-overlap solver, which the
# bubble has no counterpart to, so it is optional and the final rect is `plate=` or `frame=`.
LINE = re.compile(
r"t=\s*([\d.]+) (vpl|bub)\s+e=(\d+) vp=\((\d+),(\d+)\) anchor=\[([-\d.,e]+)\] "
r"cam=\[([-\d.,e]+)\] fwd=\[([-\d.,e]+)\] scr=\(([-\d.e]+),([-\d.e]+)\) "
r"(?:solved=\(([-\d.e]+),([-\d.e]+)\) )?(?:plate|frame)=\(([-\d.e]+),([-\d.e]+)\)"
)
# A step this far past the plate's own projected motion is a JUMP, not a glide: about a quarter of
# the plate's height, i.e. the smallest relocation a viewer reads as the plate moving by itself.
JUMP_PX = 4.0
# Frames further apart than this are not consecutive — the plate blinked out and came back, and
# differencing across the gap would invent a jump.
GAP_S = 0.1
def vec3(s):
return [float(v) for v in s.split(",")]
def load(path, lo, hi):
"""-> (tag, viewport, {entity: [(t, anchor, cam, scr, solved, rect)]}, {t: overlay count})
A bubble line has no solver stage; its `solved` slot mirrors the raw point, which makes the
SOLVE arithmetic below identically zero rather than special-cased (the section is skipped).
"""
rows, frames, vp, tag = defaultdict(list), defaultdict(int), None, None
for line in open(path):
m = LINE.match(line)
if not m:
continue
t = float(m.group(1))
if not (lo <= t <= hi):
continue
tag = m.group(2)
vp = (float(m.group(4)), float(m.group(5)))
frames[t] += 1
scr = (float(m.group(9)), float(m.group(10)))
solved = (
(float(m.group(11)), float(m.group(12)))
if m.group(11) is not None
else scr
)
rows[int(m.group(3))].append(
(
t,
vec3(m.group(6)),
vec3(m.group(7)),
scr,
solved,
(float(m.group(13)), float(m.group(14))),
)
)
for s in rows.values():
s.sort()
return tag, vp, rows, frames
def stat(vals, fmt="{:6.3f}"):
if not vals:
return " —"
v = sorted(abs(x) for x in vals)
return " ".join(
fmt.format(x) for x in (v[len(v) // 2], v[int(0.9 * (len(v) - 1))], v[-1])
)
def on_screen(p, vp):
return 0.0 <= p[0] <= vp[0] and 0.0 <= p[1] <= vp[1]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("trace")
ap.add_argument("--from", dest="lo", type=float, default=0.0)
ap.add_argument("--to", dest="hi", type=float, default=1e9)
ap.add_argument("--worst", type=int, default=10, help="how many jumps to name")
a = ap.parse_args()
tag, vp, rows, frames = load(a.trace, a.lo, a.hi)
if not rows:
sys.exit(
f"{a.trace}: no `vpl`/`bub` lines in [{a.lo}, {a.hi}] — "
"was WOW_MOVE_TRACE_TAGS=vpl (or =bub) set?"
)
what = "plates" if tag == "vpl" else "bubbles"
ts = sorted(frames)
dts = sorted(b - a_ for a_, b in zip(ts, ts[1:]) if b - a_ < GAP_S)
drawn = sum(len(s) for s in rows.values())
off = sum(1 for s in rows.values() for r in s if not on_screen(r[3], vp))
print(
f"viewport {vp[0]:.0f}x{vp[1]:.0f} frames {len(ts)} in [{ts[0]:.2f},{ts[-1]:.2f}] "
f"dt med {1000 * dts[len(dts) // 2]:.1f} ms {what}/frame {drawn / len(ts):.2f}"
)
print(f"OFF-SCREEN {what} drawn: {off}/{drawn} ({100 * off / drawn:.1f}%) [must be 0]")
snap, jumps, steps = [], [], 0
for e, s in rows.items():
for p, q in zip(s, s[1:]):
if q[0] - p[0] > GAP_S:
continue
steps += 1
d_scr = [q[3][i] - p[3][i] for i in (0, 1)]
d_sol = [q[4][i] - p[4][i] for i in (0, 1)]
d_pl = [q[5][i] - p[5][i] for i in (0, 1)]
snap.append(max(abs(d_pl[i] - d_sol[i]) for i in (0, 1)))
solve = max(abs(d_sol[i] - d_scr[i]) for i in (0, 1))
if solve >= JUMP_PX:
jumps.append((solve, q[0], e, p[5], q[5], p[3], q[3]))
print(f"SNAP extra px/frame (med p90 max): {stat(snap)} [≤ half a device pixel]")
if tag == "vpl":
print(
f"SOLVE jumps ≥ {JUMP_PX:.0f} px: {len(jumps)}/{steps} steps "
f"({100 * len(jumps) / max(steps, 1):.2f}%)"
)
for j in sorted(jumps, reverse=True)[: a.worst]:
print(
f" t={j[1]:8.3f} e={j[2]:<5d} {j[0]:7.1f} px "
f"plate ({j[3][0]:7.1f},{j[3][1]:6.1f}) -> ({j[4][0]:7.1f},{j[4][1]:6.1f}) "
f"scr ({j[5][0]:8.1f},{j[5][1]:7.1f}) -> ({j[6][0]:8.1f},{j[6][1]:7.1f})"
)
# The world anchor's own motion, split by axis. The HORIZONTAL term is the unit gliding; the
# VERTICAL term is what the run cycle does to the posed head attachment (plus terrain). A
# standing unit reads 0.0000 in both — which is exactly why 1341 cleared the anchor for the
# plate and why that clearance says nothing about a unit that is RUNNING (1398).
print("\nANCHOR motion — the world point's own Δ per frame, yd (med p90 max):")
for e, s in sorted(rows.items(), key=lambda kv: -len(kv[1]))[:8]:
pairs = [(a_, b) for a_, b in zip(s, s[1:]) if b[0] - a_[0] < GAP_S]
if len(pairs) < 10:
continue
horiz = [
((b[1][0] - a_[1][0]) ** 2 + (b[1][2] - a_[1][2]) ** 2) ** 0.5 for a_, b in pairs
]
vert = [b[1][1] - a_[1][1] for a_, b in pairs]
ys = [r[1][1] for r in s]
print(
f" e={e:<5d} n={len(pairs):<5d} horiz {stat(horiz, '{:7.4f}')} "
f"vert {stat(vert, '{:7.4f}')} vert span {max(ys) - min(ys):.4f} yd"
)
print("\nPROJECTION smoothness — |Δ| and |ΔΔ| of the raw point, per unit (med p90 max):")
for e, s in sorted(rows.items(), key=lambda kv: -len(kv[1]))[:8]:
vis = [r for r in s if on_screen(r[3], vp)]
if len(vis) < 30:
continue
d = [
[b[3][i] - a_[3][i] for i in (0, 1)]
for a_, b in zip(vis, vis[1:])
if b[0] - a_[0] < GAP_S
]
dd = [[b[i] - a_[i] for i in (0, 1)] for a_, b in zip(d, d[1:])]
anchor = max(
(abs(b[1][i] - a_[1][i]) for a_, b in zip(s, s[1:]) for i in (0, 1)), default=0.0
)
print(
f" e={e:<5d} n={len(vis):<5d} anchor {anchor:6.4f} yd/frame "
f"|Δx| {stat([v[0] for v in d])} |ΔΔx| {stat([v[0] for v in dd])} "
f"|ΔΔy| {stat([v[1] for v in dd])}"
)
if __name__ == "__main__":
main()
Executable
+1432
View File
File diff suppressed because it is too large Load Diff