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 ef0fcc327c
commit 64dc60f52f
99 changed files with 30532 additions and 81 deletions
+5
View File
@@ -0,0 +1,5 @@
# `cargo play` — the run loop. Same optimization as `--release`, plus incremental compilation (see
# `[profile.play]` in the workspace Cargo.toml for why it is a separate profile and not a flip of
# `release`). Env vars still prefix it: `WOW_USER=one WOW_PASS=pone cargo play`.
[alias]
play = "run --profile play -p benilla"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# PreToolUse(EnterWorktree | Agent) guard (docs/METHOD.md):
# refuse the HARNESS worktree tool. `scripts/wt.sh` is this repo's only worktree mechanism.
#
# Two doors, one room. `EnterWorktree` is the named tool; `Agent` with `isolation: "worktree"`
# is the same worktree by another door — the subagent is spawned into a fresh
# `.claude/worktrees/agent-<id>` checkout that no pool verb knows about (decision 2287: one was
# found under the primary on 2026-09-16, created by a session whose method text still said to
# use it). For `Agent` the block is conditional on that one field; every other Agent call
# passes untouched.
#
# Why a guard for a tool that sounds like exactly the right thing: it IS the natural reach. The
# session reads "every session works in its own git worktree", claims a slot, and then meets a
# built-in tool named EnterWorktree — the name matches the rule word for word. The pull is
# strongest right after a correct `wt.sh claim`, because the Bash tool resets cwd to the primary
# between calls, so the printed slot path looks unusable and this looks like how you "enter" it.
#
# Both of its forms are wrong here, for different reasons:
# `name` -> creates a worktree under `.claude/worktrees/`. That is precisely what the pool
# replaced: ownerless agent worktrees, no sweeper, each paying a cold ~4.5-min dep
# build, ~60 GB of them at the 2026-07-17 disk incident (decisions 0192, 0433 —
# sweep step 2 still exists to reap the strays).
# `path` -> points at a pool slot with a second tracker that has its own lifecycle opinions,
# including removal prompts. A pool slot must never be `git worktree remove`d; its
# warm target/ is the entire point (0192), and 0520 is the incident from a session
# holding the wrong idea of which slot it was in.
# So the block is absolute and needs no allowlist — same shape as guard-primary-checkout.sh.
# There is no legitimate call here: the pool's verbs are Bash, which this never sees.
#
# Unscoped by design, unlike the primary-checkout guard. That one allows sibling repos because a
# dispatched RE agent legitimately edits in wow-5875-re; this one has no such case — wow-re runs
# its own isolated-worktree rule under its own law (docs/METHOD.md, "The cross-repo RE workflow"), and
# an agent reaching for the harness tool there is making the same mistake in someone else's tree.
input=$(cat)
# A deliberate exception names itself, the WT_ALLOW_PRIMARY / WOW_ALLOW_ACCOUNT shape (0677).
[ -n "${WT_ALLOW_HARNESS_WORKTREE:-}" ] && exit 0
# No pool on this machine — a plain clone — means there is no pool to prefer: the harness tool is
# then an ordinary worktree, and nothing here has an opinion about it (scripts/wt.sh).
own=$(git -C "$(dirname "$0")" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
primary=$(cd "${own:-/nonexistent}/.." 2>/dev/null && pwd) || exit 0
pool_here() { [ -d "${WT_POOL_ROOT:-/Volumes/SanDisk/benilla-wt}" ] || [ -d "$(dirname "$1")/benilla-wt" ]; }
pool_here "$primary" || exit 0
tool=$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)
if [ "$tool" = "Agent" ]; then
isolation=$(printf '%s' "$input" | jq -r '.tool_input.isolation // empty' 2>/dev/null)
[ "$isolation" = "worktree" ] || exit 0
door="Agent isolation: worktree"
else
door="EnterWorktree"
fi
target=$(printf '%s' "$input" | jq -r '.tool_input.path // .tool_input.name // empty' 2>/dev/null)
cat >&2 <<EOF
BLOCKED — $door is not how this repo does worktrees${target:+ (asked for: $target)}.
\`scripts/wt.sh\` is the only worktree mechanism here (docs/METHOD.md, "Parallel sessions — one
worktree each"). The harness tool builds worktrees under \`.claude/worktrees/\` — ownerless, with
no sweeper and a cold ~4.5-min dependency build each; ~60 GB of those strays were the 2026-07-17
disk incident (decisions 0192/0433). Pointing it at a pool slot instead is no better: a slot must
never be removed, its warm target/ is the whole point.
If you have NOT claimed a slot yet:
./scripts/wt.sh claim <short-task-name> # prints the slot path on stdout
If you HAVE claimed one, you do not need this tool — you need the path. The Bash tool resets cwd
to the primary between calls; that is expected, not a problem to solve. Two habits cover it
(a dispatched agent inherits the session's slot and works there the same way — it never needs a
tree of its own; a diagnostic that genuinely needs a clean tree is the parent's to run, on a
synced slot):
Bash cd /path/to/benilla-wt/pool-N && cargo test ... # every call, prefix it
Read/Edit /path/to/benilla-wt/pool-N/crates/... # absolute, under the slot
The gates, the hooks and the runtime asset reads all follow the slot on their own.
EOF
exit 2
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# PreToolUse(Bash) guard: refuse to keep working when the PRIMARY checkout is dirty and this
# session holds no pool slot. Exit 2 blocks the call and hands stderr back to the agent.
#
# **This is `guard-primary-checkout.sh`'s hole, closed.** That hook is wired to `Edit|Write|
# NotebookEdit`, and it reasons — in a comment that was true when it was written — that "the three
# things that genuinely write to the primary are scripts, not tool calls ... and they run as Bash,
# which this never sees." A session editing through Bash (`sed -i`, a `python3 - <<PY` heredoc,
# `tee`) is not covered by a single line of it. On 2026-08-25 a session did exactly that: 434 lines
# across ten files, committed to main from the primary, while three neighbours were landing into the
# same checkout. Nothing fired. docs/METHOD.md said, and had said for months, that such a write is
# hook-blocked. It was not.
#
# **Why dirtiness and not the command.** The obvious fix is to scan the command for write verbs and
# resolve their arguments. That is shell parsing, and it is a losing game: the write that started
# this was a heredoc whose target path was computed inside Python, which no pattern can see. So this
# asks a question with no parsing in it at all — *is the primary dirty, and is this session the one
# with nowhere else to be?* It cannot be evaded, because it does not look at what you typed.
#
# The cost is that it fires on the command AFTER the first stray write rather than on the write
# itself. Measured against the incident it exists for, that is one command, not 434 lines: the
# session ran ~40 Bash calls over that work.
#
# **A dirty primary is never normal** (docs/METHOD.md hard rules; docs/METHOD.md "Parallel sessions"), which
# is what makes this a cheap question to ask. The primary belongs to no session and stays clean; a
# session that has claimed a slot is not answerable for dirt it did not make, so it passes.
#
# `WT_ALLOW_PRIMARY=1` overrides — in the hook's environment, or as an inline prefix on the command
# itself, because for a Bash tool call that is where a deliberate exception is actually typed.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -n "$cmd" ] || exit 0
# The deliberate exception, named in the transcript either way.
[ -n "${WT_ALLOW_PRIMARY:-}" ] && exit 0
case "$cmd" in *WT_ALLOW_PRIMARY=1*) exit 0 ;; esac
# The remedy must never be blocked by the thing it remedies: `wt.sh claim` is the one command this
# message asks for, and `status`/`land`/`release` are how a session gets itself unstuck.
case "$cmd" in *wt.sh*) exit 0 ;; esac
# Scoped by the shared git common-dir, like the sibling hooks: another repo (wow-5875-re) has its
# own, so a dispatched RE agent is never touched by benilla's rule.
own=$(git -C "$(dirname "$0")" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
[ -n "$own" ] || exit 0
primary=$(cd "$own/.." 2>/dev/null && pwd) || exit 0
# No pool on this machine — a plain clone — means this checkout IS the working tree: nothing to
# guard. The pool is what makes the primary off-limits (scripts/wt.sh); without one, the rule has
# nothing to protect.
pool_here() { [ -d "${WT_POOL_ROOT:-/Volumes/SanDisk/benilla-wt}" ] || [ -d "$(dirname "$1")/benilla-wt" ]; }
pool_here "$primary" || exit 0
# Clean primary -> nothing to protect, and this is the overwhelmingly common case: one `git status`
# against a warm index, ~10 ms, on a tree whose only large directory (target/) is gitignored.
dirt=$(git -C "$primary" status --porcelain 2>/dev/null | head -6)
[ -n "$dirt" ] || exit 0
mine="${CLAUDE_CODE_SESSION_ID:-}"
[ -n "$mine" ] || exit 0 # a human shell or CI — not ours to police
# Does this session already hold a slot? Then it has somewhere to be, and the dirt in the primary
# is somebody else's problem to answer for — blocking here would be a gate firing on innocent work,
# which is how a gate teaches "the gate is broken, work around it".
#
# **BOTH pool roots, because there are two** (decision 1726): slots are allocated on the external
# drive, and the old internal root beside the primary is draining but still live. This hook asked
# only the internal one — so from the day 1726 landed, a session holding an external slot answered
# "no slot" and was blocked on its NEXT Bash call after any neighbour dirtied the primary. That is
# a gate firing on exactly the innocent work the paragraph above says it must not, and it takes out
# every session at once rather than the one that made the mess. `WT_POOL_ROOT` is honoured for the
# same reason `wt.sh` honours it.
for pool in "${WT_POOL_ROOT:-/Volumes/SanDisk/benilla-wt}" "$(dirname "$primary")/benilla-wt"; do
for marker in "$pool"/pool-*/.wt-claimed; do
[ -e "$marker" ] || continue
[ "$(cut -f3 "$marker" 2>/dev/null)" = "$mine" ] && exit 0
done
done
cat >&2 <<EOF
BLOCKED — the PRIMARY checkout is dirty and this session has not claimed a slot.
primary: $primary
$(printf '%s\n' "$dirt" | sed 's/^/ /')
The primary belongs to no session and stays clean (docs/METHOD.md hard rules; docs/METHOD.md "Parallel
sessions — one worktree each"). Working here reverts other sessions' edits, jams the
whole-workspace gates with a neighbour's in-flight code, and blocks their \`wt.sh land\`.
If those changes are YOURS — you have been editing the primary, most likely through Bash, which
\`guard-primary-checkout.sh\` cannot see:
./scripts/wt.sh claim <short-task-name> # prints a slot path
git -C $primary diff > /tmp/carry.patch # needs WT_ALLOW_PRIMARY=1 on this call
# …then apply it in the slot and reset the primary.
If they are NOT yours, do not clean them up — they are another session's live work. Claim a slot
and work there; say so to the director if the primary stays dirty.
If the pool is genuinely full, \`wt.sh claim\` says so and the answer is to STOP and ask the
director which slot to take over — never to carry on here.
A deliberate exception is \`WT_ALLOW_PRIMARY=1 <command>\`.
EOF
exit 2
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# PreToolUse(Edit|Write|NotebookEdit) guard (docs/METHOD.md):
# refuse to write into a checkout that is not this session's. Exit 2 blocks the call and hands
# stderr back to the agent, which then claims a slot and works there.
#
# TWO trees are not yours, and the file name only says the first (kept, deliberately: renaming it
# would churn `.claude/settings.json`, which every live session reads, for a naming tidy-up):
#
# 1 · the PRIMARY checkout — nobody's, and it stays clean (decision 0976, below);
# 2 · another SESSION's pool slot (decision 1042).
#
# It fires at the FIRST edit because that is where the fix is one `wt.sh claim` and nothing has been
# lost; unguarded, the cost lands on a BYSTANDER hours later, whose `wt.sh land` refuses on a dirty
# primary full of work they can neither see nor move. Why a hook and not more prose: decision 0976.
#
# The block is absolute and needs no allowlist — there is no legitimate agent edit here. The one
# thing that genuinely writes to the primary is a script, not a tool call (`wt.sh land` fast-forwards
# main), and it runs as Bash, which this never sees. `WT_ALLOW_PRIMARY=1` overrides, the `WOW_ALLOW_ACCOUNT` shape
# from 0677: structurally hard rather than merely forbidden, and a deliberate exception names itself.
#
# **"…which this never sees" WAS the hole, and it is why `guard-primary-bash.sh` exists.** That
# sentence assumed only scripts and humans reach the primary through Bash. An AGENT editing with
# `sed -i` or a `python3 - <<PY` heredoc does too, and none of it passes through this file: on
# 2026-08-25 a session wrote 434 lines into the primary and committed them to main without one line
# of this hook running. This one still catches the Edit/Write path at the FIRST write, which is the
# better moment; the Bash guard is the backstop that cannot be evaded by choice of tool.
#
# Scoped by the shared git common-dir, like `on-stop.sh`: a sibling repo
# (wow-5875-re) has its own, so a dispatched RE agent editing THERE is never touched by benilla's
# rule. Paths outside any git repo — the scratchpad, `~/.benilla` — are likewise none of our business.
input=$(cat)
# Edit/Write carry `file_path`; NotebookEdit carries `notebook_path`.
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' 2>/dev/null)
[ -n "$path" ] || exit 0 # no target we can judge → allow
# Absolutize against the tool's own cwd — a relative path from a session sitting IN the primary is
# exactly the case this exists to catch, and it is the one that arrives without a leading slash.
cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)
cwd="${cwd:-$PWD}"
case "$path" in
/*) ;;
'~'/*) path="${HOME}${path#\~}" ;;
*) path="$cwd/$path" ;;
esac
# A Write targets a file that does not exist yet, and may name directories that do not either, so
# resolve against the nearest existing ancestor rather than the path itself.
dir=$(dirname "$path")
while [ ! -d "$dir" ] && [ "$dir" != "/" ] && [ "$dir" != "." ]; do dir=$(dirname "$dir"); done
[ -d "$dir" ] || exit 0
# Same-REPO check first (this repo's worktrees all share one common-dir; other repos don't).
own=$(git -C "$(dirname "$0")" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
there=$(git -C "$dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
[ -n "$own" ] && [ "$there" = "$own" ] || exit 0
# The primary checkout is the common-dir's parent — the same expression `wt.sh` computes `PRIMARY`
# with, so the two can never disagree about which tree is the shared one. A linked worktree's
# toplevel is its own slot path, and falls through to the slot check below.
primary=$(cd "$own/.." 2>/dev/null && pwd) || exit 0
# No pool on this machine — a plain clone — means this checkout IS the working tree: nothing to
# guard. The pool is what makes the primary off-limits (scripts/wt.sh); without one, the rule has
# nothing to protect.
pool_here() { [ -d "${WT_POOL_ROOT:-/Volumes/SanDisk/benilla-wt}" ] || [ -d "$(dirname "$1")/benilla-wt" ]; }
pool_here "$primary" || exit 0
top=$(git -C "$dir" rev-parse --path-format=absolute --show-toplevel 2>/dev/null)
[ -n "$top" ] || exit 0
if [ "$top" = "$primary" ]; then
[ -n "${WT_ALLOW_PRIMARY:-}" ] && exit 0 # deliberate exception, named in the transcript
rel="${path#"$primary"/}"
cat >&2 <<EOF
BLOCKED — that write targets the PRIMARY checkout ($primary/$rel).
The primary belongs to no session and stays clean (docs/METHOD.md hard rules; docs/METHOD.md "Parallel
sessions — one worktree each"). Editing it silently reverts other sessions' work, jams the
whole-workspace gates with a neighbour's in-flight code, and blocks their \`wt.sh land\`.
Claim a slot and work there instead:
./scripts/wt.sh claim <short-task-name> # prints the slot path; cd there and carry on
If you already have a claimed slot, you are simply in the wrong directory — reissue this edit
against the slot path. A deliberate exception is \`WT_ALLOW_PRIMARY=1\`.
EOF
exit 2
fi
# ── Another session's slot (decision 1042) ───────────────────────────────────────────────────────
# 1041 stopped a foreign session at `wt.sh land`. This stops it at the first EDIT, which is 0976's
# whole argument one level down: at the verb, the damage is already a split branch somebody has to
# unpick by hand; at the edit, the fix is one `wt.sh claim` and nothing has happened yet. On
# 2026-08-06 the gap between the two was an hour, a red `fmt --check` on files this session had
# never touched, and two decision records that reverted under it mid-write.
#
# **Agents are safe here, and that is a measured fact, not a hope**: a dispatched subagent reports
# the same `CLAUDE_CODE_SESSION_ID` as its parent (checked, 2026-08-06 — same value, same PID). So
# the bulk-edit dispatches docs/METHOD.md's "Who types what" depends on, which work in their session's
# own slot by design, pass this untouched. 1041 named that as the fact it was waiting on.
marker="$top/.wt-claimed"
[ -e "$marker" ] || exit 0 # free slot: unclaimed, no session to wrong
owner=$(cut -f3 "$marker" 2>/dev/null)
case "$owner" in "" | "-") exit 0 ;; esac # pre-1041 marker: no session recorded
mine="${CLAUDE_CODE_SESSION_ID:-}"
[ -n "$mine" ] || exit 0 # nothing to compare (human shell, CI)
[ "$owner" = "$mine" ] && exit 0 # yours — the overwhelmingly common case
[ -n "${WT_ALLOW_FOREIGN:-}" ] && exit 0 # deliberate exception, named in the transcript
holder=$(cut -f1 "$marker" 2>/dev/null)
cat >&2 <<EOF
BLOCKED — that write targets ANOTHER SESSION's worktree ($top).
that slot is claim '$holder', held by session $owner
you are session $mine
Two sessions in one slot revert each other's edits, redden each other's gates, and split each
other's commits at land (decisions 1041, 1042). Claim your own slot:
./scripts/wt.sh claim <short-task-name> # prints the slot path; cd there and carry on
\`wt.sh status\` tags the slot you own \`(yours)\` — if nothing is tagged, you have not claimed one.
A deliberate exception (recovering a dead session's work, say) is \`WT_ALLOW_FOREIGN=1\`.
EOF
exit 2
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# PreToolUse(Bash) guard (docs/METHOD.md "Who types what": *Parallel agents share one working tree —
# whole-tree git ops are forbidden*): refuse the handful of git verbs that sweep a tree — or the
# SHARED STASH STACK — rather than a path.
#
# Why a hook, on 2026-08-14: a session reaching for a one-command "revert my file, run the test,
# put it back" repro typed `git stash push -- <path> -q`. The trailing `-q` parsed as a pathspec,
# the push failed — and the follow-up `git stash pop` then popped a DIFFERENT session's entry
# (`sess/death-arc`'s) into this worktree. It conflicted on docs/MAP.md and aborted, so nothing was
# lost; the near-miss is the point. **Stashes are per-REPOSITORY, not per-worktree** — every pool
# slot shares one stack — so `stash pop` is a whole-tree op wearing a local-looking name, and the
# blast radius is another session's uncommitted hours. The earlier incident docs/METHOD.md records (a
# six-agent fan-out losing hours to one agent's diagnostic `git stash`) is the same mechanism.
#
# The rule the prose already had, made structural — 0976's argument for the primary-checkout guard,
# one verb over: at the command, the fix is one different command and nothing has happened; after
# it, somebody unpicks a stash stack by hand and hopes the conflict preserved their work.
#
# **The block list is deliberately SHORT.** A gate that fires on legitimate work teaches "the gate
# is broken, work around it" (the 2026-07-20 incident), so
# this blocks only forms with no path-scoped reading:
#
# git stash [push|save|pop|apply|drop|clear|branch|create|store] — the shared stack
# git reset --hard — sweeps the tree
# git clean -f… — deletes untracked files
# git checkout . | git checkout -- . | git restore . | … — the sweep-everything forms
#
# Everything else passes, including the ones this repo's own work depends on: `git stash list`/
# `show` (read-only), `git checkout HEAD -- <path>` (the correct way to do the repro above),
# `git checkout --detach`/`-B <branch>` (what `wt.sh` itself runs), `git restore --source=X -- path`,
# and a soft/mixed `git reset` (which moves HEAD but leaves the working tree alone).
#
# Scoped by the shared git common-dir like every other hook here: a sibling repo (wow-5875-re) has
# its own rules and falls through untouched. Note the hook only ever sees the agent's own command
# text, so git run INSIDE our scripts (`wt.sh`) is never its business.
#
# `WT_ALLOW_TREE_OP=1` overrides, the `WOW_ALLOW_ACCOUNT`/`WT_ALLOW_PRIMARY` shape (0677, 0976):
# structurally hard rather than merely forbidden, and a deliberate exception names itself.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -n "$cmd" ] || exit 0
[ -n "${WT_ALLOW_TREE_OP:-}" ] && exit 0 # deliberate exception, named in the transcript
# Per SEGMENT of a (possibly compound) command, so a `git stash` riding behind a `&&` is judged
# on its own rather than by the line's first verb.
verdict=""
while IFS= read -r seg; do
# Strip a leading `cd … &&`-style residue and normalise whitespace for matching.
s=$(printf '%s' "$seg" | sed 's/^[[:space:]]*//; s/[[:space:]]\{1,\}/ /g')
# Only `git …` invocations, with or without a leading `-C <dir>`.
case "$s" in
git\ *) ;;
*) continue ;;
esac
# Drop `-C <dir>` and other pre-verb globals so the verb is the next token.
v=$(printf '%s' "$s" | sed -E 's/^git( +-[A-Za-z-]+( +[^ ]+)?)* +//')
case "$v" in
# ── the shared stash stack (read-only subcommands excepted) ──────────────────────────────
stash\ list* | stash\ show*) ;;
stash | stash\ *)
verdict="git stash — the stash stack is shared by EVERY worktree of this repo, so this can
capture, or restore, another live session's uncommitted work. It has already come within one
merge conflict of doing exactly that (2026-08-14).
For the common case — 'revert one file, run something, put it back' — copy it aside and use the
path-scoped restore instead:
cp <file> \"\$SCRATCH/<file>.mine\"
git checkout HEAD -- <file> # path-scoped: touches nothing else, no shared state
<run the thing>
cp \"\$SCRATCH/<file>.mine\" <file>
To park work properly, commit it on your session branch — that is what the branch is for."
break
;;
# ── whole-tree resets / sweeps ───────────────────────────────────────────────────────────
reset*--hard*)
verdict="git reset --hard — discards every uncommitted change in this worktree, including
work belonging to a dispatched agent running in this session's slot right now.
Undo one path with \`git checkout HEAD -- <path>\`; move HEAD without touching files with
\`git reset --soft\`."
break
;;
clean\ *-*f* | clean\ -*f*)
verdict="git clean -f — deletes untracked files outright, and untracked is exactly what a
neighbouring agent's not-yet-added new file looks like.
Delete the specific files you mean by name, or list first with \`git clean -n\`."
break
;;
checkout\ . | checkout\ --\ . | checkout\ -- | restore\ . | restore\ --\ . | checkout\ -f | checkout\ -f\ *)
verdict="a pathless/sweep-everything checkout — it reverts the WHOLE tree, not the file you
are looking at.
Name the path: \`git checkout HEAD -- <path>\` (or \`git restore -- <path>\`)."
break
;;
esac
done <<EOF
$(printf '%s' "$cmd" | tr ';&|\n' '\n\n\n\n')
EOF
[ -n "$verdict" ] || exit 0
# Same-REPO check LAST — it costs a git call, and the overwhelming majority of commands never
# reach it. The cwd resolution: a tree-wide op is judged
# by the tree it lands in, and a `cd <slot> && …` is how every session writes one.
cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)
cwd="${cwd:-$PWD}"
cd_dir=$(printf '%s' "$cmd" | tr ';&|' '\n' \
| sed -n 's/^[[:space:]]*cd[[:space:]]\{1,\}\([^[:space:]]\{1,\}\)[[:space:]]*$/\1/p' | tail -n 1)
if [ -n "$cd_dir" ]; then
case "$cd_dir" in
/*) cwd="$cd_dir" ;;
'~'*) cwd="${HOME}${cd_dir#\~}" ;;
*) cwd="$cwd/$cd_dir" ;;
esac
fi
[ -d "$cwd" ] || cwd=$PWD
own=$(git -C "$(dirname "$0")" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
there=$(git -C "$cwd" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
[ -n "$own" ] && [ "$there" = "$own" ] || exit 0 # another repo's rules are its own
primary=$(cd "$own/.." 2>/dev/null && pwd) || exit 0
# No pool on this machine — a plain clone — means this checkout IS the working tree, shared with
# nobody: nothing to guard. The pool is what makes the tree shared (scripts/wt.sh).
pool_here() { [ -d "${WT_POOL_ROOT:-/Volumes/SanDisk/benilla-wt}" ] || [ -d "$(dirname "$1")/benilla-wt" ]; }
pool_here "$primary" || exit 0
cat >&2 <<EOF
BLOCKED — tree-wide git op in a SHARED working tree.
$verdict
(docs/METHOD.md, "Who types what": parallel agents share one working tree, so whole-tree git ops are
forbidden — diagnostics use path-scoped commands only. A genuinely deliberate exception is
WT_ALLOW_TREE_OP=1; if you need a clean tree of your own, take a worktree instead.)
EOF
exit 2
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# PreToolUse(Edit|Write|NotebookEdit) guard (docs/METHOD.md hard rules; decision 1486): refuse to write
# anything into the 1.12.1 install. Exit 2 blocks the call and hands stderr back to the agent.
#
# The rule is the director's, stated 2026-08-21 while B261 was being built: *benilla never changes
# or adds anything in the WoW folder*. It has two halves and this file is the second one.
#
# 1 · the CLIENT never writes there — every persisted file resolves through
# `crate::local_state` into `benilla-config/` beside the binary (0954/1175), and
# `scripts/smoke.sh` now measures that a full run leaves the install byte-for-byte as it
# found it;
# 2 · WE never write there either — which is this hook, because we already had. The install
# currently carries `apitrace-stderr.log`, `apitrace-stderr-ring.log` and
# `abbey-standing-frame1501.png`: three files a session dropped in the nearest folder to
# hand. Nobody decided to; that is exactly why prose was never going to be enough (0976's
# argument, one folder over).
#
# WHY IT MATTERS MORE HERE THAN IT LOOKS. The install is not ours to write to at all — it is the
# player's own copy of somebody else's game, and the one thing the repo may never contain
# (docs/METHOD.md). On THIS machine it is also a symlink into the sibling RE repo (`wow-5875-re/WoW`),
# so a stray write lands in another repository's working tree and shows up in their `git status`.
# And it makes "what here is benilla's?" unanswerable, which is the whole reason 0954 put our state
# in one visible folder.
#
# Scoped by PHYSICAL path, not by name: the slot's `WoW` is a symlink, so a write can arrive
# spelled either way and only `cd -P` collapses the two. `$WOW_DATA` is honoured because that is
# the resolver's own first step — a machine that points the client elsewhere protects that instead.
#
# `BENILLA_ALLOW_INSTALL_WRITE=1` overrides, the `WT_ALLOW_PRIMARY` shape: structurally hard rather
# than merely forbidden, and a deliberate exception names itself in the transcript.
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' 2>/dev/null)
[ -n "$path" ] || exit 0
cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)
cwd="${cwd:-$PWD}"
case "$path" in
/*) ;;
'~'/*) path="${HOME}${path#\~}" ;;
*) path="$cwd/$path" ;;
esac
[ -n "${BENILLA_ALLOW_INSTALL_WRITE:-}" ] && exit 0
# Resolve the target against its nearest EXISTING ancestor — a Write names a file, and may name
# directories, that do not exist yet.
dir=$(dirname "$path")
while [ ! -d "$dir" ] && [ "$dir" != "/" ] && [ "$dir" != "." ]; do dir=$(dirname "$dir"); done
[ -d "$dir" ] || exit 0
real=$(cd -P "$dir" 2>/dev/null && pwd) || exit 0
# The roots to protect. `$WOW_DATA` first (the resolver's own order), then the `WoW` link every
# checkout and pool slot carries. Both physicalised, so the symlink and its target are one root.
own=$(git -C "$(dirname "$0")" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
primary=$(cd "${own:-.}/.." 2>/dev/null && pwd)
for root in "${WOW_DATA:-}" "$primary/WoW" "$cwd/WoW"; do
[ -n "$root" ] && [ -d "$root" ] || continue
root=$(cd -P "$root" 2>/dev/null && pwd) || continue
case "$real/" in
"$root"/*)
cat >&2 <<EOF
BLOCKED — that write targets the 1.12.1 INSTALL ($root).
the file: $path
benilla READS a WoW install and never writes to one (docs/METHOD.md hard rules; decision 1486). It is
the player's own copy of somebody else's game, the repo may never contain any of it, and on this
machine that folder is a symlink into the sibling RE repo — a stray file lands in wow-5875-re's
working tree.
Where the thing you are writing actually goes:
· anything the CLIENT persists → \`crate::local_state\` (\`benilla-config/\`, 0954/1175)
· a capture, log, or scratch file → the session scratchpad
· a decision, a doc → the repo
A deliberate exception is \`BENILLA_ALLOW_INSTALL_WRITE=1\`.
EOF
exit 2
;;
esac
done
exit 0
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Stop hook (docs/METHOD.md): tidy pass at the end of every turn — stamp this session's slot
# claim so the pool can tell a live session from a dead one, and format Rust so the fmt gate is
# green by the time anything commits. Best-effort: never block the agent from stopping (always
# exit 0). Operates on the SESSION'S checkout (worktree-aware), guarded via the shared git common-dir
# so it never formats a sibling repo (all benilla worktrees share one common-dir; wow-5875-re doesn't).
#
# It also **never formats the primary checkout**, and says so. Formatting is a write, and the primary
# belongs to no session (docs/METHOD.md "Parallel sessions"); a session sitting there is the mistake
# `guard-primary-checkout.sh` blocks at edit time, and this is the one place that notices the same
# mistake arriving by any OTHER route — a `sed -i`, a `perl -pi`, a script — since it runs every
# turn regardless of which tools were used. Warn, never block: a Stop hook that refuses to stop is a
# trap, and this one is best-effort by design.
own=$(git -C "$(dirname "$0")" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
# ── The claim HEARTBEAT ──────────────────────────────────────────────────────────────────────────
# Stamp this session's claim marker once a turn, so "is anyone still working in that slot?" is a
# fact we hold rather than a guess. Before this, the only activity signal a dead claim could be
# judged on was its TIP COMMIT age, and `wt.sh` says why that is a poor one: a live session looks
# stale the moment it lands, so the reap window had to be 24 h on BOTH clocks to be safe. The cost
# of that safety was the director's own complaint — close a session after its work lands and the
# slot sits unusable for a day, with nothing "obviously free" for the next session to take.
#
# Deliberately BEFORE the cwd checks below, and found by SESSION ID rather than by `$PWD`: a turn
# that happened to end in the scratchpad, or in the primary, is still a turn this session was alive
# for. Best-effort like everything else here — a failed touch must never keep a session from
# stopping.
mine="${CLAUDE_CODE_SESSION_ID:-}"
if [ -n "$own" ] && [ -n "$mine" ]; then
primary_dir=$(cd "$own/.." 2>/dev/null && pwd)
if [ -n "$primary_dir" ]; then
# BOTH pool roots (decision 1726): slots are allocated on the external drive and the old
# internal root is draining. This walked only the internal one, so from the day 1726 landed the
# heartbeat stopped stamping for every session in the new pool — and the heartbeat is the whole
# reason `wt.sh` can tell a live claim from a dead one. A claim that never restamps looks idle
# to `reap_idle_claims`, which is how a full `claim` takes over a slot somebody is working in.
for pool in "${WT_POOL_ROOT:-/Volumes/SanDisk/benilla-wt}" "$(dirname "$primary_dir")/benilla-wt"; do
for marker in "$pool"/pool-*/.wt-claimed; do
[ -e "$marker" ] || continue
[ "$(cut -f3 "$marker" 2>/dev/null)" = "$mine" ] || continue
touch "$marker" 2>/dev/null || true
break 2
done
done
fi
fi
root=$(git rev-parse --path-format=absolute --show-toplevel 2>/dev/null)
cur=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
[ -n "$root" ] && [ -n "$own" ] && [ "$cur" = "$own" ] || exit 0
primary=$(cd "$own/.." 2>/dev/null && pwd)
# No pool on this machine — a plain clone — means the checkout IS the working tree, and formatting
# it is the whole job. The pool is what makes the primary off-limits (scripts/wt.sh).
pool_here() { [ -d "${WT_POOL_ROOT:-/Volumes/SanDisk/benilla-wt}" ] || [ -d "$(dirname "$1")/benilla-wt" ]; }
if [ -n "$primary" ] && [ "$root" = "$primary" ] && [ -z "${WT_ALLOW_PRIMARY:-}" ] && pool_here "$primary"; then
echo "on-stop: this session is sitting in the PRIMARY checkout ($primary) — it belongs to no" >&2
echo "on-stop: session and must stay clean. Not formatting it. Run './scripts/wt.sh claim <name>'" >&2
echo "on-stop: and work in the printed slot; move anything already changed here into that slot." >&2
exit 0
fi
cd "$root" || exit 0
cargo fmt --all >/dev/null 2>&1 || true
exit 0
+58
View File
@@ -0,0 +1,58 @@
{
"autoMemoryEnabled": false,
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/on-stop.sh\"",
"timeout": 120
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-tree-wide-git.sh\"",
"timeout": 10
},
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-primary-bash.sh\"",
"timeout": 10
}
]
},
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-primary-checkout.sh\"",
"timeout": 10
},
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-wow-install.sh\"",
"timeout": 10
}
]
},
{
"matcher": "EnterWorktree|Agent",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-harness-worktree.sh\"",
"timeout": 10
}
]
}
]
}
}
+40
View File
@@ -0,0 +1,40 @@
---
name: probe
description: Run the client unattended against the local vmangos server — a live probe, a capture, a leg. Use whenever a session or an agent needs the client logged in without a person at the keyboard.
---
The rules are in `docs/METHOD.md` ("The local server"). This is the how.
**Identity.** `WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<N spelled>` for `pool-N`
(pool-4: `probe4` / `pprobe4` / `Probefour`; pool-0: `Probezero`). The client refuses a login
about to land on a player's account or another slot's; `WOW_ALLOW_ACCOUNT=1` overrides.
Probe accounts hold the highest GM level, so every GM command works.
**Every run:** `WOW_UNATTENDED=1`, which reconnects instead of parking on a dialog and exits
non-zero on a login it cannot pass. An agent adds `WOW_NOSOUND=1`. `scripts/leg.sh`,
`smoke.sh`, `cine.sh` and `summon-live.sh` pass both already; a capture (`WOW_CAPTURE`) and a
rig (`WOW_RIG`) imply them.
**The body.** A probe cannot die: every world entry sends `.cheat god on`. `.die` clears it for
a death test; `WOW_GOD=off` runs unshielded. GM mode is on by default and makes reaction
colours, nameplates, threat, aggro, fall damage and breath read wrong: `WOW_GM=off` for any of
those. The preflight banner names the body and its state on every entry; read it first.
**A body that is not a level-1 human warrior:** `WOW_RIG="tauren druid 60 gear:heal-preraid-bis
spec:heal-preraid-bis at:ThunderBluff"` finds or creates it on the slot's account and applies
the lot; `WOW_RIG="gear:?"` lists the catalog. `WOW_PROBE_CHAT="<cmd>"` sends a GM command, and
the server's answer is echoed as `net: server says`.
**Wire work is proven from the trace:** `WOW_MOVE_TRACE=<path> WOW_MOVE_TRACE_TAGS="move,snd,in"
WOW_PROBE_EXIT_AT=<secs>`, then grep the file for the `rly` and `snd` lines. An unfiltered trace
in a populated scene stops being a per-frame clock.
**Keep a long run awake:** `caffeinate -u -t 2`, then `caffeinate -dis <command>`. An occluded
window runs at about 1 fps and the live-FPS line stamps `occluded_frames=`; a lock screen
throttles the same way but reads 0 with `p99_ms` near 1020, so check
`ioreg -n Root -d1 -a | grep CGSSessionScreenIsLocked` before an unattended series.
**Captures:** `scripts/visual.sh` wraps a capture run; the recipe, and why a capture goes
through `cargo run` and never a bare binary, is the header of
`crates/benilla-app/src/capture/mod.rs`. Pixel questions go through `benilla-visual crop`,
`series` and `hotspot`.
+32 -3
View File
@@ -4,18 +4,47 @@
# Cargo.lock IS committed (this workspace produces binaries).
# --- Blizzard-owned content: NEVER commit ---
# A legally-installed 1.12.1 client, read at runtime; not redistributed.
# The user's legally-installed 1.12.1 client. Read at runtime; not redistributed.
/WoW
# Cached / extracted assets derived from an install.
# The user's legally-installed Classic Era client (product wow_classic_era; the shared CASC Data
# dir sits beside the product dir). Source of the Era UI exports for decision 0068; not redistributed.
/WoW-era
# Cached / extracted assets derived from the user's install.
/extracted/
/out/
# Real client files used as test fixtures (document expected paths, do not commit).
**/tests/fixtures/
# Profiling output (`--features bevy/trace_chrome`).
# --- benilla's own local state (decision 0954, moved out of the install by 1175) ---
# The one folder benilla persists into: config.toml, macros/, bindings/, saved-variables.lua,
# camera/. A dev build resolves it to the primary checkout (so the worktree pool shares one); a
# release build puts it beside the binary — which is why it is not called `benilla/`: that name
# collides with the binary of the same name. Machine-local by definition — `crate::local_state`
# is the only thing in the tree that may write it.
/benilla-config
# Worktree-pool claim marker (scripts/wt.sh) — per-slot state, never content.
/.wt-claimed
# Profiling output: `--features bevy/trace_chrome` drops a multi-GB flame chart in the cwd, which
# otherwise leaves the checkout "dirty" and blocks the worktree verbs (decision 0610).
trace-*.json
# OS / editor cruft
.DS_Store
*.swp
/.idea/
# Claude Code: share the project hooks + settings + slash commands + skills, ignore per-machine state.
/.claude/*
!/.claude/settings.json
!/.claude/hooks/
!/.claude/commands/
!/.claude/skills/
/.claude/settings.local.json
# Python bytecode from the `scripts/` tooling.
__pycache__/
# The vanilla addon corpus the UI engine's real-addon tests read (third-party content, decision
# 2329): a symlink `scripts/wt.sh` lays into every slot and the primary, beside `WoW`.
/wow-addons-vanilla
+20
View File
@@ -0,0 +1,20 @@
# benilla
A from-scratch World of Warcraft 1.12.1 client in Rust and Bevy. The reference client is the
spec: benilla is a faithful, modern implementation of it, not a place to get creative.
Read, in this order:
1. `docs/METHOD.md` — the rules and how we work. Binding for every session.
2. `docs/MAP.md` — what is built right now, generated from the code. Orient here before
touching anything.
3. `docs/CONTRIBUTING.md` — what gets in and how a change is judged.
Procedures with steps are skills in `.claude/skills/`: `probe` (an unattended run against the
local server).
The rest of the root is code and data: `crates/` the workspace, `reference/` the 1.12 name
catalogues the tests check against, `scripts/` the gates and instruments, `third_party/`
vendored code.
@docs/METHOD.md
+4 -4
View File
@@ -9,10 +9,10 @@
</div>
> [!IMPORTANT]
> **Issues and pull requests are closed here.** benilla is a solo project developed in a private
> tree; this repo is its export, published as squashed snapshots, so a PR here has nothing to land
> on. The best way to contribute is to join the [Discord](https://discord.gg/wJSJx467G4) and report
> the bugs you find. Questions and ideas are welcome in the same place.
> **Issues are off, and pull requests are not open yet.** [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md)
> says what will get in when they are. Until then, the way to contribute is to join the
> [Discord](https://discord.gg/wJSJx467G4) and report the bugs you find. Questions and ideas are
> welcome in the same place.
benilla speaks the original 1.12.1 protocol, so it connects to any server the real client could,
and reads its game data at runtime from your own 1.12.1 install. Every file format and the network
@@ -34,7 +34,7 @@
//!
//! - **Over-reports**: any quad from a new widget counts, including one drawn off-screen or under
//! another window. "Something reached the render list" is not "the player can see it"; the
//! director's eye is still the judge of that (the contract §7). Over-reporting is the deliberate
//! director's eye is still the judge of that (`docs/METHOD.md` §7). Over-reporting is the deliberate
//! direction — a silent under-report is the failure mode this whole column exists to end.
//! - **Under-reports** in one known shape: an addon that changes an *existing* widget in place —
//! `SetTexture` on one of our regions, `SetBackdrop` on one of our frames — creates no new
+1 -1
View File
@@ -587,7 +587,7 @@ fn install(n: &mut AuraNodes, spell_id: u32, procs: &[AuraNode], now: f32) {
/// the spell, the nodes it installed, and the target the ramp is now aiming at.
///
/// This is how "stealth shows nothing" is *reproduced and then closed* without eyeballing a capture
/// (`method.md` §5/§6): with the aura layer dead the trace is silent on `.cast 1784`; with it live
/// (`docs/METHOD.md` §5/§6): with the aura layer dead the trace is silent on `.cast 1784`; with it live
/// the same press prints `aur arm spell=1784 alpha=[0.3] -> base 1.00 target 0.30`, and the drop
/// prints the ramp back to 1.00. It rides the shared move-trace sink so an aura edge interleaves on
/// one timeline with the movement/anim lines — a "why did it not fade when I moved" question needs
+1 -1
View File
@@ -404,7 +404,7 @@ fn fire_shakes(
/// Charge (1424). That is a read-before-write of a zero-initialised field, not a design — the
/// value it wants is unambiguous, being the same one the sound leg uses a moment later — so we
/// spawn at the kit play's real position and the authored data plays. benilla implements the
/// mechanism, not the quirk (the contract §3, `method.md` step 3); the reference's own numbers are
/// mechanism, not the quirk (`docs/METHOD.md` §3, `docs/METHOD.md` step 3); the reference's own numbers are
/// in `decisions/1849`, and reverting to them would mean deliberately modelling an uninitialised
/// field we do not have.
fn fire_kit_shakes(
+1 -1
View File
@@ -75,7 +75,7 @@ pub(super) fn seed_ui_fixture(
// **A UI capture with no script VM is not a capture — refuse it.** Every seed below opens its
// window by calling into the in-game UI, so with no VM they all fail the same way: a nil
// global, one `warn!` in a log full of pipeline chatter, a valid-looking PNG of a UI-less
// world, and exit 0. That is the false-negative shape `method.md` §6 exists to prevent, and it
// world, and exit 0. That is the false-negative shape `docs/METHOD.md` §6 exists to prevent, and it
// burned a session. `scenario_wants_ui` removed the cause (a `ui:` scenario no longer needs
// `WOW_CAPTURE_UI=1`); this is the tripwire for whatever else could leave the VM absent, and
// it exits non-zero the way the window-size refusal does (`video::warn_if_window_mismatch`).
@@ -58,7 +58,7 @@
//! cargo run -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `WOW_USER=probeN WOW_PASS=pprobeN
//! WOW_CHAR=Probe<N-spelled>`, method.md "The local vmangos server"; this worktree is `pool-5`).
//! WOW_CHAR=Probe<N-spelled>`, docs/METHOD.md "The local vmangos server"; this worktree is `pool-5`).
//! Non-combat. An outer grep on `PROBE_AUCTION:` is the whole harness; the probe self-exits (the
//! [`super::probes::ProbeExitPlugin`] pattern) once DONE.
+1 -1
View File
@@ -23,7 +23,7 @@
//!
//! vmangos `Chat.cpp`'s `modifyCommandTable` pins `.modify money` at `SEC_BASIC_ADMIN` (4,
//! `Common.h` `AccountTypes`) — one level ABOVE `SEC_GAMEMASTER` (3), the level every probe account
//! is provisioned at (method.md). So `.modify money` is refused server-side for a probe login,
//! is provisioned at (docs/METHOD.md). So `.modify money` is refused server-side for a probe login,
//! exactly the same floor [`super::probe_taxi`]'s module doc already found for the taxi fare. This
//! probe never sends it: step (e) reads the character's live `money` field instead (DB-verified
//! this session: `Probeone` carries 100000 copper, `bank_bag_slots` 0 — comfortably funds the
+1 -1
View File
@@ -94,7 +94,7 @@
//!
//! Non-combat — the probe never attacks and never stands anywhere contested; with one player in
//! the instance there is nobody to fight. Pair with the SLOT-KEYED probe identity
//! (`method.md`, "The local vmangos server").
//! (`docs/METHOD.md`, "The local vmangos server").
use bevy::ecs::system::NonSendMut;
use bevy::prelude::*;
@@ -31,7 +31,7 @@
//! account carries an extra `SMSG_BATTLEFIELD_STATUS` in its burst.
//!
//! Non-combat. Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
//! WOW_CHAR=Probe<N-spelled>`; `method.md`, "The local vmangos server"), and `WOW_NOSOUND=1` when
//! WOW_CHAR=Probe<N-spelled>`; `docs/METHOD.md`, "The local vmangos server"), and `WOW_NOSOUND=1` when
//! it runs unattended. One `timeout`'d run plus a grep for `PROBE bgqueue:` is the whole harness.
use bevy::prelude::*;
@@ -78,7 +78,7 @@
//! WOW_PROBE_BINDER=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — this worktree is `pool-1` → `probe1`/`pprobe1`/`Probeone`;
//! method.md "The local vmangos server"). Non-combat, and GM mode is left exactly as found. An
//! docs/METHOD.md "The local vmangos server"). Non-combat, and GM mode is left exactly as found. An
//! outer `timeout` + grep on `PROBE_BINDER:` is the whole harness; the probe self-exits once DONE.
//!
//! Every step SKIPs with a note rather than FAILing for an environmental problem (the NPC never
+2 -2
View File
@@ -4,7 +4,7 @@
//! Goudy, 2026-08-09 (`#bugs` `1535810645956632686`): *"page text with html text absolutely
//! annihilates performance"* — ~50% fps drop (62 → 36 fps, 16.0 → 28.0 ms) while the *Alliance
//! Military Ranks* plaque's reader is up, recovering the moment it closes. That is a **frame-cost
//! A/B**, and eyeballing an fps counter is exactly the way not to settle one (method.md's
//! A/B**, and eyeballing an fps counter is exactly the way not to settle one (docs/METHOD.md's
//! "timing and feel are measured, never eyeballed"). So this probe teleports to the plaque, samples
//! the UI pass's own per-phase meter ([`crate::ui_script::UiFrameCost`]) with the reader CLOSED,
//! opens it on the real route, samples again, and prints the two side by side — including how many
@@ -21,7 +21,7 @@
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_BOOK=1 cargo run -q -p benilla --release
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, method.md
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, docs/METHOD.md
//! "The local vmangos server"). An outer `timeout` + a grep on `PROBE_BOOK:` is the whole harness;
//! the probe self-exits ([`super::probes::ProbeExitPlugin`]'s pattern) once DONE.
@@ -12,10 +12,10 @@
//! anything (the reap already emptied the `Casting` key it tests). The probe also logs every
//! cast-bar phase transition ([`bar_timeline`]) so the red bar's hold/burst/fade durations are
//! measured off the timestamps, not eyeballed. Non-combat, safe unattended
//! (method.md's rule bans unattended *combat* probes); a hearth that completes because the
//! (docs/METHOD.md's rule bans unattended *combat* probes); a hearth that completes because the
//! cancel FAILED just ports the probe character home — visible in the log as the missing cancel line. Pair with
//! the slot-keyed probe identity (`WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<N-spelled>`,
//! method.md) + `WOW_CAST_TRACE=1 WOW_PROBE_EXIT_AT=<s>`.
//! docs/METHOD.md) + `WOW_CAST_TRACE=1 WOW_PROBE_EXIT_AT=<s>`.
use bevy::prelude::*;
@@ -8,7 +8,7 @@
//! the `SMSG_CHAR_CREATE` result byte, and — unless `WOW_PROBE_CHARCREATE_KEEP=1` — deletes the
//! character it just made ([`CharRequest::Delete`]) so the account isn't littered, logging that
//! result too. Run it **without** `WOW_CHAR` (which would auto-enter the world and leave select),
//! against the slot-keyed probe account (`WOW_USER=probeN WOW_PASS=pprobeN`, N = your pool slot — method.md "The local vmangos server"); creating/deleting characters is a
//! against the slot-keyed probe account (`WOW_USER=probeN WOW_PASS=pprobeN`, N = your pool slot — docs/METHOD.md "The local vmangos server"); creating/deleting characters is a
//! non-combat operation, safe to run headlessly.
use benilla_protocol::{messages, CharAction, CharCreateReq};
@@ -97,7 +97,7 @@
//! WOW_PROBE_CHARTER=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — this worktree is `pool-0` → `probe0`/`pprobe0`/`Probezero`;
//! method.md "The local vmangos server". **Never the default `one` account** — a login on it kicks
//! docs/METHOD.md "The local vmangos server". **Never the default `one` account** — a login on it kicks
//! the director's live session.) `WOW_NOSOUND=1` because an unattended probe must not play zone
//! music into the director's room; `caffeinate -dis` is **not** needed for a run this short — the
//! whole sequence is a handful of round trips and finishes in well under a minute.
@@ -162,7 +162,7 @@ const REQUIRED_SIGNATURES: i64 = 9;
const FRESH_SIGNATURES: i64 = 0;
/// Copper handed to the probe body up front so the buy can never fail for funds. `.modify money`
/// is `SEC_BASIC_ADMIN` (4) in vmangos's `Chat.cpp` command table and every `probeN` account is
/// gmlevel **6** (method.md, decision 0651), so it lands; with no selection it targets the sender
/// gmlevel **6** (docs/METHOD.md, decision 0651), so it lands; with no selection it targets the sender
/// (`ChatHandler::GetSelectedPlayer`, `Chat.cpp:2601-2612`), which is why it is sent before the
/// probe touches an NPC.
const FUND_COPPER: u32 = 100_000;
@@ -8,7 +8,7 @@
//! is the correction (the real client also arms the latch in `OnLootResponse 0x5eb900`, and a chest
//! never sends `CMSG_LOOT` at all).
//!
//! **A kneel is a number here, not a picture** (`method.md` step 4: timing and pose questions are
//! **A kneel is a number here, not a picture** (`docs/METHOD.md` step 4: timing and pose questions are
//! settled with instruments). The probe reads the self unit's own
//! [`AnimDriver::active_anim`][crate::creature_anim::AnimDriver::active_anim] — the id the base
//! selector actually landed on — so the verdict is `50` (`Loot`) or it is not, with no screenshot
@@ -38,7 +38,7 @@
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_CHEST=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `docs/METHOD.md`
//! "The local vmangos server"). `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]` aims it at a different
//! object; the default is a live `Battered Chest` spawn (`gameobject.guid` 26756, template 2843) in
//! the Mirror Lake stretch of Elwynn, the same corner of the world the report came from. A chest is
+1 -1
View File
@@ -34,7 +34,7 @@
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_CLAM=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `docs/METHOD.md`
//! "The local vmangos server"). `WOW_PROBE_CLAM=<entry>` aims it at a different openable template;
//! the default is a Small Barnacled Clam (7973 — `Flags` LOOTABLE with `LockID = 0`, so it is
//! openable the moment it exists: no key, no lockpicking). The probe `.additem`s its own copy and
@@ -5,7 +5,7 @@
//! the net layer) → still riding → arrived docked on the far continent. Every phase edge prints
//! a `PROBE crossing:` line, so an outer `timeout`d run + grep is the whole harness. Non-combat.
//! Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
//! WOW_CHAR=Probe<N-spelled>` for a `pool-N` worktree — method.md "The local vmangos server";
//! WOW_CHAR=Probe<N-spelled>` for a `pool-N` worktree — docs/METHOD.md "The local vmangos server";
//! a shared account gets kicked by parallel sessions mid-ride).
use bevy::prelude::*;
+1 -1
View File
@@ -60,7 +60,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
name: "WOW_PROBE_EXIT_AT",
purpose: "<secs> — exit the app after N wall seconds; bounds any scripted live probe's lifetime",
// Fires on `ProbeClock` at `<secs>`, and it is the one probe variable a trace-only run
// sets on its own (`WOW_MOVE_TRACE`/`WOW_STREAM_TRACE` legs, method.md) — 0794's
// sets on its own (`WOW_MOVE_TRACE`/`WOW_STREAM_TRACE` legs, docs/METHOD.md) — 0794's
// throttled camera leg was exactly such a run.
wall_clock: true,
},
@@ -94,7 +94,7 @@
//! WOW_PROBE_GMTICKET=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — this worktree is `pool-3` → `probe3`/`pprobe3`/`Probethree`;
//! method.md "The local vmangos server"). Non-combat; the probe never drives the body and sends no
//! docs/METHOD.md "The local vmangos server"). Non-combat; the probe never drives the body and sends no
//! GM command, so GM mode and position are left exactly as found — but note that the body is not
//! necessarily *still* when a run starts (see [`REST_EPS`]). An outer `timeout` + grep on
//! `PROBE_GMTICKET:` is the whole harness; the probe self-exits once DONE. Nothing here is a
@@ -43,7 +43,7 @@
//! WOW_NOSOUND=1 WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
//! WOW_PROBE_GOQUEST=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `docs/METHOD.md`
//! "The local vmangos server"). `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]` aims it elsewhere; the
//! default is the **Goldshire wanted poster** — `gameobject.guid` 26843, template 68
//! `Wanted Poster`, `type` 2 `GAMEOBJECT_TYPE_QUESTGIVER`, `flags` 4 `INTERACT_COND`, offering
@@ -11,7 +11,7 @@
//! on screen*, not merely that a packet arrived.
//!
//! Non-combat. Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
//! WOW_CHAR=Probe<N-spelled>`; `method.md`, "The local vmangos server"), and `WOW_NOSOUND=1` when
//! WOW_CHAR=Probe<N-spelled>`; `docs/METHOD.md`, "The local vmangos server"), and `WOW_NOSOUND=1` when
//! it runs unattended. One `timeout`'d run plus a grep for `PROBE guardpoi:` is the whole harness.
use bevy::prelude::*;
+1 -1
View File
@@ -40,7 +40,7 @@
//! WOW_PROBE_MAIL=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `WOW_USER=probeN WOW_PASS=pprobeN
//! WOW_CHAR=Probe<N-spelled>`, method.md "The local vmangos server"; this worktree is `pool-2` →
//! WOW_CHAR=Probe<N-spelled>`, docs/METHOD.md "The local vmangos server"; this worktree is `pool-2` →
//! `probe2`/`pprobe2`/`Probetwo`). `WOW_CHAR` doubles as the probe's own mail-send target — read
//! once at world-enter, never hardcoded. An outer `timeout` + grep on `PROBE_MAIL:` is the whole
//! harness; the probe self-exits (the [`super::probes::ProbeExitPlugin`] pattern) once DONE.
@@ -2,10 +2,10 @@
//! once in-world, periodically fire the attack-nearest core ([`AttackNearestRequest`] — the
//! action-bar attack's own no-selection path), so the probe character fights whatever is closest
//! while the `benilla_assets::trace` sink (`WOW_MOVE_TRACE=<path>`) records the swing→impact→spawn combat-text
//! timeline for pair-birth analysis. Pair with the slot-keyed probe identity (method.md) + an outer
//! timeline for pair-birth analysis. Pair with the slot-keyed probe identity (docs/METHOD.md) + an outer
//! `timeout`.
//!
//! **Do NOT run this unattended** (director's rule, 2026-07-14, `method.md` "The local vmangos
//! **Do NOT run this unattended** (director's rule, 2026-07-14, `docs/METHOD.md` "The local vmangos
//! server"): the probe fights back-to-back with no health awareness and the character DIES (Tri
//! did). Combat runs are director-assisted — ask, or hand an eyeball script.
@@ -11,7 +11,7 @@
//! real atlas. So this probe drives the **whole live pipeline** from Lua and reads the result off
//! the entities the renderer actually built.
//!
//! The measurement is numeric, not visual (method.md's rule: a capture can confirm an existence
//! The measurement is numeric, not visual (docs/METHOD.md's rule: a capture can confirm an existence
//! fact, it cannot measure one). Each leg projects three model-space probe points through the
//! camera and the root the renderer placed this frame, and compares the resulting NDC against the
//! baseline's:
@@ -38,7 +38,7 @@
//! WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive WOW_UNATTENDED=1 WOW_NOSOUND=1 \
//! WOW_PROBE_MODEL_CAMERA=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — method.md "The local vmangos server"). `WOW_TILE_TRACE=1`
//! (the slot-keyed probe identity — docs/METHOD.md "The local vmangos server"). `WOW_TILE_TRACE=1`
//! alongside it prints the leg, the record, the eye and the matrix per pane per frame.
use bevy::camera::visibility::RenderLayers;
@@ -192,7 +192,7 @@ fn read_leg(
fn shoot(commands: &mut Commands, name: &str) {
// Every file we write goes through `local_state` — never into the WoW install, never a
// platform config dir (the contract's one-folder rule).
// platform config dir (docs/METHOD.md's one-folder rule).
let Some(path) = crate::local_state::home().map(|d| d.join(format!("{name}.png"))) else {
warn!("PROBE_MODEL_CAMERA: no local-state folder — skipping the {name} shot");
return;
@@ -4,9 +4,9 @@
//! leader/loot management, pings — solo, with the probe account as the other member. It also
//! accepts **duel** challenges (decision 0637), which is what makes the duel arc's two-client
//! questions answerable at all: whether the opponent turns hostile, whether the arbiter flag
//! plants. Non-combat (the unattended-combat ban, `method.md` "The local vmangos server", stands
//! plants. Non-combat (the unattended-combat ban, `docs/METHOD.md` "The local vmangos server", stands
//! untouched: this probe answers a challenge but never swings, and a duel nobody strikes in
//! simply times out). Pair with the slot-keyed probe identity (`WOW_USER=probeN …`, method.md).
//! simply times out). Pair with the slot-keyed probe identity (`WOW_USER=probeN …`, docs/METHOD.md).
use bevy::prelude::*;
+1 -1
View File
@@ -71,7 +71,7 @@
//! character (give `WOW_RIG` a race+class) starts with empty bags and is the cheaper path.
//!
//! Non-combat throughout: the rig creates, configures, places and stops. It never fights, so the
//! unattended-combat ban (method.md) is untouched.
//! unattended-combat ban (docs/METHOD.md) is untouched.
use benilla_protocol::{messages, CharAction, CharCreateReq};
use bevy::prelude::*;
@@ -48,7 +48,7 @@
//! WOW_DATA=WoW/Data WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
//! WOW_UNATTENDED=1 WOW_PROBE_SERVICE=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — method.md "The local vmangos server"). Non-combat, GM mode
//! (the slot-keyed probe identity — docs/METHOD.md "The local vmangos server"). Non-combat, GM mode
//! left as found, nothing bought and nothing turned in: every leg opens a window and closes it
//! client-side, which is what the reference's own close does (`ui_gossip`: there is no
//! `CMSG_GOSSIP_CLOSE` in 1.12).
@@ -78,7 +78,7 @@ use crate::ui_trainer::TrainerOpen;
/// This constant is the reason the questgiver leg first read as a client defect: its hop point was
/// an invented Northshire "hub" ~29 yd from Deputy Willem, so the probe called a ladder the click
/// would never have reached, the server dropped the packet on its own distance check, and the
/// timeout printed a FAIL about the client (method.md §6 — prove the run before reading the
/// timeout printed a FAIL about the client (docs/METHOD.md §6 — prove the run before reading the
/// result). Every hop point is now an NPC's own spawn, and the scan re-checks the reach.
const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ;
/// Let the hop land and the tile stream before the first scan.
@@ -10,7 +10,7 @@
//!
//! **Why a probe and not a capture:** none of this is a picture. Every reading here is a number or
//! a Lua truth value — the queued area id, `IsInMeetingStoneQueue()`, whether the stock minimap
//! button is shown — so the verdict needs no eye in the loop (`method.md` step 4).
//! button is shown — so the verdict needs no eye in the loop (`docs/METHOD.md` step 4).
//!
//! **Where it enters the chain, exactly.** It writes the very
//! [`MeetingStoneUse`] the click ladder writes, so everything **downstream** of that seam runs
@@ -44,7 +44,7 @@
//! WOW_NOSOUND=1 WOW_UNATTENDED=1 WOW_USER=probe3 WOW_PASS=pprobe3 WOW_CHAR=Probethree \
//! WOW_PROBE_STONE=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `docs/METHOD.md`
//! "The local vmangos server"). `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]` aims it elsewhere.
//!
//! **It leaves the probe body at the stone's minimum level**, deliberately and without putting it
+1 -1
View File
@@ -8,7 +8,7 @@
//! DBC prediction `Σ path-segment length ÷ 32 yd/s` (`PLAYER_FLIGHT_SPEED`) — timing measured,
//! never eyeballed (decision 0404). An outer `timeout`d run + grep is the whole harness.
//! Non-combat. Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
//! WOW_CHAR=Probe<N-spelled>`; method.md "The local vmangos server"). Uses `.taxicheat on` so
//! WOW_CHAR=Probe<N-spelled>`; docs/METHOD.md "The local vmangos server"). Uses `.taxicheat on` so
//! the fresh probe character can fly to an unvisited node (and so the SHOWTAXINODES mask
//! exercises the full-network branch); the 110-copper fare is DB-seeded (see the Wait phase —
//! `.modify money` outranks the probe account).
@@ -36,7 +36,7 @@
//! WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone WOW_UNATTENDED=1 WOW_NOSOUND=1 \
//! WOW_PROBE_VENDOR_SWAP=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — method.md "The local vmangos server").
//! (the slot-keyed probe identity — docs/METHOD.md "The local vmangos server").
use bevy::prelude::*;
+1 -1
View File
@@ -8,7 +8,7 @@ use bevy::prelude::*;
use super::ProbeClock;
/// Keep a probe run's window **un-occludable, and out of the director's way** — the one defence
/// against macOS's ~1 fps throttle for a fully covered window (decisions 0713/0777, method.md's
/// against macOS's ~1 fps throttle for a fully covered window (decisions 0713/0777, docs/METHOD.md's
/// `caffeinate` note), at the smallest footprint that still buys it.
///
/// The on-top half used to live inside the FPS probe alone, which reads as "a frame-rate concern".
+1 -1
View File
@@ -1242,7 +1242,7 @@ pub(super) const SCAR_LOOK: [f32; 3] = [-11792.7, -2647.1, 66.6];
/// **Forgetting it did not fail.** The run brought up the world with no in-game UI, the fixture's
/// seed hit a nil global (`ToggleQuestLog`), logged ONE `warn!` into ~100 lines of pipeline
/// chatter, wrote a perfectly valid PNG of a UI-less world, and exited **0**. That is an
/// instrument that manufactures a confident false negative — the shape `method.md` §6 spends a
/// instrument that manufactures a confident false negative — the shape `docs/METHOD.md` §6 spends a
/// paragraph on — and it cost a session a wasted build and nearly a wrong conclusion about a fix
/// that was in fact working.
///
@@ -165,7 +165,7 @@ mod tests {
use benilla_protocol::messages as m;
/// A stand-in `LAUGH_WORDn` list. The words are **invented on purpose**: the shipped ones are
/// the install's own `GlobalStrings.lua` content, which never enters this repo (the contract), and
/// the install's own `GlobalStrings.lua` content, which never enters this repo (docs/METHOD.md), and
/// the mechanism under test is the enumeration, not the vocabulary. A test that passes with
/// nonsense words is a test that proves the list is not baked into the code.
fn words(n: u32) -> Option<String> {
+1 -1
View File
@@ -358,7 +358,7 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `cvar/scratch/graphics-cost-cvar-census.md` §8 lists `nearclip` among the eleven dead knobs
// for exactly that). The camera does not read that global; it reads the record. So the ctor's
// `0x3de38e39` = 1/9 is overwritten by the first frame's stamp and never reaches a picture —
// a verified-but-partial mechanism, which the contract §4 names as the classic trap.
// a verified-but-partial mechanism, which docs/METHOD.md §4 names as the classic trap.
//
// pfUI's `hdgraphic` writes it (`ConsoleExec("nearClip " .. arg*2/100)`, 0.06..0.30 across its
// extended stops) — every value inside the reference's own `[0.01, 0.33]`, which is why that
@@ -183,7 +183,7 @@ pub(crate) fn advance_fx_anim(
/// the `kit spawn` / `kit expire` lines the instance lane already writes.
///
/// This is how "the Ice Barrier shield is frozen" is *closed by measurement* rather than by eye
/// (method.md §4/§5): with the lifecycle dead the trace shows `kit spawn` and nothing after; with it
/// (docs/METHOD.md §4/§5): with the lifecycle dead the trace shows `kit spawn` and nothing after; with it
/// live the same cast prints `fx leg hold e=… anim=158` one birth-span (0.70 s) later, and the aura
/// drop prints `fx leg decay` followed by the `kit expire` a further 1.10 s on. A duration question
/// is answered from timestamps, never from watching.
+1 -1
View File
@@ -173,7 +173,7 @@ pub(crate) fn drag_yaw(delta_px: f32, window: Option<&Window>) -> f32 {
/// The rotate buttons' **hold rate** — the ref's `CHARACTER_FACING_INCREMENT = 2`, applied per
/// `OnUpdate` tick by both screens' `RotateLeft/Right_OnUpdate`. Ours is per *second* at the ref's
/// 60 fps, so the arrow turns at one speed instead of at the frame rate (`method.md` step 3: the
/// 60 fps, so the arrow turns at one speed instead of at the frame rate (`docs/METHOD.md` step 3: the
/// mechanism, not the quirk).
pub(crate) const ROTATE_RATE: f32 = 120.0 * std::f32::consts::PI / 180.0;
+1 -1
View File
@@ -252,7 +252,7 @@ pub(crate) fn layout_character_path(realm: &str, character: &str) -> Option<Path
///
/// The reference's residents are `WDB/namecache.wdb`, `creaturecache.wdb` and `petnamecache.wdb`
/// — three files **inside the install**, which is exactly where benilla may not write (the install
/// is read-only, the contract's hard rule), so ours lives here like every other thing we persist.
/// is read-only, `docs/METHOD.md`'s hard rule), so ours lives here like every other thing we persist.
/// One file rather than three because our three stores share a lifetime and a realm; the
/// reference's split follows its `DBCache<T>` template instantiation, not a property of the data.
///
+1 -1
View File
@@ -34,7 +34,7 @@
//! ## Where it lives, and the one place we deviate
//!
//! The reference writes `WDB/` **inside the install**, which is precisely where benilla may not
//! write (the contract's read-only rule). Ours goes to `benilla-config/cache/<realm>.tsv` through
//! write (`docs/METHOD.md`'s read-only rule). Ours goes to `benilla-config/cache/<realm>.tsv` through
//! [`crate::local_state`], like every other thing we persist — and it is **realm-scoped**, which
//! the reference's is not. That is a fix rather than a preference: every key is realm-local (a
//! player guid, a creature entry, a pet number), so one shared file would serve another realm's
+3 -3
View File
@@ -108,7 +108,7 @@ const ABILITY_BLOCKERS: &[(u32, &str)] = &[
];
/// Worth naming on entry: an unattended probe that logs in already fighting is the exact shape of
/// the accident the unattended-combat ban exists for (method.md). The bit itself is declared once
/// the accident the unattended-combat ban exists for (docs/METHOD.md). The bit itself is declared once
/// ([`crate::player`]).
use crate::player::UNIT_FLAG_IN_COMBAT;
@@ -170,7 +170,7 @@ fn offline_notice(net: Option<Res<crate::net::NetOffline>>) {
warn!(
"preflight: NET OFF — no IO thread this run, so the drain gets no packets and NOTHING on \
the wire path executes (net::apply, the movement stream, every MSG_MOVE_*). Fine for a \
visual capture; NOT evidence for a change to any of it. method.md's gate is a clean run \
visual capture; NOT evidence for a change to any of it. docs/METHOD.md's gate is a clean run \
of the AFFECTED path — for wire work that means a live server run."
);
}
@@ -478,7 +478,7 @@ fn findings(
if unit_flags & UNIT_FLAG_IN_COMBAT != 0 {
out.push(
"IN COMBAT on arrival — something is already fighting this character. Do not leave it \
unattended (method.md's unattended-combat ban): break the fight or move it out."
unattended (docs/METHOD.md's unattended-combat ban): break the fight or move it out."
.into(),
);
}
+1 -1
View File
@@ -160,7 +160,7 @@ impl ProbeShield {
}
/// Whether `user` is a probe account — `probe` followed by digits, the slot-keyed identity every
/// unattended session logs in with (method.md "The local vmangos server"). Nothing else is ours to
/// unattended session logs in with (docs/METHOD.md "The local vmangos server"). Nothing else is ours to
/// modify: `one` is the director's, and a bystander test account is not a probe.
fn is_probe_account(user: &str) -> bool {
// Byte-wise (a &str slice at 5 would panic mid-char on non-ASCII input; bytes can't).
+3 -3
View File
@@ -108,7 +108,7 @@ pub(crate) fn env_login() -> bool {
/// `WOW_CAPTURE` and `WOW_RIG` are folded in because they *cannot* be a person: a capture authors
/// the camera and a rig drives the body, so a run that sets either has already said what it is and
/// cannot forget to. Everything else declares — `scripts/leg.sh`, `smoke.sh`, `cine.sh`,
/// `summon-live.sh` and the ad-hoc probe recipe in `method.md` all pass `WOW_UNATTENDED=1`.
/// `summon-live.sh` and the ad-hoc probe recipe in `docs/METHOD.md` all pass `WOW_UNATTENDED=1`.
///
/// Read by whatever may act *instead of* a person: the lost-session verdict (decision 1262 — the
/// session-loss readers never call this directly, [`crate::net::DisconnectedMessage::new`] asks
@@ -252,7 +252,7 @@ pub(crate) fn dev_source_dir() -> Option<&'static std::path::Path> {
/// the fast path is about to authenticate as an account that belongs to somebody else — the
/// director's `one` (a login KICKS their live session mid-play) or another slot's `probeN` (the
/// kicked client's 0065 teardown despawns every net entity, so a parallel session's probe reads a
/// unit-less world and prints garbage; it happened, method.md records it).
/// unit-less world and prints garbage; it happened, docs/METHOD.md records it).
///
/// Slot identity comes from the compiled-in manifest path, because that is what the pool guarantees
/// is unique per session: every slot has its own checkout and its own `target/`. Outside a pool slot
@@ -308,7 +308,7 @@ fn guard_for(slot: Option<u32>, user: &str) -> Result<(), String> {
Err(format!(
"the env fast path is about to log in as `{user}` from pool-{slot}, and that is {whose}. \
This slot's identity is WOW_USER=probe{slot} WOW_PASS=pprobe{slot} \
WOW_CHAR=Probe{spelled} (method.md \"The local vmangos server\").",
WOW_CHAR=Probe{spelled} (docs/METHOD.md \"The local vmangos server\").",
spelled = spell_digit(slot)
))
}
+1 -1
View File
@@ -559,7 +559,7 @@ impl Plugin for SoundPlugin {
// (decision 1026 — `SoundConfig::muted` starts false), so the silence has to be opt-in by
// the *automated* callers, both of which are unattended by construction:
// $WOW_NOSOUND — an agent opening the client to check something (the dispatch recipe in
// `method.md` sets it; nobody is listening, and a background window that
// `docs/METHOD.md` sets it; nobody is listening, and a background window that
// starts playing zone music is pure noise in a shared room).
// $WOW_CAPTURE — the visual harness. A screenshot has no audio track; opening a device
// per capture is cost and racket for nothing.
+1 -1
View File
@@ -269,7 +269,7 @@ pub(super) fn load_creature_types(mut commands: Commands, world_assets: Option<R
/// 0567 replaced the cone with screen-space priority. Under our scoring the player is at distance
/// 0 and screen-centre *unconditionally*, so porting the byte-level rule would make `CTRL-TAB`
/// self-target on **every** press rather than facing-dependently — faithful to the bytes,
/// unfaithful to the behaviour, which is the trade `CLAUDE.md` §7 hands to the director rather
/// unfaithful to the behaviour, which is the trade `docs/METHOD.md` §7 hands to the director rather
/// than to this file. Recorded here so it is a known divergence and not a gap (decision 1745).
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled system param — the app's convention for big query sets
+1 -1
View File
@@ -1674,7 +1674,7 @@ pub(super) fn drain_addon_chat_sends(
/// **The lane is traced in both directions, under one tag.** An addon channel is invisible by
/// construction — it never reaches a chat frame, which is the entire point of `LANG_ADDON` — so
/// without a line here a broadcast that went out and one that never happened look identical from
/// our own logs, which is exactly how a silently-discarded wire body survives (method.md's rule
/// our own logs, which is exactly how a silently-discarded wire body survives (docs/METHOD.md's rule
/// that new wire bodies are proved, not assumed). `ui_chat::net` already writes inbound addon
/// traffic to the `addon` trace tag; this writes the outbound half to the same tag, so
/// `WOW_MOVE_TRACE=<path> WOW_MOVE_TRACE_TAGS=addon` on a live run is the whole conversation in
+1 -1
View File
@@ -8,7 +8,7 @@
//! machine's source tree. On a player's machine every one of those files resolves to nothing and
//! the client boots with no interface at all: not a crash, just an empty screen and one log line
//! per manifest entry that nobody sees.
//! `assets/ui` is our own content (MIT/Apache, not Blizzard's — the contract's hard rule is
//! `assets/ui` is our own content (MIT/Apache, not Blizzard's — `docs/METHOD.md`'s hard rule is
//! untouched), so it can simply be part of the program.
//!
//! ## Resolution order — the same in every build
@@ -2357,3 +2357,36 @@ fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
"_Save should write the slider's 180 and its half; got yaw={yaw} pitch={pitch}"
);
}
/// `assets/ui` does not grow. The interface is the stock 1.12 FrameXML executed off the player's
/// own patch chain; what is left here is the glue and the shims, named below. A new file means a
/// window was authored instead of migrated: point `benilla.toc` at
/// `Interface\FrameXML\<Window>.xml`, delete ours, and build the engine verbs the stock file calls.
#[test]
fn assets_ui_does_not_grow() {
const SHIPPED: &[&str] = &[
"ContainerFrameAdapters.xml",
"GameMenuFrame.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"ScriptLogFrame.xml",
"ScrollTemplates.xml",
"SpellBookAdapters.xml",
"benilla.toc",
];
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut found: Vec<String> = std::fs::read_dir(&dir)
.expect("assets/ui")
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| !n.starts_with('.'))
.collect();
found.sort();
let mut shipped: Vec<String> = SHIPPED.iter().map(|s| s.to_string()).collect();
shipped.sort();
assert_eq!(
found, shipped,
"assets/ui changed. It does not grow: a window is migrated, not authored (docs/METHOD.md). \
A file that retired comes off this list; a new one needs a reason this list can name."
);
}
+2 -2
View File
@@ -1590,7 +1590,7 @@ pub(crate) fn fire_transitions(
// `RefreshUnit`, which is event-driven, so a mob that becomes someone else's tap would stay
// full-colour until something unrelated happened to refresh it. A passing unit test on the
// predicates would have said nothing about that — the mechanism existing is not the mechanism
// applying (`method.md` step 5).
// applying (`docs/METHOD.md` step 5).
//
// Bit `0x8` deliberately has NO arm here: the reference's watcher has none for it either
// (proven by enumerating all 122 instructions and 12 branches of `0x600440`). The pair is read
@@ -2447,7 +2447,7 @@ mod tests {
/// `UnitIsTapped` answering correctly is only half of it. pfUI's grey-bar branch lives inside
/// `RefreshUnit`, which is event-driven — so a mob that becomes someone else's tap would stay
/// full-colour until something unrelated happened to repaint the frame. The predicate would be
/// right and the screen would be wrong, which no test of the predicate can see (`method.md`
/// right and the screen would be wrong, which no test of the predicate can see (`docs/METHOD.md`
/// step 5: the mechanism existing is not the mechanism applying).
///
/// The reference dispatches event id **29** — `UNIT_FACTION`, not a tapped-specific event —
+1 -1
View File
@@ -124,7 +124,7 @@ fn candidates_from(override_dir: Option<PathBuf>, exe_dir: Option<PathBuf>) -> V
/// ```
///
/// These tests read the **real** 1.12 install, which is gitignored and not on every machine
/// (the contract: never commit Blizzard assets), so "no install" has always meant *pass without
/// (`docs/METHOD.md`: never commit Blizzard assets), so "no install" has always meant *pass without
/// asserting* rather than *fail*. That is deliberately unchanged; what changes is that the message
/// now names every path that was tried, so a machine where the tests silently do nothing says why.
///
@@ -9,9 +9,9 @@
//! second `SMSG_SPELL_GO`) timestamps the server's cooldown end relative to the first GO.
//!
//! Run: `cargo run -p benilla-protocol --example cooldown_probe -- probeN pprobeN [host]` — the
//! slot-keyed probe account (method.md "The local vmangos server"; NEVER `one`, the director's
//! slot-keyed probe account (docs/METHOD.md "The local vmangos server"; NEVER `one`, the director's
//! account — a probe login there kicks their live session). Two caveats: this is a COMBAT probe,
//! so it runs director-supervised (method.md: no unattended combat probes), and `.learn` is
//! so it runs director-supervised (docs/METHOD.md: no unattended combat probes), and `.learn` is
//! SEC_DEVELOPER (5); probe accounts are gmlevel 6 (SEC_ADMINISTRATOR), so it lands. The vmangos
//! console for the run and restore it after.
@@ -3,7 +3,7 @@
//! decode against the server DB's `creature_template.faction`.
//!
//! Run: `cargo run -p benilla-protocol --example faction_probe -- probeN pprobeN [host]`
//! — the slot-keyed probe account (method.md "The local vmangos server"). The account is a
//! — the slot-keyed probe account (docs/METHOD.md "The local vmangos server"). The account is a
//! REQUIRED arg with no default: a default was once `one`, the director's account, where a
//! probe login kicks their live session (decision 0530).
@@ -2,7 +2,7 @@
//! HORDE character's say must echo back (the server accepted the tongue), a dot-command must
//! answer (it survived the pre-parse `KnowsLanguage` gate), and the split-writer path must carry
//! the tongue too. Run: `cargo run -p benilla-protocol --example horde_chat_probe -- probeN
//! pprobeN [host]` — the slot-keyed probe account (method.md "The local vmangos server"; it
//! pprobeN [host]` — the slot-keyed probe account (docs/METHOD.md "The local vmangos server"; it
//! once hardcoded the retired shared `three` identity, decision 0530). Creates the orc
//! `Orc<N-spelled>` on this account on first run (names are realm-unique, so the orc keys to
//! the slot too). Exists because a hardcoded-Common send silently ate every Horde character's
@@ -32,8 +32,9 @@ fn main() -> Result<()> {
.next()
.context("usage: horde_chat_probe -- <probeN> <pprobeN> [host] (slot-keyed account)")?;
let host = args.next().unwrap_or_else(|| "localhost".into());
let orc = orc_name(&user)
.context("account must be a slot-keyed probeN (method.md \"The local vmangos server\")")?;
let orc = orc_name(&user).context(
"account must be a slot-keyed probeN (docs/METHOD.md \"The local vmangos server\")",
)?;
let logon = benilla_protocol::logon(&host, &user, &pass)?;
let addr = logon
@@ -23,7 +23,7 @@
//! it tops an account up to the full nine. Creating characters needs no GM level; the state flags do,
//! and that is where this silently half-applies if the account is short: **`.character premade
//! gear|spec` needs gmlevel 4 and `--level`'s two commands need 5**, and that level lives in
//! `realmd.account_access`, *not* `account.gmlevel` (see method.md, "The local vmangos server").
//! `realmd.account_access`, *not* `account.gmlevel` (see docs/METHOD.md, "The local vmangos server").
//! Every server reply is echoed as `server says — …`, so a refusal is visible rather than silent.
//!
//! **`--wipe` deletes characters irreversibly, hand-made ones included.** It is deliberately not the
@@ -126,7 +126,7 @@ impl Probe for Aura {
index is wrong, or the GM `.aura` command was refused — it needs gmlevel >= 4 \
(VERIFIED vmangos `Chat/Chat.cpp:1229`: SEC_BASIC_ADMIN, which is 4 in \
`shared/Common.h:142`), and the slot-keyed probe accounts are gmlevel 6, so this \
probe cannot run as `probeN` without a temporary grant (method.md, decision 0450's \
probe cannot run as `probeN` without a temporary grant (docs/METHOD.md, decision 0450's \
precedent for --worldstate).",
)?;
let debuff = auras
@@ -79,7 +79,7 @@ impl Probe for Death {
// is an `==`, so it stayed silent and the probe's corpse capture quietly stopped
// firing: the arc would have bailed "the corpse object never streamed" on a wire
// that was perfectly correct. An instrument that fails closed after a refactor is
// the expensive kind (the contract §5 — the instruments are part of the codebase).
// the expensive kind (docs/METHOD.md §5 — the instruments are part of the codebase).
let repop_sent = cx.world.death_arc.as_ref().is_some_and(|a| a.repop_sent);
if repop_sent && self.corpse_create.is_none() && *kind == EntityKind::Corpse {
self.corpse_create = Some((*guid, *position));
@@ -118,7 +118,7 @@ impl Probe for MountTele {
fn poll(&mut self, cx: &mut Ctx) -> Result<()> {
// Mount only once the staging teleport has actually landed us outdoors — proving the run's
// starting state from a packet rather than assuming it (`method.md` step 6).
// starting state from a packet rather than assuming it (`docs/METHOD.md` step 6).
if !self.mount_requested && cx.world.self_map == OUTDOOR_MAP {
self.mount_requested = true;
cx.session.send_chat(&format!(".aura {MOUNT_SPELL}"))?;
@@ -90,7 +90,7 @@ impl Probe for Spells {
// the first streamed creature — the mask-2 + PACKED-guid target block. Ordered AFTER the
// dest phase deliberately: `.learn` executes deferred and resolves the SELECTION, so a
// creature selected here before the command ran turned the learn into "Player not
// found!" (observed live, 2026-07-30 — the `.cheat god` re-target trap, method.md).
// found!" (observed live, 2026-07-30 — the `.cheat god` re-target trap, docs/METHOD.md).
if self.targeted_cast_sent.is_none() && cx.world.dest_verdict.is_some() {
if let Some(spell) = self.cast_sent {
if let Some((&guid, _)) = cx
+1 -1
View File
@@ -63,7 +63,7 @@
//! "Private"). The real `GlobalStrings.lua` is executed off the player's own install at boot, so
//! those keys are already sitting on `_G` when `GetPVPRankInfo` runs, and reading them there is the
//! only correct answer: a table of titles shipped in this repo would be Blizzard text we may not
//! carry (the contract's install-content rule) *and* wrong in every non-enUS locale. There is **no
//! carry (docs/METHOD.md's install-content rule) *and* wrong in every non-enUS locale. There is **no
//! `PVP_RANK_0_*`** — internal rank 0 names nothing, and both reference panes lean on that nil to
//! fall back to the `NONE` GlobalString, so a missing key answers nil here rather than an empty
//! string or a raise.
+1 -1
View File
@@ -70,7 +70,7 @@ const BG_ENV_PREFIXES: &[&str] = &[
"WOW_RIG",
"WOW_SCHED_CENSUS",
// The one that names the condition directly: `WOW_UNATTENDED` *is* "the chair is empty"
// (1769), and method.md tells every ad-hoc probe to set it. It was missing here for as long
// (1769), and docs/METHOD.md tells every ad-hoc probe to set it. It was missing here for as long
// as this list has existed, so a probe launched exactly the way the method prescribes — but
// under none of the named prefixes — opened focused and borderless-fullscreen over the
// director's work. Reported 2026-09-01 while probing B346. This is the case the list's own
+45
View File
@@ -0,0 +1,45 @@
# Contributing
benilla is a faithful 1.12.1 client. It is the foundation people build on, not the place to get
creative: a change is accepted when it makes benilla more like 1.12.1 or fixes a bug, with
evidence from the reference, in one small piece, with the gates green. Everything else is a
fork, and forks are welcome.
## What gets in
- A fix for a bug, with how to see it before and after.
- A step closer to 1.12.1: a missing packet, verb, window, effect or behaviour, done the way
the real client does it.
- A correction where benilla and the reference disagree, with the reference fact stated.
## What does not
- Features 1.12.1 does not have, and behaviour changed because it seems better. A deviation
from the reference is the maintainer's call and is recorded where it lives; a pull request is
not the place to propose one.
- Anything from a WoW install: art, models, sounds, maps, data. The one exception is interface
code (FrameXML and GlueXML), and only through the migration recipe in `docs/METHOD.md`.
- Big or mixed changes. One change per pull request, small enough to read in one sitting.
## How a change is judged
1. `scripts/gates.sh` is green: fmt, clippy with warnings denied, the workspace tests, the
player build.
2. The reference fact is stated: what 1.12.1 does, and where that is known from (the client's
behaviour you observed, a DBC field, a FrameXML line, a packet capture). The names and shapes
under `reference/` are the surface benilla tracks.
3. A comment where it matters, one line, saying what the code does and the 1.12 fact behind it.
No history.
4. The commit message says what changed, for a player or a developer, in one line.
## Setting up
- A 1.12.1 install of your own: `WOW_DATA=<path>`, or a `WoW` link at the repo root. benilla
reads it and never writes into it.
- A server to test against: any 1.12.1 server. The project runs against a local vmangos
(`WOW_HOST`, `WOW_USER`, `WOW_PASS`).
- `cargo play` builds and runs the play profile. `scripts/check.sh` verifies a round of work;
`scripts/gates.sh` is what a pull request must pass.
- Bugs, questions and ideas go to the Discord linked from the README. Issues are off on purpose.
Working with an AI agent is expected. The agent reads `AGENTS.md`, and the same rules bind it.
+551
View File
@@ -0,0 +1,551 @@
# benilla — generated map
> **GENERATED** by `scripts/genmap.sh` from the code — do not edit by hand; rerun the script
> when what's built changes. *What-is* only: the *what-changed* is in git. No timestamp —
> identical code yields an identical map.
## Crates
- **benilla-adt** (no Bevy) — ADT terrain-tile reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021
- **benilla-app** (Bevy) — The Bevy app as a library: everything but the build-id stamp, which lives in the `benilla` launcher shim so a commit dirties ~30 lines instead of this crate (decision 0993)
- **benilla-assets** (Bevy) — Bevy AssetSource + AssetLoaders over the WoW 1.12.1 MPQ patch chain
- **benilla-blp** (no Bevy) — BLP2 texture decoder for WoW 1.12.1 (build 5875) — in-repo, decision 0021
- **benilla-buildstamp** (no Bevy) — The git build-id stamp emitter shared by the launcher shims' build scripts (decision 0993/1160)
- **benilla-bytes** (no Bevy) — Bounds-checked byte readers + IFF chunk iteration shared by the format parsers — decision 0064
- **benilla-dbc** (no Bevy) — WDBC (client DBC) reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021
- **benilla-formats** (no Bevy) — WoW 1.12.1 asset-format adapters (MPQ/BLP/DBC/ADT/WDT) and the benilla-extract CLI
- **benilla-m2** (no Bevy) — M2 (MD20) model reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021
- **benilla-mpq** (no Bevy) — Read-only MPQ archive reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021
- **benilla-protocol** (no Bevy) — WoW 1.12.1 auth (SRP6) + world wire protocol and the benilla-auth CLI
- **benilla-srp** (no Bevy) — WoW 1.12.1 (build 5875) SRP6 client + vanilla world-header crypto — in-repo, decision 0021
- **benilla-ui** (no Bevy) — Engine-free WoW UI engine core: TOC/FrameXML parsing, templates, anchors, z-order — decision 0068
- **benilla-visual** (no Bevy) — Perceptual image diff for the Phase-5 visual A/B render harness (decision 0008)
- **benilla-wdt** (no Bevy) — WDT map tile-table reader + tile/world coords for WoW 1.12.1 (build 5875) — in-repo, decision 0021
- **benilla-wmo** (no Bevy) — WMO (root + group) reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021
- **benilla-world** (Bevy) — The world renderer: terrain, water, sky, weather, models, lighting, collision and the frame ladder — everything needed to put something in a world and ask the world about itself, with no game attached (decision 1160)
- **benilla-worldview** (no Bevy) — The world viewer: the engine with no game attached — and the wall that keeps the split honest (decision 1160)
- **benilla** (no Bevy) — The launcher: build.rs stamps the git build id, main() hands it to benilla-app. A shim, so the stamp's every-commit rebuild dirties this package and not the app crate (decision 0993)
## App subsystems — Bevy plugins in load order (`crates/benilla-app/src/lib.rs`)
- ThreadQosPlugin
- BgWinPlugin
- MacQuitPlugin
- WorldPlugins
- DevToolsPlugin
- FpsJournalPlugin
- GamePlugins
- DevProbesPlugin
### Behind the `dev` feature (`crates/benilla-app/src/dev.rs` — compiled out by `--no-default-features`)
- DebugPanelPlugin
- PerfPlugin
- HoverLogPlugin
- AssetChurnPlugin
- PreflightPlugin
- ProbeShieldPlugin
- CapturePlugin
- LiveShotPlugin
- ProbeRigPlugin
- ProbeFocusPlugin
- ProbeChatPlugin
- ProbeLuaPlugin
- ProbeDragPlugin
- ProbeHoverPlugin
- ProbeKeyPlugin
- ProbeExitPlugin
- PickProbePlugin
- PhaseProbePlugin
- DepthProbePlugin
- NodeProbePlugin
- ProbeResizePlugin
- ParticleCensusPlugin
- GroundCensusPlugin
- LiftCensusPlugin
- StallPlugin
- TrailCensusPlugin
- UnitVisualsPlugin
- JitterMeterPlugin
- DressCensusPlugin
- RevealAuditPlugin
- EntityCensusPlugin
- SchedCensusPlugin
- ProbeMeleePlugin
- ProbePartnerPlugin
- ProbeCrossingPlugin
- ProbeTaxiPlugin
- ProbeGuardPoiPlugin
- ProbeBgQueuePlugin
- ProbeBgPlugin
- ProbeMailPlugin
- ProbeAuctionPlugin
- ProbeBankPlugin
- ProbeBinderPlugin
- ProbeServicePlugin
- ProbeVendorSwapPlugin
- ProbeModelCameraPlugin
- ProbeGmTicketPlugin
- ProbeCharterPlugin
- ProbeBookPlugin
- ProbeStonePlugin
- ProbeChestPlugin
- ProbeGoQuestPlugin
- ProbeClamPlugin
- ProbeCastCancelPlugin
- ProbeCharCreatePlugin
- LiveFpsPlugin
- ProbeLookPlugin
- ProbePitchPlugin
- ProbeCamPlugin
## Modules (top-level, per crate)
- **benilla-adt** (lib.rs): combined_alpha
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_hide ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui
- **benilla-assets** (lib.rs): adt anim_rng blp column_grid coords gpu_blp m2 materials minimap_grid model spatial_cache terrain tex_filter trace wdt wmo world_assets
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui zone_map
- **benilla-m2** (lib.rs): error model skin tests track
- **benilla-mpq** (lib.rs): crypto
- **benilla-protocol** (lib.rs): auth events guid messages wire world
- **benilla-srp** (lib.rs): vanilla_header
- **benilla-ui** (lib.rs): bindings_xml civil framexml justify layout loader markup messages order script source strings toc widget
- **benilla-visual** (lib.rs): relight
- **benilla-world** (lib.rs): art_scope assets bgwin billboard boot build_id clouds clutter collision decal dev_state doodad_anim entity_shade exterior_cull ffx_glow final_pass frame_pace ground_fx instance_tint interact interior lighting liquid log_ring mac_quit map_proj mat_anim_table mesh_tag model_fade model_forms model_render modkeys particles ribbons ride_frame rig_anim rig_palette rig_rider schedule shaders sky sky_order skybox static_gx static_merge straddle sun surface terrain_stream thread_qos view vis_chain water_fx wdl weather wmo_portal world_census world_map world_plugins world_point world_unit worldview zfill
## CLI binaries
- `benilla-auth` (in benilla-protocol)
- `benilla-extract` (in benilla-formats)
- `benilla-world` (in benilla-protocol)
- `blpx` (in benilla-blp)
- `mpqx` (in benilla-formats)
## WGSL shaders (`crates/*/src/shaders/`, embedded)
- `embedded://benilla_app/shaders/ui_add.wgsl`
- `embedded://benilla_app/shaders/ui_gamma.wgsl`
- `embedded://benilla_app/shaders/ui_node_gamma.wgsl`
- `embedded://benilla_app/shaders/ui_quad.wgsl`
- `embedded://benilla_app/shaders/ui_slice_gamma.wgsl`
- `embedded://benilla_assets/shaders/liquid.wgsl`
- `embedded://benilla_assets/shaders/terrain.wgsl`
- `embedded://benilla_assets/shaders/wdl.wgsl`
- `embedded://benilla_assets/shaders/wow_model.wgsl`
- `embedded://benilla_world/shaders/celestial.wgsl`
- `embedded://benilla_world/shaders/cloud.wgsl`
- `embedded://benilla_world/shaders/ffx_glow.wgsl`
- `embedded://benilla_world/shaders/sky_vertex.wgsl`
- `embedded://benilla_world/shaders/sky.wgsl`
- `embedded://benilla_world/shaders/star.wgsl`
- `embedded://benilla_world/shaders/static_gx.wgsl`
- `embedded://benilla_world/shaders/wow_effect.wgsl`
## Instruments — `$WOW_*` switches (env-var read sites)
> The headless/dev instrument fleet, discovered from the code: every `WOW_*` env var
> some `.rs` reads, and where it's read — the doc comment at the read site is the
> semantics. (The in-window surfaces — the Ctrl+Shift dev-chord overlays — are plugins
> above; this indexes the switches that don't announce themselves.)
- `WOW_ADDON_PROBE_AWAIT_INVITE` — benilla-protocol/examples/addon_chat_probe.rs
- `WOW_ADDON_PROBE_TRACE` — benilla-protocol/examples/addon_chat_probe.rs
- `WOW_ALLOW_ACCOUNT` — benilla-app/src/login/mod.rs, benilla-app/src/run_mode.rs
- `WOW_AMBIGUITY_DUMP` — benilla-app/src/game_plugins.rs
- `WOW_ANIM_COST` — benilla-app/src/creature_anim/driver.rs
- `WOW_ANIM_PARK_ALL` — benilla-app/src/creature_anim/lod.rs
- `WOW_ANISO` — benilla-app/src/cvars.rs, benilla-assets/src/tex_filter.rs
- `WOW_API_DUMP` — benilla-app/tests/world_api_wall.rs
- `WOW_ARCH_CENSUS` — benilla-app/src/perf/mod.rs
- `WOW_ART_RADIUS` — benilla-world/src/art_scope.rs
- `WOW_ASSET_CHURN` — benilla-app/src/asset_churn.rs
- `WOW_ASSET_DUMP` — benilla-app/src/capture/probes/live_fps.rs
- `WOW_AUDIO_LIVE` — benilla-app/src/sound/output/mod.rs
- `WOW_AUDIO_NO_RT` — benilla-app/src/sound/output/mod.rs
- `WOW_AUDIT_` — benilla-world/src/bgwin.rs
- `WOW_AUDIT_START` — benilla-world/src/wmo_portal/audit/mod.rs
- `WOW_AUDIT_WMO` — benilla-world/src/wmo_portal/audit/mod.rs
- `WOW_BARE_DOODAD_COLLIDERS` — benilla-world/src/terrain_stream/collider.rs
- `WOW_BG` — benilla-world/src/bgwin.rs
- `WOW_BLOB_VIS` — benilla-world/src/terrain_stream/merge.rs
- `WOW_BLOB_VIS_UID` — benilla-world/src/terrain_stream/merge.rs
- `WOW_BOOTH_DUMP` — benilla-app/src/portrait/test_bake.rs
- `WOW_BOOTH_LOG` — benilla-app/src/portrait/mod.rs
- `WOW_BUBBLE_TRACE` — benilla-app/src/chat_bubble.rs
- `WOW_CAM_CHANGED` — benilla-app/src/perf/mod.rs
- `WOW_CAM_DUMP` — benilla-app/src/player/camera.rs
- `WOW_CAPTURE` — benilla-app/src/capture/mod.rs, benilla-app/src/capture/scenarios.rs, benilla-app/src/char_select/addons.rs, benilla-app/src/cvars.rs, benilla-app/src/lib.rs, benilla-app/src/local_state.rs, benilla-app/src/login/mod.rs, benilla-app/src/name_persist.rs, benilla-app/src/net.rs, benilla-app/src/player/camera_saved.rs, benilla-app/src/run_mode.rs, benilla-app/src/sound/mod.rs, benilla-app/src/ui_chat/settings.rs, benilla-app/src/ui_chat/tests.rs, benilla-app/src/ui_macro/tests.rs, benilla-app/src/ui_saved.rs, benilla-app/src/ui_script/addons.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/ui_script/world_entry_tests.rs, benilla-app/src/video.rs, benilla-world/src/bgwin.rs, benilla-world/src/clouds/mod.rs, benilla-world/src/dev_state.rs
- `WOW_CAPTURE_AGE` — benilla-app/src/capture/mod.rs
- `WOW_CAPTURE_DEADLINE` — benilla-app/src/capture/mod.rs
- `WOW_CAPTURE_OUT` — benilla-app/src/capture/mod.rs
- `WOW_CAPTURE_REHOVER` — benilla-app/src/ui_script/mod.rs
- `WOW_CAPTURE_STABLE` — benilla-app/src/capture/mod.rs
- `WOW_CAPTURE_UI` — benilla-app/src/capture/scenarios.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/video.rs
- `WOW_CAST_TRACE` — benilla-app/src/net.rs
- `WOW_CHAR` — benilla-app/src/capture/probe_mail.rs, benilla-app/src/char_select/mod.rs, benilla-app/src/cvars.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/net/io.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs
- `WOW_CHARCREATE_DIALS` — benilla-app/src/char_create/mod.rs
- `WOW_CHARCREATE_NAME` — benilla-app/src/char_create/mod.rs
- `WOW_CHARCREATE_PICK` — benilla-app/src/capture/mod.rs, benilla-app/src/char_create/mod.rs
- `WOW_CHARCREATE_SHOT` — benilla-app/src/char_create/mod.rs, benilla-world/src/bgwin.rs
- `WOW_CHARCREATE_SHOT_OUT` — benilla-app/src/char_create/mod.rs
- `WOW_CHARSELECT_ADDONS` — benilla-app/src/char_select/mod.rs
- `WOW_CHARSELECT_DIALOG` — benilla-app/src/char_select/mod.rs
- `WOW_CHARSELECT_PICK` — benilla-app/src/char_select/mod.rs, benilla-app/src/cvars.rs
- `WOW_CHARSELECT_SHOT` — benilla-world/src/bgwin.rs
- `WOW_CHARSELECT_SHOT_AT` — benilla-app/src/char_select/mod.rs
- `WOW_CHARSELECT_SHOT_OUT` — benilla-app/src/char_select/mod.rs
- `WOW_CHARSELECT_WALK` — benilla-app/src/char_select/mod.rs
- `WOW_CLAMP_COST` — benilla-app/src/net/motion/spline.rs
- `WOW_CLAMP_SEAT` — benilla-app/src/net/motion/spline.rs
- `WOW_CLAMP_TRACE` — benilla-app/src/net/motion/spline.rs
- `WOW_CLOCK` — benilla-world/src/dev_state.rs
- `WOW_CLOUD_DUMP` — benilla-world/src/clouds/mod.rs
- `WOW_CLUSTERS` — benilla-app/src/player/setup.rs
- `WOW_CLUTTER_ALPHA` — benilla-world/src/clutter.rs
- `WOW_CLUTTER_DENSITY` — benilla-app/src/cvars.rs, benilla-app/src/lib.rs, benilla-world/src/clutter.rs
- `WOW_CLUTTER_FADE` — benilla-world/src/clutter.rs
- `WOW_COLUMN_COST` — benilla-assets/src/column_grid.rs
- `WOW_CPU_CENSUS` — benilla-app/src/perf/census.rs
- `WOW_CRASH_INJECT` — benilla-app/src/perf/crash_inject.rs
- `WOW_CREATE_SPLINE` — benilla-app/src/net/motion/spline.rs
- `WOW_CREATE_TEST` — benilla-app/src/portrait/glue_booth.rs, benilla-world/src/bgwin.rs
- `WOW_CULL_TRACE` — benilla-world/src/exterior_cull.rs
- `WOW_CULLDUMP` — benilla-world/src/wmo_portal/mod.rs
- `WOW_CURSOR_TRACE` — benilla-app/src/cursor.rs
- `WOW_DATA` — benilla-app/src/local_state.rs, benilla-formats/src/install.rs, benilla-formats/tests/wow_data_env.rs, benilla-formats/tests/wow_data_none.rs
- `WOW_DEMO_BOTTOM_BARS` — benilla-app/src/ui_script/mod.rs
- `WOW_DEPTH` — benilla-app/src/capture/depth_probe.rs, benilla-app/src/dev.rs, benilla-world/src/bgwin.rs
- `WOW_DEPTH_AFTER` — benilla-app/src/capture/depth_probe.rs
- `WOW_DEPTH_AT` — benilla-app/src/capture/depth_probe.rs
- `WOW_DEPTH_COUNT` — benilla-app/src/capture/depth_probe.rs
- `WOW_DEPTH_QUADS` — benilla-app/src/capture/depth_probe.rs, benilla-app/src/dev.rs
- `WOW_DITHER` — benilla-world/src/ffx_glow.rs
- `WOW_DPI` — benilla-app/src/video.rs
- `WOW_DRESS_CENSUS` — benilla-app/src/capture/probes/dress_census.rs, benilla-app/src/dev.rs
- `WOW_DRESS_CENSUS_RADIUS` — benilla-app/src/capture/probes/dress_census.rs
- `WOW_DRIFT_DUMP` — benilla-world/src/liquid/drift.rs
- `WOW_DUMP_TILE` — benilla-world/src/wmo_portal/audit/light_probe.rs
- `WOW_EFFECT_TRACE` — benilla-world/src/particles/render.rs
- `WOW_EMIT_DUMP` — benilla-world/src/particles/emitdump.rs
- `WOW_ENTITY_CENSUS` — benilla-app/src/capture/probes/world_census.rs, benilla-app/src/dev.rs
- `WOW_FADE_NEAR` — benilla-world/src/terrain_stream/spawn/mod.rs
- `WOW_FARCLIP` — benilla-app/src/cvars.rs, benilla-world/src/view.rs
- `WOW_FEED_COST` — benilla-app/src/ui_items/feed.rs
- `WOW_FEED_GATE_CHECK` — benilla-app/src/ui_script/gate.rs, benilla-world/src/bgwin.rs
- `WOW_FEED_GATE_TRACE` — benilla-app/src/ui_script/gate.rs, benilla-world/src/bgwin.rs
- `WOW_FIXED_DT` — benilla-world/src/frame_pace.rs
- `WOW_FOG_DUMP` — benilla-world/src/lighting/resolve.rs, benilla-world/src/liquid/query.rs
- `WOW_FOLLOW_TRACE` — benilla-app/src/player/follow.rs
- `WOW_FORCE_SUB` — benilla-world/src/liquid/mod.rs
- `WOW_FPS_` — benilla-world/src/bgwin.rs
- `WOW_FPS_JOURNAL` — benilla-app/src/perf/journal.rs
- `WOW_FPS_PROBE` — benilla-app/src/capture/mod.rs
- `WOW_FRAME_PHASES` — benilla-app/src/perf/phases.rs
- `WOW_FREEZE_UI_MESH` — benilla-app/src/ui_pass.rs
- `WOW_FX_AGE` — benilla-app/src/capture/mod.rs
- `WOW_FX_AT` — benilla-app/src/capture/mod.rs
- `WOW_FX_AZ` — benilla-app/src/capture/mod.rs
- `WOW_FX_CENSUS` — benilla-app/src/capture/probes/fx_draw_census.rs
- `WOW_FX_DISPLAY` — benilla-app/src/capture/mod.rs
- `WOW_FX_DIST` — benilla-app/src/capture/mod.rs
- `WOW_FX_EL` — benilla-app/src/capture/mod.rs
- `WOW_FX_FLY` — benilla-app/src/capture/mod.rs
- `WOW_FX_GO` — benilla-app/src/capture/mod.rs
- `WOW_FX_GO_STATE` — benilla-app/src/capture/mod.rs
- `WOW_FX_GO_TYPE` — benilla-app/src/capture/mod.rs
- `WOW_FX_GROUND` — benilla-app/src/capture/mod.rs
- `WOW_FX_HOLD` — benilla-app/src/capture/mod.rs
- `WOW_FX_MINUTE` — benilla-app/src/capture/mod.rs
- `WOW_FX_MODEL` — benilla-app/src/capture/mod.rs
- `WOW_FX_SCALE` — benilla-app/src/capture/mod.rs
- `WOW_FX_TURN` — benilla-app/src/capture/mod.rs
- `WOW_FX_UP` — benilla-app/src/capture/mod.rs
- `WOW_FX_YAW` — benilla-app/src/capture/mod.rs
- `WOW_GHOST_PROBE` — benilla-app/src/death/mod.rs
- `WOW_GLUE_ROUNDTRIP` — benilla-app/src/char_select/mod.rs, benilla-world/src/bgwin.rs
- `WOW_GLYPH_CACHE` — benilla-app/src/ui_text/engine/gpu.rs
- `WOW_GM` — benilla-app/src/probe_shield.rs
- `WOW_GOD` — benilla-app/src/preflight.rs, benilla-app/src/probe_shield.rs
- `WOW_GPU_MS` — benilla-app/src/perf/gpu.rs
- `WOW_GPU_PASSES` — benilla-app/src/perf/journal.rs
- `WOW_GROUND_CENSUS` — benilla-app/src/capture/probes/ground_census.rs, benilla-app/src/dev.rs
- `WOW_GROUND_CENSUS_RADIUS` — benilla-app/src/capture/probes/ground_census.rs
- `WOW_GX_CENSUS` — benilla-world/src/static_gx/cull.rs
- `WOW_GX_FADE_TRACE` — benilla-world/src/static_gx/cull.rs
- `WOW_GX_PERF` — benilla-world/src/static_gx/mod.rs
- `WOW_HIT_COST` — benilla-app/src/ui_script/input.rs
- `WOW_HOST` — benilla-app/src/cvars.rs, benilla-app/src/realmlist.rs
- `WOW_HOVER_LOG` — benilla-app/src/hover_log.rs
- `WOW_HOVER_LOG_REPORT` — benilla-app/src/lib.rs
- `WOW_HOVER_PROBE` — benilla-app/src/target/hover_probe.rs
- `WOW_INDIRECT` — benilla-world/src/world_plugins.rs
- `WOW_INTERIOR_COST` — benilla-world/src/interior.rs
- `WOW_INTERIOR_LOG` — benilla-world/src/entity_shade.rs, benilla-world/src/interior.rs, benilla-world/src/model_fade.rs
- `WOW_IO_BUFFER` — benilla-app/src/sound/mixer.rs
- `WOW_JITTER` — benilla-app/src/capture/probes/jitter.rs, benilla-app/src/dev.rs
- `WOW_JITTER_RAW` — benilla-app/src/capture/probes/jitter.rs
- `WOW_LAYOUT_DERIVE_TRACE` — benilla-ui/src/script/model.rs
- `WOW_LAYOUT_PROF` — benilla-ui/src/script/layout.rs
- `WOW_LAYOUT_TOUCH_TRACE` — benilla-ui/src/script/model.rs
- `WOW_LAYOUT_VERIFY` — benilla-ui/src/script/layout.rs
- `WOW_LIFT_CENSUS` — benilla-app/src/capture/probes/lift_census.rs, benilla-app/src/dev.rs
- `WOW_LIGHT_AT` — benilla-world/src/wmo_portal/audit/light_probe.rs
- `WOW_LIGHT_DUMP` — benilla-world/src/lighting/global_light.rs, benilla-world/src/lighting/resolve.rs
- `WOW_LIGHT_GRID` — benilla-world/src/wmo_portal/audit/light_probe.rs
- `WOW_LIQUID_DC` — benilla-assets/src/world_assets/images.rs
- `WOW_LIVE_` — benilla-world/src/bgwin.rs
- `WOW_LIVE_FPS` — benilla-app/src/capture/probes/live_fps.rs, benilla-app/src/dev.rs, benilla-world/src/bgwin.rs
- `WOW_LIVE_FPS_AT` — benilla-app/src/capture/probes/live_fps.rs
- `WOW_LIVE_FPS_MOVE` — benilla-app/src/capture/probes/live_fps.rs
- `WOW_LIVE_SHOT` — benilla-app/src/capture/live_shot.rs, benilla-app/src/dev.rs
- `WOW_LIVE_SHOT_AT` — benilla-app/src/capture/live_shot.rs
- `WOW_LIVE_SHOT_COUNT` — benilla-app/src/capture/live_shot.rs
- `WOW_LIVE_SHOT_EVERY` — benilla-app/src/capture/live_shot.rs
- `WOW_LOGIN_SHOT` — benilla-world/src/bgwin.rs
- `WOW_LOGIN_SHOT_OUT` — benilla-app/src/login/screen.rs
- `WOW_LOGIN_SMOKE` — benilla-app/src/char_select/mod.rs, benilla-app/src/login/mod.rs, benilla-app/src/login/smoke.rs, benilla-world/src/bgwin.rs
- `WOW_LOGIN_SMOKE_HOLD` — benilla-app/src/login/smoke.rs
- `WOW_LOGOUT_SMOKE` — benilla-app/src/char_select/mod.rs, benilla-world/src/bgwin.rs
- `WOW_MAP` — benilla-app/src/capture/mod.rs, benilla-world/src/world_map.rs
- `WOW_MATANIM_DUTY` — benilla-world/src/doodad_anim/mat_anim.rs
- `WOW_MATANIM_LIVE` — benilla-world/src/doodad_anim/mat_anim.rs
- `WOW_MATANIM_PROBE` — benilla-world/src/doodad_anim/mat_anim.rs
- `WOW_MERGE_CENSUS` — benilla-world/src/static_merge.rs
- `WOW_MERGE_FADERS` — benilla-world/src/terrain_stream/merge.rs
- `WOW_MERGED_FADE` — benilla-assets/src/materials.rs
- `WOW_MERGED_SLOT` — benilla-assets/src/materials.rs
- `WOW_MESH_EVENTS` — benilla-app/src/perf/mod.rs
- `WOW_MESH_HOLDERS` — benilla-app/src/perf/mod.rs
- `WOW_MESH_TOUCH` — benilla-app/src/perf/mod.rs
- `WOW_MIX_TAP` — benilla-app/src/sound/mix_tap.rs
- `WOW_MM_BLIP_PROBE` — benilla-app/src/minimap/mod.rs, benilla-world/src/bgwin.rs
- `WOW_MM_PROBE` — benilla-app/src/capture/mod.rs, benilla-app/src/minimap/mod.rs, benilla-world/src/bgwin.rs
- `WOW_MM_STATS` — benilla-app/src/minimap/mod.rs
- `WOW_MM_ZOOM` — benilla-app/src/minimap/mod.rs
- `WOW_MOVE_SPEED` — benilla-app/src/player/setup.rs
- `WOW_MOVE_TRACE` — benilla-assets/src/trace.rs
- `WOW_MOVE_TRACE_TAGS` — benilla-assets/src/trace.rs
- `WOW_MSAA` — benilla-app/src/cvars.rs, benilla-world/src/view.rs
- `WOW_MT_EXTRACT` — benilla-app/src/lib.rs
- `WOW_MT_POSTUPDATE` — benilla-app/src/lib.rs
- `WOW_MT_UPDATE` — benilla-app/src/lib.rs
- `WOW_NAME_AZ` — benilla-app/src/capture/mod.rs
- `WOW_NAME_DIST` — benilla-app/src/capture/mod.rs
- `WOW_NAME_EL` — benilla-app/src/capture/mod.rs
- `WOW_NAME_H` — benilla-app/src/capture/mod.rs
- `WOW_NO_ALPHATEST` — benilla-world/src/model_render.rs
- `WOW_NO_ANIM_LOD` — benilla-app/src/creature_anim/lod.rs, benilla-app/src/lib.rs
- `WOW_NO_BC` — benilla-assets/src/gpu_blp.rs
- `WOW_NO_DOODAD_BODIES` — benilla-world/src/terrain_stream/collider.rs
- `WOW_NO_DRAW_ELECTION` — benilla-world/src/exterior_cull.rs
- `WOW_NO_FFX` — benilla-world/src/ffx_glow.rs
- `WOW_NO_FRAME_PACE` — benilla-world/src/frame_pace.rs
- `WOW_NO_HULL_WELD` — benilla-world/src/terrain_stream/weld.rs
- `WOW_NO_INDIRECT` — benilla-app/src/player/setup.rs
- `WOW_NO_LIQUID` — benilla-world/src/liquid/mod.rs
- `WOW_NO_MESH_PARK` — benilla-world/src/model_render/park.rs
- `WOW_NO_MOUNT_TILT` — benilla-app/src/entities/conform.rs
- `WOW_NO_PARTICLES` — benilla-world/src/liquid/drift.rs, benilla-world/src/particles.rs, benilla-world/src/ribbons.rs
- `WOW_NO_RIDE_FRAME` — benilla-world/src/ride_frame.rs
- `WOW_NO_RIG_REBASE` — benilla-world/src/rig_palette.rs
- `WOW_NO_ROOM_LOD` — benilla-app/src/creature_anim/lod.rs
- `WOW_NO_TILE_CELLS` — benilla-world/src/terrain_stream/furnish.rs
- `WOW_NO_TILE_DROP` — benilla-world/src/terrain_stream.rs
- `WOW_NO_TWIST` — benilla-app/src/creature_anim/twist.rs
- `WOW_NODE_PROBE` — benilla-app/src/capture/probes/world_census.rs, benilla-app/src/dev.rs, benilla-world/src/bgwin.rs
- `WOW_NOPORTALCULL` — benilla-world/src/dev_state.rs
- `WOW_NOSOUND` — benilla-app/src/sound/mod.rs
- `WOW_NOVSYNC` — benilla-app/src/video.rs, benilla-world/src/worldview.rs
- `WOW_OCEAN_DEPTH_DIV` — benilla-formats/src/liquid.rs
- `WOW_PANEL` — benilla-world/src/dev_state.rs
- `WOW_PART_CHURN` — benilla-app/src/perf/mod.rs
- `WOW_PARTICLE_CENSUS` — benilla-app/src/capture/probes/particle_census.rs, benilla-app/src/dev.rs, benilla-world/src/bgwin.rs
- `WOW_PARTICLE_DEPTHDUMP` — benilla-world/src/particles/depthdump.rs
- `WOW_PARTICLE_DEPTHDUMP_BONES` — benilla-world/src/particles/depthdump.rs
- `WOW_PARTICLE_FLAT` — benilla-world/src/particles/render.rs
- `WOW_PARTICLE_NODEPTH` — benilla-world/src/particles/render.rs
- `WOW_PASS` — benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/run_mode.rs
- `WOW_PERF_HUD` — benilla-app/src/perf/hud.rs
- `WOW_PHASE` — benilla-app/src/capture/phase_probe.rs, benilla-app/src/dev.rs, benilla-world/src/bgwin.rs
- `WOW_PHASE_AT` — benilla-app/src/capture/phase_probe.rs
- `WOW_PHASE_COUNT` — benilla-app/src/capture/phase_probe.rs
- `WOW_PHYS_HZ` — benilla-world/src/world_plugins.rs
- `WOW_PHYS_PREPROP` — benilla-world/src/world_plugins.rs
- `WOW_PICK` — benilla-app/src/capture/pick_probe.rs, benilla-app/src/dev.rs, benilla-world/src/bgwin.rs
- `WOW_PICK_AT` — benilla-app/src/capture/pick_probe.rs
- `WOW_PICK_COUNT` — benilla-app/src/capture/pick_probe.rs
- `WOW_PICK_EVERY` — benilla-app/src/capture/pick_probe.rs
- `WOW_PIN_ASPECT` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIN_COL` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIN_EYE` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIN_FLOOR` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIN_FOVY` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIN_LOOK` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIN_MAP` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIN_SITE` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIN_TILE` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIN_UID` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIN_WMO` — benilla-world/src/wmo_portal/audit/pin.rs
- `WOW_PIPE_TRACE` — benilla-app/src/pipe_warm/mod.rs
- `WOW_POINTS_DUMP` — benilla-world/src/lighting/global_light.rs
- `WOW_PORTRAIT_TEST` — benilla-app/src/portrait/mod.rs, benilla-app/src/portrait/test_bake.rs, benilla-world/src/bgwin.rs
- `WOW_PORTRAIT_TEST_SKIN` — benilla-app/src/portrait/test_bake.rs
- `WOW_PROBE` — benilla-app/src/dev.rs, benilla-app/src/lib.rs, benilla-world/src/bgwin.rs
- `WOW_PROBE_AUCTION` — benilla-app/src/dev.rs
- `WOW_PROBE_BANK` — benilla-app/src/dev.rs
- `WOW_PROBE_BG` — benilla-app/src/capture/probe_bg.rs, benilla-app/src/dev.rs
- `WOW_PROBE_BG_SAMPLES` — benilla-app/src/capture/probe_bg.rs
- `WOW_PROBE_BGQUEUE` — benilla-app/src/dev.rs
- `WOW_PROBE_BINDER` — benilla-app/src/dev.rs
- `WOW_PROBE_BOOK` — benilla-app/src/dev.rs
- `WOW_PROBE_CAM` — benilla-app/src/capture/probe_cam.rs
- `WOW_PROBE_CHARCREATE` — benilla-app/src/capture/probe_charcreate.rs, benilla-app/src/dev.rs
- `WOW_PROBE_CHARCREATE_KEEP` — benilla-app/src/capture/probe_charcreate.rs
- `WOW_PROBE_CHARTER` — benilla-app/src/dev.rs
- `WOW_PROBE_CHAT` — benilla-app/src/capture/probes/act.rs, benilla-app/src/dev.rs
- `WOW_PROBE_CHAT_AT` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_CHAT_EVERY` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_CHEST` — benilla-app/src/capture/probe_chest.rs, benilla-app/src/dev.rs
- `WOW_PROBE_CLAM` — benilla-app/src/capture/probe_clam.rs, benilla-app/src/dev.rs
- `WOW_PROBE_DOCK` — benilla-app/src/capture/probe_crossing.rs
- `WOW_PROBE_DRAG` — benilla-app/src/capture/probes/act.rs, benilla-app/src/dev.rs
- `WOW_PROBE_DRAG_AT` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_DRAG_LUA` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_DRAG_STEP` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_EXIT_AT` — benilla-app/src/capture/probes/run.rs, benilla-app/src/dev.rs
- `WOW_PROBE_GMTICKET` — benilla-app/src/dev.rs
- `WOW_PROBE_GOQUEST` — benilla-app/src/capture/probe_goquest.rs, benilla-app/src/dev.rs
- `WOW_PROBE_HOVER` — benilla-app/src/capture/probes/act.rs, benilla-app/src/dev.rs
- `WOW_PROBE_HOVER_AT` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_HOVER_DUTY` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_HOVER_JITTER` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_HOVER_STEP` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_KEY` — benilla-app/src/capture/probes/act.rs, benilla-app/src/dev.rs
- `WOW_PROBE_LOOK` — benilla-app/src/capture/probe_look.rs
- `WOW_PROBE_LUA` — benilla-app/src/capture/probes/act.rs, benilla-app/src/dev.rs
- `WOW_PROBE_LUA_AGAIN` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_LUA_AT` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_MAIL` — benilla-app/src/dev.rs
- `WOW_PROBE_MODEL_CAMERA` — benilla-app/src/dev.rs
- `WOW_PROBE_NAME_TRACE` — benilla-app/src/nameplates.rs
- `WOW_PROBE_PARK` — benilla-app/src/capture/probes/run.rs
- `WOW_PROBE_PITCH` — benilla-app/src/capture/probe_pitch.rs
- `WOW_PROBE_RESIZE` — benilla-app/src/capture/probes/run.rs, benilla-app/src/dev.rs
- `WOW_PROBE_SERVICE` — benilla-app/src/dev.rs
- `WOW_PROBE_SHARED_SKIN` — benilla-app/src/entities/attach/char_skin.rs
- `WOW_PROBE_STONE` — benilla-app/src/capture/probe_stone.rs, benilla-app/src/dev.rs
- `WOW_PROBE_UI_ONE_TEX` — benilla-app/src/ui_pass.rs
- `WOW_PROBE_UNCAP` — benilla-app/src/capture/mod.rs
- `WOW_PROBE_VENDOR_SWAP` — benilla-app/src/dev.rs
- `WOW_PROBE_VSYNC` — benilla-app/src/capture/mod.rs
- `WOW_REALM` — benilla-app/src/realm_select/mod.rs
- `WOW_REALM_SMOKE` — benilla-app/src/realm_select/smoke.rs
- `WOW_REFUSE_LOGIN` — benilla-app/src/net/io.rs
- `WOW_REMOTE_FLAT` — benilla-app/src/net/motion/remote.rs
- `WOW_REMOTE_IDLE_GATE` — benilla-app/src/net/motion/remote.rs
- `WOW_REMOTE_SNAP` — benilla-app/src/net/motion/remote.rs
- `WOW_RENDER_SCALE` — benilla-app/src/cvars.rs, benilla-app/src/world_backdrop.rs
- `WOW_RES_CENSUS` — benilla-app/src/perf/census.rs
- `WOW_RESIZE` — benilla-app/src/capture/mod.rs
- `WOW_REVEAL` — benilla-app/src/capture/probes/reveal.rs, benilla-app/src/dev.rs
- `WOW_RIG` — benilla-app/src/capture/probe_rig.rs, benilla-app/src/dev.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/probe_shield.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs, benilla-world/src/bgwin.rs
- `WOW_RIG_CENSUS` — benilla-world/src/rig_palette.rs
- `WOW_RIG_COST` — benilla-world/src/rig_palette.rs
- `WOW_RIG_SKIN` — benilla-assets/src/materials.rs
- `WOW_ROW_BLOAT` — benilla-app/src/perf/census.rs, benilla-app/src/perf/mod.rs
- `WOW_SCHED_CENSUS` — benilla-app/src/dev.rs, benilla-world/src/bgwin.rs
- `WOW_SHADE_CENSUS` — benilla-world/src/entity_shade.rs
- `WOW_SHOT_REQUIRE` — benilla-app/src/capture/live_shot.rs
- `WOW_SHOT_REQUIRE_DIST` — benilla-app/src/capture/live_shot.rs
- `WOW_SKY_DEPTH` — benilla-assets/src/materials.rs
- `WOW_SOUND_PROBE` — benilla-app/src/sound/probe.rs
- `WOW_STALL` — benilla-app/src/capture/probes/stall.rs, benilla-app/src/dev.rs
- `WOW_STALL_INJECT` — benilla-app/src/perf/stall.rs
- `WOW_STALL_SAMPLE` — benilla-app/src/perf/stall.rs
- `WOW_STATIC_GX` — benilla-world/src/static_gx/mod.rs
- `WOW_STATIC_GX_FADE` — benilla-world/src/static_gx/mod.rs
- `WOW_STATIC_GX_PROP` — benilla-world/src/static_gx/mod.rs
- `WOW_STATIC_GX_WMO` — benilla-world/src/static_gx/mod.rs
- `WOW_STATIC_MERGE` — benilla-world/src/terrain_stream/merge.rs
- `WOW_STREAM_TRACE` — benilla-app/src/perf/mod.rs
- `WOW_TAB_TRACE` — benilla-app/src/target/scan.rs
- `WOW_TABHOVER` — benilla-app/src/capture/fixtures.rs
- `WOW_TEARDOWN_INJECT` — benilla-app/src/perf/stall.rs
- `WOW_TEXT_PROBE` — benilla-app/src/ui_script/extract/text.rs
- `WOW_THREADS` — benilla-world/src/boot.rs
- `WOW_TILE_DUMP` — benilla-app/src/ui_models/mod.rs
- `WOW_TILE_TRACE` — benilla-app/src/ui_models/mod.rs
- `WOW_TILE_UNLOAD` — benilla-world/src/assets/mod.rs
- `WOW_TINT_PROBE` — benilla-app/src/aura_visual.rs
- `WOW_TRAIL_CENSUS` — benilla-app/src/capture/probes/trail_census.rs, benilla-app/src/dev.rs
- `WOW_TRILINEAR` — benilla-app/src/cvars.rs, benilla-assets/src/tex_filter.rs
- `WOW_TWIST_GAP` — benilla-app/src/player/body_pose.rs
- `WOW_UI_COST` — benilla-app/src/ui_script/extract/mod.rs
- `WOW_UI_DIFF` — benilla-app/src/ui_pass.rs, benilla-app/src/ui_script/extract/mod.rs
- `WOW_UI_GATE` — benilla-app/src/ui_script/extract/mod.rs
- `WOW_UI_HANDLERS` — benilla-ui/src/script/handler_prof.rs
- `WOW_UI_PICK` — benilla-app/src/ui_script/extract/mod.rs
- `WOW_UI_PROBE` — benilla-app/src/ui_pass.rs
- `WOW_UI_SCALE` — benilla-app/src/cvars.rs, benilla-app/src/ui_script/mod.rs
- `WOW_UI_SPLICE_VERIFY` — benilla-app/src/ui_script/extract/mod.rs
- `WOW_UISCRIPT_DUMP` — benilla-app/tests/ui_script_wall.rs
- `WOW_UNATTENDED` — benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/run_mode.rs, benilla-world/src/bgwin.rs
- `WOW_UNIT_VISUALS` — benilla-app/src/capture/probes/visual_census.rs, benilla-app/src/dev.rs
- `WOW_UNIT_VISUALS_RADIUS` — benilla-app/src/capture/probes/visual_census.rs
- `WOW_UPLOAD_BUDGET` — benilla-world/src/world_plugins.rs
- `WOW_USER` — benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/run_mode.rs
- `WOW_VIS_DUMP` — benilla-app/src/capture/probes/live_fps.rs
- `WOW_VIS_TRACE` — benilla-world/src/model_render/visibility.rs
- `WOW_VISTA_AT` — benilla-app/src/capture/mod.rs
- `WOW_VISTA_FACE` — benilla-app/src/capture/mod.rs
- `WOW_VISTA_MIN` — benilla-app/src/capture/mod.rs
- `WOW_VISTA_PITCH` — benilla-app/src/capture/mod.rs
- `WOW_VPLATE_TRACE` — benilla-app/src/vplates.rs
- `WOW_WARM_SLICE` — benilla-app/src/pipe_warm/mod.rs
- `WOW_WATER_CLIP` — benilla-assets/src/materials.rs
- `WOW_WAVE_DUMP` — benilla-world/src/ffx_glow.rs
- `WOW_WEATHER` — benilla-world/src/weather/mod.rs
- `WOW_WEATHER_DUMP` — benilla-world/src/weather/mod.rs
- `WOW_WFX_AGE` — benilla-app/src/capture/mod.rs
- `WOW_WFX_AT` — benilla-app/src/capture/mod.rs
- `WOW_WFX_AZ` — benilla-app/src/capture/mod.rs
- `WOW_WFX_DEPTH` — benilla-app/src/capture/mod.rs
- `WOW_WFX_DIST` — benilla-app/src/capture/mod.rs
- `WOW_WFX_EL` — benilla-app/src/capture/mod.rs
- `WOW_WFX_HEAD` — benilla-app/src/capture/mod.rs
- `WOW_WFX_MODE` — benilla-app/src/capture/mod.rs
- `WOW_WFX_SPEED` — benilla-app/src/capture/mod.rs
- `WOW_WIN` — benilla-app/src/video.rs, benilla-world/src/worldview.rs
- `WOW_WMO_BIAS` — benilla-world/src/model_render.rs, benilla-world/src/static_gx/mod.rs
- `WOW_WORLDVIEW_` — benilla-world/src/bgwin.rs
- `WOW_WORLDVIEW_AT` — benilla-world/src/worldview.rs
- `WOW_WORLDVIEW_CHECK` — benilla-world/src/bgwin.rs, benilla-world/src/worldview.rs
- `WOW_WORLDVIEW_SHOT` — benilla-world/src/worldview.rs
- `WOW_WORLDVIEW_SHOT_AT` — benilla-world/src/worldview.rs
- `WOW_WORLDVIEW_SURVEY` — benilla-world/src/worldview.rs
## Scripts (`scripts/`)
- `doc-links.py` — fail on a doc link whose target does not exist ANYWHERE in the workspace.
- `gen-message-catalog.py` — Generate `crates/benilla-ui/src/messages/catalog.rs` from wow-5875-re's message-catalog.tsv.
- `gen-reference-events.py` — Regenerate `reference/1.12-events.tsv` — every FrameScript event the 1.12.1 client can dispatch, with the ARGUMENTS its producers push.
- `gen-reference-globals.py` — Regenerate `reference/1.12-globals.tsv` — the 1.12.1 client's global namespace, with each name attributed to whoever provides it.
- `gen-reference-verb-events.py` — 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.
- `mixsum.py` — read a `$WOW_MIX_TAP` capture and say, in numbers, what the mix was doing when.
- `pass-span-lint.py` — one `pass_span` per render pass at a time (decision 2258, bug B390).
- `soundprobe.py` — read back a `$WOW_SOUND_PROBE` capture and name the mechanism.
- `vplsum.py` — Read a `vpl`/`bub` overlay trace and say WHERE the overlay's motion came from — projection, solve, or snap.
- `api-coverage.sh` — 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 o…
- `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 …
- `cine.sh` — play one cinematic on the probe and print what happened, as a timeline.
- `crosscheck.sh` — compile benilla for the platforms the gates cannot see.
- `gates.sh` — The commit gates (docs/METHOD.md "Gates"), one vetted runner: fmt-check → clippy -D warnings → workspace tests (skips refused where the data is) → doc…
- `genmap.sh` — 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 generat…
- `leg.sh` — the interleaved A/B leg runner (1353 law 1, mechanized).
- `smoke.sh` — The fourth gate, as a command.
- `soundprobe.sh` — start the client in MEASURING MODE (decision 1556).
- `summon-live.sh` — The two-client live summon probe (decision 1747) — the instrument that closes the loop on being summoned, because nothing smaller can.
- `visual.sh` — Phase-5 visual A/B render harness driver (decisions 0008 + 0010).
- `vkleg.sh` — 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 sce…
- `wt.sh` — the session-worktree pool (decisions 0192 + 0433).
+123
View File
@@ -0,0 +1,123 @@
# METHOD — how benilla is built
benilla is a from-scratch World of Warcraft 1.12.1 client in Rust and Bevy. It reads the game's
data from the player's own install, speaks the original protocol to any 1.12.1 server, and does
not build a server or simulate the game. The reference client is the spec: benilla does what
1.12.1 does, as a modern, idiomatic client.
## The rule for every change
A change is right when it makes benilla more like 1.12.1 or fixes a bug, with evidence from the
reference, in one small piece, with the gates green. A deviation from the reference is the
maintainer's call alone, and each one is written down where it lives: a CVar's `Deviates` row, a
comment naming the reference fact and why we differ. Anything else is a fork.
## The loop
1. **The reference first.** For anything fidelity-defining, get the mechanism from the real
client before building: its behaviour, a DBC field, a FrameXML line, a packet capture. The
names and shapes under `reference/` are the surface benilla tracks.
2. **Build it idiomatically.** Modern Bevy and Rust, performance woven in from the start.
Implement the mechanism well; do not ape a quirk that is a bug.
3. **Measure, never eyeball.** Timing, delay, ordering and feel are settled with instruments:
trace timestamps, byte-derived numbers, a numeric probe. A capture confirms an existence
fact; it cannot measure time. Never search for a viewpoint: take the one simplest shot, and
if it does not settle the question, hand over a precise script ("log in, look at X, does Y?").
4. **Close the loop on the symptom.** A bug reported as seen is closed by reproducing it at the
reported spot first, fixing, and showing the same instrument clean there plus one adjacent
state. Green gates and an existing mechanism are never that evidence.
5. **Prove the run before reading the result.** A live result is evidence once the run is valid
from numbers: preflight banner clean, camera and body where intended, the subject in frame,
the window long enough. A negative from an unproven run is not evidence.
6. **Commit small and often.** Atomic units, explicit paths; main only advances by fast-forward.
No "done" and no commit without saying what was verified and how the result was judged.
## Hard rules
- **Never commit anything from the install.** Art, models, sounds, maps, data: the install is
read at runtime from a gitignored path, and everyone provides their own. The one exception is
interface code: FrameXML and GlueXML run off the player's own patch chain, and the copies
already under `assets/ui` stay until they retire.
- **The install is read-only.** benilla never writes into the WoW folder: no screenshot, log,
cache or scratch file. A hook blocks such a write at the tool call, and `scripts/smoke.sh`
fails a run that leaves the install changed.
- **Local state lives in one folder**, `benilla-config/` beside the binary, and every path to
it resolves through `crate::local_state`. Player settings are CVars persisted as a diff in
`benilla-config/config.toml`.
- **UI is stock-first: a window is migrated, not authored.** The end state is the stock 1.12
FrameXML executed off the player's own chain. `assets/ui` does not grow: a test names its
files and fails on a new one. A window is built by pointing `benilla.toc` at the stock file,
deleting ours and building the engine verbs it calls, never stubbing one to make the file
load.
- **A setting's default is the reference's default.** Every option boots at the stock 1.12
value. Shipping another value costs an explicit `Deviates` row with the reason, and a test
fails a row that drifts either way.
- **Our code is MIT OR Apache-2.0**, which is a claim about provenance: no original client
code, no bundled assets.
## Where things live
- `docs/METHOD.md`, this file: rules and method. Never state, never a log.
- `docs/MAP.md`: what is built, generated from the code by `scripts/genmap.sh` at every land.
Read it to orient; never edit or commit it by hand.
- git: the history. No changelogs, no status docs, no "what we did" narratives.
- The code: a comment says what the code does and the 1.12 fact behind it, in a line. No
history, no stories.
- `.claude/skills/`: procedures with steps. `probe` runs the client unattended against the local
server.
## Gates
- **The land gates**: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets
-- -D warnings`, `cargo test --workspace`, the player build without the `dev` feature and the
engine boot checks, as `scripts/gates.sh`. `wt.sh land` runs it on the tree that lands; it is
memoized on the tree.
- **Per round**: `scripts/check.sh`, scoped to the changed crates and their dependents. Run each
gate once per round; `tee` the output to a file and grep the file.
- **A clean run** is the fourth gate: `scripts/smoke.sh`, a live login and logout against the
local server. For wire work the gate is a live run with a trace (`WOW_MOVE_TRACE=<path>`,
tags filtered), never a capture: capture mode has no network.
- **Platform seams**: a `cfg(target_os)`, a `[target.'cfg(…)']` dependency, a `#[link]` or an
`extern "system"` is invisible to the macOS gates. `scripts/crosscheck.sh` compiles Linux and
Windows, and the land worker runs it when the diff touches a seam.
- **Vulkan**: `scripts/vkleg.sh` runs the client on Vulkan in a container before a release,
because Metal cannot ask what Vulkan validation refuses.
- Hooks enforce only two things: no writes into the install, and the worktree rule on a machine
with a pool. Everything else is a gate or discipline.
## Worktrees
On a machine with a pool, every session works in its own slot and `scripts/wt.sh` is the only
way to get one. `claim <name>` at the start; `land <name>` at the end, which rebases,
regenerates the map, runs the full gate chain, fast-forwards and pushes main and releases the
slot, one land at a time; `release` to abandon; `status` to see the pool. Never the primary
checkout, which belongs to no session and stays clean, and never the harness worktree tool or a
hand-rolled `git worktree add`. Work against the slot by path: prefix Bash calls with
`cd <slot> &&` and give edits absolute paths under it. No free slot means stop and ask which
one to take over, never fall back to the primary. Hooks enforce all of this.
Sessions share the machine, so a kill is scoped to a PID, never to a command pattern:
`pkill -f "cargo test"` takes out every neighbour's gates along with yours.
On a plain clone there is no pool: work on a branch and open a pull request.
## The local server
benilla talks to a local vmangos (build 5875); `WOW_HOST`, `WOW_USER`, `WOW_PASS` and `WOW_CHAR`
override the defaults. These bind every session and every agent:
- A probe logs in as its own probe account (`probeN` for `pool-N`), never as a player's: a login
kicks whoever holds the account.
- Every unattended run says so: `WOW_UNATTENDED=1`. An agent's run is silent: `WOW_NOSOUND=1`.
- No unattended combat probes.
- Anything about hostility, reaction colour, nameplates, threat, damage or breath runs with
`WOW_GM=off`.
- Read the preflight banner before debugging anything else.
The mechanics are the `probe` skill.
## Agents in a shared tree
Parallel agents share one working tree: whole-tree git ops (`stash`, `reset --hard`,
`clean -f`, a pathless `checkout`) are forbidden, diagnostics use path-scoped commands, and an
agent never gets a tree of its own.
+410
View File
@@ -0,0 +1,410 @@
# The 1.12.1 client's FrameScript events and the ARGUMENTS their producers push.
# GENERATED — scripts/gen-reference-events.py. Source: wow-5875-re's
# re/events/event-catalog.tsv + event-firesites.tsv. Decision 2140.
#
# arg_formats `|`-separated, one per distinct producer shape. `()` = zero Lua values.
# Directives: %s string, %d/%u number, %f number. An event with two
# producers of different shapes carries both, and which one a given
# transition takes is a question about the transition, not the name.
# conf exact = gate on it. advisory = a contributing site declares more
# varargs than it pushes. none = no producer family reaches it HERE,
# which is not a claim that it has none; never gate on it.
# producers signal (0x703e50, no varargs) | signal2 (0x703f50, format-driven) |
# unit-field-bridge (0x51bbb0/0x51bd50 -> 0x515e50, always %s) |
# token-fanout (0x515e50 with a literal id, also %s).
# 394 events, 278 with a known producer; 22 glue-space fire sites dropped.
name ids producers arg_formats conf note
ACTIONBAR_HIDEGRID 211 signal () exact
ACTIONBAR_PAGE_CHANGED 212 signal () exact
ACTIONBAR_SHOWGRID 210 signal () exact
ACTIONBAR_SLOT_CHANGED 213 signal2 %d exact
ACTIONBAR_UPDATE_COOLDOWN 216 signal () exact
ACTIONBAR_UPDATE_STATE 214 signal () exact
ACTIONBAR_UPDATE_USABLE 215 signal () exact
ADDON_ACTION_FORBIDDEN 432 signal2 %s exact
ADDON_LOADED 429 signal2 %s exact
AREA_SPIRIT_HEALER_IN_RANGE 527 signal () exact
AREA_SPIRIT_HEALER_OUT_OF_RANGE 528 signal () exact
AUCTION_BIDDER_LIST_UPDATE 426 signal () exact
AUCTION_HOUSE_CLOSED 422 signal () exact
AUCTION_HOUSE_SHOW 421 signal () exact
AUCTION_ITEM_LIST_UPDATE 424 signal () exact
AUCTION_OWNED_LIST_UPDATE 425 signal () exact
AUTOEQUIP_BIND_CONFIRM 289 signal2 %d exact
AUTOFOLLOW_BEGIN 367 signal2 %s exact
AUTOFOLLOW_END 368 signal () exact
BAG_CLOSED 329 signal2 %d exact
BAG_OPEN 327 signal2 %d exact
BAG_UPDATE 328 signal2 %d exact
BAG_UPDATE_COOLDOWN 330 signal () exact
BANKFRAME_CLOSED 349 signal () exact
BANKFRAME_OPENED 348 signal () exact
BATTLEFIELDS_CLOSED 418 signal () exact
BATTLEFIELDS_SHOW 417 signal () exact
BILLING_NAG_DIALOG 517 signal2 %d exact
BIND_ENCHANT 402 signal () exact
CANCEL_LOOT_ROLL 506 signal2 %d exact
CHARACTER_POINTS_CHANGED 264 signal2 %d%d exact
CHAT_MSG_ADDON 227 signal2 %s%s%s%s exact
CHAT_MSG_AFK 249 ? none
CHAT_MSG_BATTLEGROUND 546 ? none
CHAT_MSG_BATTLEGROUND_LEADER 547 ? none
CHAT_MSG_BG_SYSTEM_ALLIANCE 497 ? none
CHAT_MSG_BG_SYSTEM_HORDE 498 ? none
CHAT_MSG_BG_SYSTEM_NEUTRAL 496 ? none
CHAT_MSG_CHANNEL 243 ? none
CHAT_MSG_CHANNEL_JOIN 244 ? none
CHAT_MSG_CHANNEL_LEAVE 245 ? none
CHAT_MSG_CHANNEL_LIST 246 ? none
CHAT_MSG_CHANNEL_NOTICE 247 ? none
CHAT_MSG_CHANNEL_NOTICE_USER 248 ? none
CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS 455 ? none
CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_MISSES 456 ? none
CHAT_MSG_COMBAT_CREATURE_VS_PARTY_HITS 453 ? none
CHAT_MSG_COMBAT_CREATURE_VS_PARTY_MISSES 454 ? none
CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS 451 ? none
CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES 452 ? none
CHAT_MSG_COMBAT_FACTION_CHANGE 533 ? none
CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS 447 ? none
CHAT_MSG_COMBAT_FRIENDLYPLAYER_MISSES 448 ? none
CHAT_MSG_COMBAT_FRIENDLY_DEATH 457 ? none
CHAT_MSG_COMBAT_HONOR_GAIN 460 ? none
CHAT_MSG_COMBAT_HOSTILEPLAYER_HITS 449 ? none
CHAT_MSG_COMBAT_HOSTILEPLAYER_MISSES 450 ? none
CHAT_MSG_COMBAT_HOSTILE_DEATH 458 ? none
CHAT_MSG_COMBAT_LOG 251 ? none
CHAT_MSG_COMBAT_MISC_INFO 358 ? none
CHAT_MSG_COMBAT_PARTY_HITS 445 ? none
CHAT_MSG_COMBAT_PARTY_MISSES 446 ? none
CHAT_MSG_COMBAT_PET_HITS 443 ? none
CHAT_MSG_COMBAT_PET_MISSES 444 ? none
CHAT_MSG_COMBAT_SELF_HITS 441 ? none
CHAT_MSG_COMBAT_SELF_MISSES 442 ? none
CHAT_MSG_COMBAT_XP_GAIN 459 ? none
CHAT_MSG_DND 250 ? none
CHAT_MSG_EMOTE 236 ? none
CHAT_MSG_FILTERED 544 ? none
CHAT_MSG_GUILD 231 ? none
CHAT_MSG_IGNORED 252 ? none
CHAT_MSG_LOOT 254 ? none
CHAT_MSG_MONEY 255 ? none
CHAT_MSG_MONSTER_EMOTE 242 ? none
CHAT_MSG_MONSTER_SAY 239 ? none
CHAT_MSG_MONSTER_WHISPER 241 ? none
CHAT_MSG_MONSTER_YELL 240 ? none
CHAT_MSG_OFFICER 232 ? none
CHAT_MSG_PARTY 229 ? none
CHAT_MSG_RAID 230 ? none
CHAT_MSG_RAID_BOSS_EMOTE 541 ? none
CHAT_MSG_RAID_LEADER 256 ? none
CHAT_MSG_RAID_WARNING 257 ? none
CHAT_MSG_SAY 228 ? none
CHAT_MSG_SKILL 253 ? none
CHAT_MSG_SPELL_AURA_GONE_OTHER 482 ? none
CHAT_MSG_SPELL_AURA_GONE_PARTY 481 ? none
CHAT_MSG_SPELL_AURA_GONE_SELF 480 ? none
CHAT_MSG_SPELL_BREAK_AURA 484 ? none
CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF 476 ? none
CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE 475 ? none
CHAT_MSG_SPELL_CREATURE_VS_PARTY_BUFF 474 ? none
CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE 473 ? none
CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF 472 ? none
CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE 471 ? none
CHAT_MSG_SPELL_DAMAGESHIELDS_ON_OTHERS 479 ? none
CHAT_MSG_SPELL_DAMAGESHIELDS_ON_SELF 478 ? none
CHAT_MSG_SPELL_FAILED_LOCALPLAYER 495 ? none
CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF 468 ? none
CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE 467 ? none
CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF 470 ? none
CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE 469 ? none
CHAT_MSG_SPELL_ITEM_ENCHANTMENTS 483 ? none
CHAT_MSG_SPELL_PARTY_BUFF 466 ? none
CHAT_MSG_SPELL_PARTY_DAMAGE 465 ? none
CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS 494 ? none
CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE 493 ? none
CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS 490 ? none
CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE 489 ? none
CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_BUFFS 492 ? none
CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE 491 ? none
CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS 488 ? none
CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE 487 ? none
CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS 486 ? none
CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE 485 ? none
CHAT_MSG_SPELL_PET_BUFF 464 ? none
CHAT_MSG_SPELL_PET_DAMAGE 463 ? none
CHAT_MSG_SPELL_SELF_BUFF 462 ? none
CHAT_MSG_SPELL_SELF_DAMAGE 461 ? none
CHAT_MSG_SPELL_TRADESKILLS 477 ? none
CHAT_MSG_SYSTEM 238 ? none
CHAT_MSG_TEXT_EMOTE 237 ? none
CHAT_MSG_WHISPER 234 ? none
CHAT_MSG_WHISPER_INFORM 235 ? none
CHAT_MSG_YELL 233 ? none
CINEMATIC_START 370 signal () exact
CINEMATIC_STOP 371 signal () exact
CLOSE_INBOX_ITEM 515 signal2 %d exact
CLOSE_TABARD_FRAME 375 signal () exact
CLOSE_WORLD_MAP 373 signal () exact
COMBAT_TEXT_UPDATE 542 signal2 %s|%s%s|%s%s%s exact
CONFIRM_BINDER 513 signal2 %s exact
CONFIRM_LOOT_ROLL 507 signal2 %d%d exact
CONFIRM_PET_UNLEARN 529 signal2 %d exact
CONFIRM_SUMMON 516 signal () exact
CONFIRM_TALENT_WIPE 512 signal2 %d exact
CONFIRM_XP_LOSS 396 signal2 %d exact
CORPSE_IN_INSTANCE 398 ? none
CORPSE_IN_RANGE 397 ? none
CORPSE_OUT_OF_RANGE 399 signal () exact
CRAFT_CLOSE 361 signal () exact
CRAFT_SHOW 359 signal () exact
CRAFT_UPDATE 360 signal () exact
CURRENT_SPELL_CAST_CHANGED 261 signal () exact
CURSOR_UPDATE 292 signal () exact
CVAR_UPDATE 312 signal2 %s%s exact
DELETE_ITEM_CONFIRM 291 signal2 %s%d exact
DISPLAY_SIZE_CHANGED 511 signal () exact
DUEL_FINISHED 383 signal () exact
DUEL_INBOUNDS 382 signal () exact
DUEL_OUTOFBOUNDS 381 signal () exact
DUEL_REQUESTED 380 signal2 %s exact
EQUIP_BIND_CONFIRM 288 signal2 %d exact
EXECUTE_CHAT_LINE 392 signal2 %s exact
FRIENDLIST_UPDATE 351 signal () exact
GMSURVEY_DISPLAY 538 signal () exact
GM_PLAYER_INFO 409 ? none
GOSSIP_CLOSED 299 signal () exact
GOSSIP_ENTER_CODE 298 signal2 %d exact
GOSSIP_SHOW 297 signal () exact
GUILD_INVITE_CANCEL 283 signal () exact
GUILD_INVITE_REQUEST 282 signal2 %s%s exact
GUILD_MOTD 284 signal2 %s exact
GUILD_REGISTRAR_CLOSED 379 signal () exact
GUILD_REGISTRAR_SHOW 378 signal () exact
GUILD_ROSTER_UPDATE 408 signal|signal2 %d|() exact
IGNORELIST_UPDATE 352 signal () exact
IGR_BILLING_NAG_DIALOG 518 signal () exact
INSPECT_HONOR_UPDATE 525 signal () exact
INSTANCE_BOOT_START 508 signal () exact
INSTANCE_BOOT_STOP 509 signal () exact
ITEM_LOCK_CHANGED 188 signal () exact
ITEM_PUSH 266 signal2 %d%s exact
ITEM_TEXT_BEGIN 293 signal () exact
ITEM_TEXT_CLOSED 296 signal () exact
ITEM_TEXT_READY 295 signal () exact
ITEM_TEXT_TRANSLATION 294 signal2 %f exact
LANGUAGE_LIST_CHANGED 258 signal () exact
LEARNED_SPELL_IN_TAB 510 signal2 %d exact
LOCALPLAYER_PET_RENAMED 331 signal () exact
LOGOUT_CANCEL 278 signal () exact
LOOT_BIND_CONFIRM 287 signal2 %u exact
LOOT_CLOSED 269 signal () exact
LOOT_OPENED 267 signal () exact
LOOT_SLOT_CLEARED 268 signal2 %d exact
LOTTERY_ITEM_UPDATE 548 signal () exact
LOTTERY_SHOW 543 signal () exact
MACRO_ACTION_FORBIDDEN 431 signal () exact
MAIL_CLOSED 411 signal () exact
MAIL_FAILED 514 signal () exact
MAIL_INBOX_UPDATE 416 signal () exact
MAIL_SEND_INFO_UPDATE 414 signal () exact
MAIL_SEND_SUCCESS 415 signal () exact
MAIL_SHOW 410 signal () exact
MEETINGSTONE_CHANGED 519 signal () exact
MEMORY_EXHAUSTED 433 signal2 %d exact
MEMORY_RECOVERED 434 signal () exact
MERCHANT_CLOSED 318 signal () exact
MERCHANT_SHOW 316 signal () exact
MERCHANT_UPDATE 317 signal () exact
MINIGAME_UPDATE 535 signal () exact
MINIMAP_PING 357 signal2 %s%f%f exact
MINIMAP_UPDATE_ZOOM 207 signal () exact
MINIMAP_ZONE_CHANGED 206 signal () exact
MIRROR_TIMER_PAUSE 363 signal2 %s%d exact
MIRROR_TIMER_START 362 signal2 %s%d%d%d%d%s exact
MIRROR_TIMER_STOP 364 signal2 %s exact
NEW_AUCTION_UPDATE 423 signal () exact
OPEN_MASTER_LOOT_LIST 503 signal () exact
OPEN_TABARD_FRAME 374 signal () exact
PARTY_INVITE_CANCEL 281 signal () exact
PARTY_INVITE_REQUEST 280 signal2 %s exact
PARTY_LEADER_CHANGED 219 signal () exact
PARTY_LOOT_METHOD_CHANGED 222 signal () exact
PARTY_MEMBERS_CHANGED 218 signal () exact
PARTY_MEMBER_DISABLE 221 signal2 %d exact
PARTY_MEMBER_ENABLE 220 signal2 %d exact
PETITION_CLOSED 391 signal () exact
PETITION_SHOW 390 signal () exact
PET_ATTACK_START 334 ? none
PET_ATTACK_STOP 335 signal () exact
PET_BAR_HIDEGRID 356 signal () exact
PET_BAR_SHOWGRID 355 signal () exact
PET_BAR_UPDATE 353 signal () exact
PET_BAR_UPDATE_COOLDOWN 354 signal () exact
PET_DISMISS_START 385 signal2 %d exact
PET_STABLE_CLOSED 440 signal () exact
PET_STABLE_SHOW 437 signal () exact
PET_STABLE_UPDATE 438 signal () exact
PET_STABLE_UPDATE_PAPERDOLL 439 signal () exact
PET_UI_CLOSE 428 signal () exact
PET_UI_UPDATE 427 signal () exact
PLAYERBANKBAGSLOTS_CHANGED 350 signal () exact
PLAYERBANKSLOTS_CHANGED 347 signal|signal2 %s|() exact
PLAYER_ALIVE 274 signal () exact
PLAYER_AURAS_CHANGED 192 signal () exact
PLAYER_CAMPING 276 ? none
PLAYER_COMBO_POINTS 202 signal () exact
PLAYER_CONTROL_GAINED 197 signal () exact
PLAYER_CONTROL_LOST 196 signal () exact
PLAYER_DAMAGE_DONE_MODS 201 ? none
PLAYER_DEAD 275 signal () exact
PLAYER_ENTERING_WORLD 272 signal () exact
PLAYER_ENTER_COMBAT 193 signal () exact
PLAYER_FARSIGHT_FOCUS_CHANGED 198 signal () exact
PLAYER_FLAGS_CHANGED 407 token-fanout %s exact
PLAYER_GUILD_UPDATE 345 signal|token-fanout %s|() exact
PLAYER_LEAVE_COMBAT 194 signal () exact
PLAYER_LEAVING_WORLD 273 signal () exact
PLAYER_LEVEL_UP 199 signal2 %d%d%d%d%d%d%d%d%d exact
PLAYER_LOGIN 270 signal () exact
PLAYER_LOGOUT 271 signal () exact
PLAYER_MONEY 200 signal () exact
PLAYER_PVP_KILLS_CHANGED 523 ? none
PLAYER_PVP_RANK_CHANGED 524 ? none
PLAYER_QUITING 277 ? none
PLAYER_REGEN_DISABLED 190 ? none
PLAYER_REGEN_ENABLED 191 signal () exact
PLAYER_SKINNED 520 signal2 %d exact
PLAYER_TARGET_CHANGED 195 signal () exact
PLAYER_TRADE_MONEY 326 signal () exact
PLAYER_UNGHOST 401 signal () exact
PLAYER_UPDATE_RESTING 405 signal () exact
PLAYER_XP_UPDATE 189 ? none
PLAYTIME_CHANGED 530 signal () exact
QUEST_ACCEPT_CONFIRM 346 signal2 %s%s exact
QUEST_COMPLETE 303 signal () exact
QUEST_DETAIL 301 ? none
QUEST_FINISHED 304 signal () exact
QUEST_GREETING 300 signal () exact
QUEST_ITEM_UPDATE 305 signal () exact
QUEST_LOG_UPDATE 308 signal () exact
QUEST_PROGRESS 302 signal () exact
QUEST_WATCH_UPDATE 545 signal2 %d exact
RAID_ROSTER_UPDATE 499 signal () exact
RAID_TARGET_UPDATE 537 signal () exact
READY_CHECK 536 signal () exact
REPLACE_ENCHANT 403 signal2 %s%s exact
RESURRECT_REQUEST 279 signal2 %s exact
SCREENSHOT_FAILED 209 signal () exact
SCREENSHOT_SUCCEEDED 208 ? none
SEND_MAIL_COD_CHANGED 413 signal () exact
SEND_MAIL_MONEY_CHANGED 412 signal () exact
SHOW_COMPARE_TOOLTIP 377 ? none
SKILL_LINES_CHANGED 265 signal () exact
SPELLCAST_CHANNEL_START 342 signal2 %d%s exact
SPELLCAST_CHANNEL_STOP 344 signal () exact
SPELLCAST_CHANNEL_UPDATE 343 signal2 %d exact
SPELLCAST_DELAYED 341 signal2 %d exact
SPELLCAST_FAILED 339 ? none
SPELLCAST_INTERRUPTED 340 signal () exact
SPELLCAST_START 337 signal2 %s%d exact
SPELLCAST_STOP 338 signal () exact
SPELLS_CHANGED 260 signal () exact
SPELL_UPDATE_COOLDOWN 262 signal () exact
SPELL_UPDATE_USABLE 263 signal () exact
START_AUTOREPEAT_SPELL 435 signal () exact
START_LOOT_ROLL 505 signal2 %d%d exact
START_MINIGAME 534 signal () exact
STOP_AUTOREPEAT_SPELL 436 signal () exact
SYSMSG 223 ? none
TABARD_CANSAVE_CHANGED 376 signal () exact
TABARD_SAVE_PENDING 521 signal () exact
TAXIMAP_CLOSED 307 signal () exact
TAXIMAP_OPENED 306 signal () exact
TIME_PLAYED_MSG 259 signal2 %d%d exact
TRADE_ACCEPT_UPDATE 322 signal2 %d%d exact
TRADE_CLOSED 320 signal () exact
TRADE_MONEY_CHANGED 325 signal () exact
TRADE_PLAYER_ITEM_CHANGED 324 signal2 %d exact
TRADE_REPLACE_ENCHANT 404 signal2 %s%s%d advisory 0x496230 declares more varargs than it pushes — do not copy
TRADE_REQUEST 285 ? none
TRADE_REQUEST_CANCEL 286 signal () exact
TRADE_SHOW 319 signal () exact
TRADE_SKILL_CLOSE 315 signal () exact
TRADE_SKILL_SHOW 313 signal () exact
TRADE_SKILL_UPDATE 314 signal () exact
TRADE_TARGET_ITEM_CHANGED 323 signal2 %d exact
TRADE_UPDATE 321 signal () exact
TRAINER_CLOSED 311 signal () exact
TRAINER_SHOW 309 signal () exact
TRAINER_UPDATE 310 signal () exact
TUTORIAL_TRIGGER 384 signal2 %d exact
UI_ERROR_MESSAGE 224 ? none
UI_INFO_MESSAGE 225 ? none
UNIT_ATTACK 332 signal2 %s exact
UNIT_ATTACK_POWER 159|160|161 unit-field-bridge %s exact
UNIT_ATTACK_SPEED 120|121 unit-field-bridge %s exact
UNIT_AURA 41|107 token-fanout|unit-field-bridge %s exact
UNIT_CLASSIFICATION_CHANGED 187 token-fanout %s exact
UNIT_COMBAT 182 signal2 %s%s%s%d%d exact
UNIT_DAMAGE 128|129|130|131 unit-field-bridge %s exact
UNIT_DEFENSE 333 signal2 %s exact
UNIT_DISPLAYPOWER 30 token-fanout|unit-field-bridge %s exact
UNIT_DYNAMIC_FLAGS 137 unit-field-bridge %s exact
UNIT_ENERGY 20 unit-field-bridge %s exact
UNIT_FACTION 29 token-fanout|unit-field-bridge %s exact
UNIT_FLAGS 40 unit-field-bridge %s exact
UNIT_FOCUS 19 unit-field-bridge %s exact
UNIT_HAPPINESS 21 signal2|unit-field-bridge %s exact
UNIT_HEALTH 16 signal|token-fanout|unit-field-bridge %s|() exact
UNIT_INVENTORY_CHANGED 186 signal2|token-fanout %s exact
UNIT_LEVEL 28 signal2|token-fanout|unit-field-bridge %s exact
UNIT_LOYALTY 132 unit-field-bridge %s exact
UNIT_MANA 17|167|174 signal|unit-field-bridge %s|() exact
UNIT_MAXENERGY 26 unit-field-bridge %s exact
UNIT_MAXFOCUS 25 signal2|unit-field-bridge %d%d%d%d%d%d%d%d%d%d|%s exact
UNIT_MAXHAPPINESS 27 unit-field-bridge %s exact
UNIT_MAXHEALTH 22 token-fanout|unit-field-bridge %s exact
UNIT_MAXMANA 23 signal2|unit-field-bridge %s exact
UNIT_MAXRAGE 24 unit-field-bridge %s exact
UNIT_MODEL_CHANGED 185 token-fanout %s exact
UNIT_NAME_UPDATE 183 token-fanout %s exact
UNIT_PET 0|2 signal|signal2|unit-field-bridge %s|() exact
UNIT_PET_EXPERIENCE 135|136 unit-field-bridge %s exact
UNIT_PET_TRAINING_POINTS 143 unit-field-bridge %s exact
UNIT_PORTRAIT_UPDATE 184 token-fanout %s exact
UNIT_QUEST_LOG_CHANGED 522 token-fanout %s exact
UNIT_RAGE 18 signal2|unit-field-bridge %d%d|%s exact
UNIT_RANGEDDAMAGE 122|165|166 unit-field-bridge %s exact
UNIT_RANGED_ATTACK_POWER 162|163|164 signal2|unit-field-bridge %s exact
UNIT_RESISTANCES 149|150|151|152|153|154|155 unit-field-bridge %s exact
UNIT_STATS 144|145|146|147|148 unit-field-bridge %s exact
UPDATE_BATTLEFIELD_SCORE 420 signal () exact
UPDATE_BATTLEFIELD_STATUS 419 signal () exact
UPDATE_BINDINGS 386 signal () exact
UPDATE_BONUS_ACTIONBAR 217 signal () exact
UPDATE_CHAT_COLOR 226 signal2 %s%f%f%f exact
UPDATE_CHAT_WINDOWS 395 signal () exact
UPDATE_EXHAUSTION 406 signal () exact
UPDATE_FACTION 372 signal () exact
UPDATE_GM_STATUS 400 signal2 %d exact
UPDATE_INSTANCE_INFO 539 signal () exact
UPDATE_INVENTORY_ALERTS 501 signal () exact
UPDATE_LFG 389 ? none
UPDATE_LFG_LIST 532 signal () exact
UPDATE_LFG_TYPES 531 signal () exact
UPDATE_MACROS 393 signal () exact
UPDATE_MASTER_LOOT_LIST 504 signal () exact
UPDATE_MOUSEOVER_UNIT 336 signal () exact
UPDATE_PENDING_MAIL 500 signal () exact
UPDATE_SHAPESHIFT_FORMS 387 signal () exact
UPDATE_TICKET 394 signal2 %d%s|%d%s%f%f%f%d%d advisory 0x5e4527 declares more varargs than it pushes — do not copy; 0x5e7960 declares more varargs than it pushes — do not copy
UPDATE_TRADESKILL_RECAST 502 signal () exact
UPDATE_WORLD_STATES 526 signal () exact
USE_BIND_CONFIRM 290 signal () exact
VARIABLES_LOADED 430 signal () exact
WHO_LIST_UPDATE 388 signal () exact
WORLD_MAP_NAME_UPDATE 366 signal () exact
WORLD_MAP_UPDATE 365 signal () exact
ZONE_CHANGED 203 ? none
ZONE_CHANGED_INDOORS 204 ? none
ZONE_CHANGED_NEW_AREA 205 signal () exact
1 # The 1.12.1 client's FrameScript events and the ARGUMENTS their producers push.
2 # GENERATED — scripts/gen-reference-events.py. Source: wow-5875-re's
3 # re/events/event-catalog.tsv + event-firesites.tsv. Decision 2140.
4 #
5 # arg_formats `|`-separated, one per distinct producer shape. `()` = zero Lua values.
6 # Directives: %s string, %d/%u number, %f number. An event with two
7 # producers of different shapes carries both, and which one a given
8 # transition takes is a question about the transition, not the name.
9 # conf exact = gate on it. advisory = a contributing site declares more
10 # varargs than it pushes. none = no producer family reaches it HERE,
11 # which is not a claim that it has none; never gate on it.
12 # producers signal (0x703e50, no varargs) | signal2 (0x703f50, format-driven) |
13 # unit-field-bridge (0x51bbb0/0x51bd50 -> 0x515e50, always %s) |
14 # token-fanout (0x515e50 with a literal id, also %s).
15 # 394 events, 278 with a known producer; 22 glue-space fire sites dropped.
16 name ids producers arg_formats conf note
17 ACTIONBAR_HIDEGRID 211 signal () exact
18 ACTIONBAR_PAGE_CHANGED 212 signal () exact
19 ACTIONBAR_SHOWGRID 210 signal () exact
20 ACTIONBAR_SLOT_CHANGED 213 signal2 %d exact
21 ACTIONBAR_UPDATE_COOLDOWN 216 signal () exact
22 ACTIONBAR_UPDATE_STATE 214 signal () exact
23 ACTIONBAR_UPDATE_USABLE 215 signal () exact
24 ADDON_ACTION_FORBIDDEN 432 signal2 %s exact
25 ADDON_LOADED 429 signal2 %s exact
26 AREA_SPIRIT_HEALER_IN_RANGE 527 signal () exact
27 AREA_SPIRIT_HEALER_OUT_OF_RANGE 528 signal () exact
28 AUCTION_BIDDER_LIST_UPDATE 426 signal () exact
29 AUCTION_HOUSE_CLOSED 422 signal () exact
30 AUCTION_HOUSE_SHOW 421 signal () exact
31 AUCTION_ITEM_LIST_UPDATE 424 signal () exact
32 AUCTION_OWNED_LIST_UPDATE 425 signal () exact
33 AUTOEQUIP_BIND_CONFIRM 289 signal2 %d exact
34 AUTOFOLLOW_BEGIN 367 signal2 %s exact
35 AUTOFOLLOW_END 368 signal () exact
36 BAG_CLOSED 329 signal2 %d exact
37 BAG_OPEN 327 signal2 %d exact
38 BAG_UPDATE 328 signal2 %d exact
39 BAG_UPDATE_COOLDOWN 330 signal () exact
40 BANKFRAME_CLOSED 349 signal () exact
41 BANKFRAME_OPENED 348 signal () exact
42 BATTLEFIELDS_CLOSED 418 signal () exact
43 BATTLEFIELDS_SHOW 417 signal () exact
44 BILLING_NAG_DIALOG 517 signal2 %d exact
45 BIND_ENCHANT 402 signal () exact
46 CANCEL_LOOT_ROLL 506 signal2 %d exact
47 CHARACTER_POINTS_CHANGED 264 signal2 %d%d exact
48 CHAT_MSG_ADDON 227 signal2 %s%s%s%s exact
49 CHAT_MSG_AFK 249 ? none
50 CHAT_MSG_BATTLEGROUND 546 ? none
51 CHAT_MSG_BATTLEGROUND_LEADER 547 ? none
52 CHAT_MSG_BG_SYSTEM_ALLIANCE 497 ? none
53 CHAT_MSG_BG_SYSTEM_HORDE 498 ? none
54 CHAT_MSG_BG_SYSTEM_NEUTRAL 496 ? none
55 CHAT_MSG_CHANNEL 243 ? none
56 CHAT_MSG_CHANNEL_JOIN 244 ? none
57 CHAT_MSG_CHANNEL_LEAVE 245 ? none
58 CHAT_MSG_CHANNEL_LIST 246 ? none
59 CHAT_MSG_CHANNEL_NOTICE 247 ? none
60 CHAT_MSG_CHANNEL_NOTICE_USER 248 ? none
61 CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS 455 ? none
62 CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_MISSES 456 ? none
63 CHAT_MSG_COMBAT_CREATURE_VS_PARTY_HITS 453 ? none
64 CHAT_MSG_COMBAT_CREATURE_VS_PARTY_MISSES 454 ? none
65 CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS 451 ? none
66 CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES 452 ? none
67 CHAT_MSG_COMBAT_FACTION_CHANGE 533 ? none
68 CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS 447 ? none
69 CHAT_MSG_COMBAT_FRIENDLYPLAYER_MISSES 448 ? none
70 CHAT_MSG_COMBAT_FRIENDLY_DEATH 457 ? none
71 CHAT_MSG_COMBAT_HONOR_GAIN 460 ? none
72 CHAT_MSG_COMBAT_HOSTILEPLAYER_HITS 449 ? none
73 CHAT_MSG_COMBAT_HOSTILEPLAYER_MISSES 450 ? none
74 CHAT_MSG_COMBAT_HOSTILE_DEATH 458 ? none
75 CHAT_MSG_COMBAT_LOG 251 ? none
76 CHAT_MSG_COMBAT_MISC_INFO 358 ? none
77 CHAT_MSG_COMBAT_PARTY_HITS 445 ? none
78 CHAT_MSG_COMBAT_PARTY_MISSES 446 ? none
79 CHAT_MSG_COMBAT_PET_HITS 443 ? none
80 CHAT_MSG_COMBAT_PET_MISSES 444 ? none
81 CHAT_MSG_COMBAT_SELF_HITS 441 ? none
82 CHAT_MSG_COMBAT_SELF_MISSES 442 ? none
83 CHAT_MSG_COMBAT_XP_GAIN 459 ? none
84 CHAT_MSG_DND 250 ? none
85 CHAT_MSG_EMOTE 236 ? none
86 CHAT_MSG_FILTERED 544 ? none
87 CHAT_MSG_GUILD 231 ? none
88 CHAT_MSG_IGNORED 252 ? none
89 CHAT_MSG_LOOT 254 ? none
90 CHAT_MSG_MONEY 255 ? none
91 CHAT_MSG_MONSTER_EMOTE 242 ? none
92 CHAT_MSG_MONSTER_SAY 239 ? none
93 CHAT_MSG_MONSTER_WHISPER 241 ? none
94 CHAT_MSG_MONSTER_YELL 240 ? none
95 CHAT_MSG_OFFICER 232 ? none
96 CHAT_MSG_PARTY 229 ? none
97 CHAT_MSG_RAID 230 ? none
98 CHAT_MSG_RAID_BOSS_EMOTE 541 ? none
99 CHAT_MSG_RAID_LEADER 256 ? none
100 CHAT_MSG_RAID_WARNING 257 ? none
101 CHAT_MSG_SAY 228 ? none
102 CHAT_MSG_SKILL 253 ? none
103 CHAT_MSG_SPELL_AURA_GONE_OTHER 482 ? none
104 CHAT_MSG_SPELL_AURA_GONE_PARTY 481 ? none
105 CHAT_MSG_SPELL_AURA_GONE_SELF 480 ? none
106 CHAT_MSG_SPELL_BREAK_AURA 484 ? none
107 CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF 476 ? none
108 CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE 475 ? none
109 CHAT_MSG_SPELL_CREATURE_VS_PARTY_BUFF 474 ? none
110 CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE 473 ? none
111 CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF 472 ? none
112 CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE 471 ? none
113 CHAT_MSG_SPELL_DAMAGESHIELDS_ON_OTHERS 479 ? none
114 CHAT_MSG_SPELL_DAMAGESHIELDS_ON_SELF 478 ? none
115 CHAT_MSG_SPELL_FAILED_LOCALPLAYER 495 ? none
116 CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF 468 ? none
117 CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE 467 ? none
118 CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF 470 ? none
119 CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE 469 ? none
120 CHAT_MSG_SPELL_ITEM_ENCHANTMENTS 483 ? none
121 CHAT_MSG_SPELL_PARTY_BUFF 466 ? none
122 CHAT_MSG_SPELL_PARTY_DAMAGE 465 ? none
123 CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS 494 ? none
124 CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE 493 ? none
125 CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS 490 ? none
126 CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE 489 ? none
127 CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_BUFFS 492 ? none
128 CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE 491 ? none
129 CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS 488 ? none
130 CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE 487 ? none
131 CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS 486 ? none
132 CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE 485 ? none
133 CHAT_MSG_SPELL_PET_BUFF 464 ? none
134 CHAT_MSG_SPELL_PET_DAMAGE 463 ? none
135 CHAT_MSG_SPELL_SELF_BUFF 462 ? none
136 CHAT_MSG_SPELL_SELF_DAMAGE 461 ? none
137 CHAT_MSG_SPELL_TRADESKILLS 477 ? none
138 CHAT_MSG_SYSTEM 238 ? none
139 CHAT_MSG_TEXT_EMOTE 237 ? none
140 CHAT_MSG_WHISPER 234 ? none
141 CHAT_MSG_WHISPER_INFORM 235 ? none
142 CHAT_MSG_YELL 233 ? none
143 CINEMATIC_START 370 signal () exact
144 CINEMATIC_STOP 371 signal () exact
145 CLOSE_INBOX_ITEM 515 signal2 %d exact
146 CLOSE_TABARD_FRAME 375 signal () exact
147 CLOSE_WORLD_MAP 373 signal () exact
148 COMBAT_TEXT_UPDATE 542 signal2 %s|%s%s|%s%s%s exact
149 CONFIRM_BINDER 513 signal2 %s exact
150 CONFIRM_LOOT_ROLL 507 signal2 %d%d exact
151 CONFIRM_PET_UNLEARN 529 signal2 %d exact
152 CONFIRM_SUMMON 516 signal () exact
153 CONFIRM_TALENT_WIPE 512 signal2 %d exact
154 CONFIRM_XP_LOSS 396 signal2 %d exact
155 CORPSE_IN_INSTANCE 398 ? none
156 CORPSE_IN_RANGE 397 ? none
157 CORPSE_OUT_OF_RANGE 399 signal () exact
158 CRAFT_CLOSE 361 signal () exact
159 CRAFT_SHOW 359 signal () exact
160 CRAFT_UPDATE 360 signal () exact
161 CURRENT_SPELL_CAST_CHANGED 261 signal () exact
162 CURSOR_UPDATE 292 signal () exact
163 CVAR_UPDATE 312 signal2 %s%s exact
164 DELETE_ITEM_CONFIRM 291 signal2 %s%d exact
165 DISPLAY_SIZE_CHANGED 511 signal () exact
166 DUEL_FINISHED 383 signal () exact
167 DUEL_INBOUNDS 382 signal () exact
168 DUEL_OUTOFBOUNDS 381 signal () exact
169 DUEL_REQUESTED 380 signal2 %s exact
170 EQUIP_BIND_CONFIRM 288 signal2 %d exact
171 EXECUTE_CHAT_LINE 392 signal2 %s exact
172 FRIENDLIST_UPDATE 351 signal () exact
173 GMSURVEY_DISPLAY 538 signal () exact
174 GM_PLAYER_INFO 409 ? none
175 GOSSIP_CLOSED 299 signal () exact
176 GOSSIP_ENTER_CODE 298 signal2 %d exact
177 GOSSIP_SHOW 297 signal () exact
178 GUILD_INVITE_CANCEL 283 signal () exact
179 GUILD_INVITE_REQUEST 282 signal2 %s%s exact
180 GUILD_MOTD 284 signal2 %s exact
181 GUILD_REGISTRAR_CLOSED 379 signal () exact
182 GUILD_REGISTRAR_SHOW 378 signal () exact
183 GUILD_ROSTER_UPDATE 408 signal|signal2 %d|() exact
184 IGNORELIST_UPDATE 352 signal () exact
185 IGR_BILLING_NAG_DIALOG 518 signal () exact
186 INSPECT_HONOR_UPDATE 525 signal () exact
187 INSTANCE_BOOT_START 508 signal () exact
188 INSTANCE_BOOT_STOP 509 signal () exact
189 ITEM_LOCK_CHANGED 188 signal () exact
190 ITEM_PUSH 266 signal2 %d%s exact
191 ITEM_TEXT_BEGIN 293 signal () exact
192 ITEM_TEXT_CLOSED 296 signal () exact
193 ITEM_TEXT_READY 295 signal () exact
194 ITEM_TEXT_TRANSLATION 294 signal2 %f exact
195 LANGUAGE_LIST_CHANGED 258 signal () exact
196 LEARNED_SPELL_IN_TAB 510 signal2 %d exact
197 LOCALPLAYER_PET_RENAMED 331 signal () exact
198 LOGOUT_CANCEL 278 signal () exact
199 LOOT_BIND_CONFIRM 287 signal2 %u exact
200 LOOT_CLOSED 269 signal () exact
201 LOOT_OPENED 267 signal () exact
202 LOOT_SLOT_CLEARED 268 signal2 %d exact
203 LOTTERY_ITEM_UPDATE 548 signal () exact
204 LOTTERY_SHOW 543 signal () exact
205 MACRO_ACTION_FORBIDDEN 431 signal () exact
206 MAIL_CLOSED 411 signal () exact
207 MAIL_FAILED 514 signal () exact
208 MAIL_INBOX_UPDATE 416 signal () exact
209 MAIL_SEND_INFO_UPDATE 414 signal () exact
210 MAIL_SEND_SUCCESS 415 signal () exact
211 MAIL_SHOW 410 signal () exact
212 MEETINGSTONE_CHANGED 519 signal () exact
213 MEMORY_EXHAUSTED 433 signal2 %d exact
214 MEMORY_RECOVERED 434 signal () exact
215 MERCHANT_CLOSED 318 signal () exact
216 MERCHANT_SHOW 316 signal () exact
217 MERCHANT_UPDATE 317 signal () exact
218 MINIGAME_UPDATE 535 signal () exact
219 MINIMAP_PING 357 signal2 %s%f%f exact
220 MINIMAP_UPDATE_ZOOM 207 signal () exact
221 MINIMAP_ZONE_CHANGED 206 signal () exact
222 MIRROR_TIMER_PAUSE 363 signal2 %s%d exact
223 MIRROR_TIMER_START 362 signal2 %s%d%d%d%d%s exact
224 MIRROR_TIMER_STOP 364 signal2 %s exact
225 NEW_AUCTION_UPDATE 423 signal () exact
226 OPEN_MASTER_LOOT_LIST 503 signal () exact
227 OPEN_TABARD_FRAME 374 signal () exact
228 PARTY_INVITE_CANCEL 281 signal () exact
229 PARTY_INVITE_REQUEST 280 signal2 %s exact
230 PARTY_LEADER_CHANGED 219 signal () exact
231 PARTY_LOOT_METHOD_CHANGED 222 signal () exact
232 PARTY_MEMBERS_CHANGED 218 signal () exact
233 PARTY_MEMBER_DISABLE 221 signal2 %d exact
234 PARTY_MEMBER_ENABLE 220 signal2 %d exact
235 PETITION_CLOSED 391 signal () exact
236 PETITION_SHOW 390 signal () exact
237 PET_ATTACK_START 334 ? none
238 PET_ATTACK_STOP 335 signal () exact
239 PET_BAR_HIDEGRID 356 signal () exact
240 PET_BAR_SHOWGRID 355 signal () exact
241 PET_BAR_UPDATE 353 signal () exact
242 PET_BAR_UPDATE_COOLDOWN 354 signal () exact
243 PET_DISMISS_START 385 signal2 %d exact
244 PET_STABLE_CLOSED 440 signal () exact
245 PET_STABLE_SHOW 437 signal () exact
246 PET_STABLE_UPDATE 438 signal () exact
247 PET_STABLE_UPDATE_PAPERDOLL 439 signal () exact
248 PET_UI_CLOSE 428 signal () exact
249 PET_UI_UPDATE 427 signal () exact
250 PLAYERBANKBAGSLOTS_CHANGED 350 signal () exact
251 PLAYERBANKSLOTS_CHANGED 347 signal|signal2 %s|() exact
252 PLAYER_ALIVE 274 signal () exact
253 PLAYER_AURAS_CHANGED 192 signal () exact
254 PLAYER_CAMPING 276 ? none
255 PLAYER_COMBO_POINTS 202 signal () exact
256 PLAYER_CONTROL_GAINED 197 signal () exact
257 PLAYER_CONTROL_LOST 196 signal () exact
258 PLAYER_DAMAGE_DONE_MODS 201 ? none
259 PLAYER_DEAD 275 signal () exact
260 PLAYER_ENTERING_WORLD 272 signal () exact
261 PLAYER_ENTER_COMBAT 193 signal () exact
262 PLAYER_FARSIGHT_FOCUS_CHANGED 198 signal () exact
263 PLAYER_FLAGS_CHANGED 407 token-fanout %s exact
264 PLAYER_GUILD_UPDATE 345 signal|token-fanout %s|() exact
265 PLAYER_LEAVE_COMBAT 194 signal () exact
266 PLAYER_LEAVING_WORLD 273 signal () exact
267 PLAYER_LEVEL_UP 199 signal2 %d%d%d%d%d%d%d%d%d exact
268 PLAYER_LOGIN 270 signal () exact
269 PLAYER_LOGOUT 271 signal () exact
270 PLAYER_MONEY 200 signal () exact
271 PLAYER_PVP_KILLS_CHANGED 523 ? none
272 PLAYER_PVP_RANK_CHANGED 524 ? none
273 PLAYER_QUITING 277 ? none
274 PLAYER_REGEN_DISABLED 190 ? none
275 PLAYER_REGEN_ENABLED 191 signal () exact
276 PLAYER_SKINNED 520 signal2 %d exact
277 PLAYER_TARGET_CHANGED 195 signal () exact
278 PLAYER_TRADE_MONEY 326 signal () exact
279 PLAYER_UNGHOST 401 signal () exact
280 PLAYER_UPDATE_RESTING 405 signal () exact
281 PLAYER_XP_UPDATE 189 ? none
282 PLAYTIME_CHANGED 530 signal () exact
283 QUEST_ACCEPT_CONFIRM 346 signal2 %s%s exact
284 QUEST_COMPLETE 303 signal () exact
285 QUEST_DETAIL 301 ? none
286 QUEST_FINISHED 304 signal () exact
287 QUEST_GREETING 300 signal () exact
288 QUEST_ITEM_UPDATE 305 signal () exact
289 QUEST_LOG_UPDATE 308 signal () exact
290 QUEST_PROGRESS 302 signal () exact
291 QUEST_WATCH_UPDATE 545 signal2 %d exact
292 RAID_ROSTER_UPDATE 499 signal () exact
293 RAID_TARGET_UPDATE 537 signal () exact
294 READY_CHECK 536 signal () exact
295 REPLACE_ENCHANT 403 signal2 %s%s exact
296 RESURRECT_REQUEST 279 signal2 %s exact
297 SCREENSHOT_FAILED 209 signal () exact
298 SCREENSHOT_SUCCEEDED 208 ? none
299 SEND_MAIL_COD_CHANGED 413 signal () exact
300 SEND_MAIL_MONEY_CHANGED 412 signal () exact
301 SHOW_COMPARE_TOOLTIP 377 ? none
302 SKILL_LINES_CHANGED 265 signal () exact
303 SPELLCAST_CHANNEL_START 342 signal2 %d%s exact
304 SPELLCAST_CHANNEL_STOP 344 signal () exact
305 SPELLCAST_CHANNEL_UPDATE 343 signal2 %d exact
306 SPELLCAST_DELAYED 341 signal2 %d exact
307 SPELLCAST_FAILED 339 ? none
308 SPELLCAST_INTERRUPTED 340 signal () exact
309 SPELLCAST_START 337 signal2 %s%d exact
310 SPELLCAST_STOP 338 signal () exact
311 SPELLS_CHANGED 260 signal () exact
312 SPELL_UPDATE_COOLDOWN 262 signal () exact
313 SPELL_UPDATE_USABLE 263 signal () exact
314 START_AUTOREPEAT_SPELL 435 signal () exact
315 START_LOOT_ROLL 505 signal2 %d%d exact
316 START_MINIGAME 534 signal () exact
317 STOP_AUTOREPEAT_SPELL 436 signal () exact
318 SYSMSG 223 ? none
319 TABARD_CANSAVE_CHANGED 376 signal () exact
320 TABARD_SAVE_PENDING 521 signal () exact
321 TAXIMAP_CLOSED 307 signal () exact
322 TAXIMAP_OPENED 306 signal () exact
323 TIME_PLAYED_MSG 259 signal2 %d%d exact
324 TRADE_ACCEPT_UPDATE 322 signal2 %d%d exact
325 TRADE_CLOSED 320 signal () exact
326 TRADE_MONEY_CHANGED 325 signal () exact
327 TRADE_PLAYER_ITEM_CHANGED 324 signal2 %d exact
328 TRADE_REPLACE_ENCHANT 404 signal2 %s%s%d advisory 0x496230 declares more varargs than it pushes — do not copy
329 TRADE_REQUEST 285 ? none
330 TRADE_REQUEST_CANCEL 286 signal () exact
331 TRADE_SHOW 319 signal () exact
332 TRADE_SKILL_CLOSE 315 signal () exact
333 TRADE_SKILL_SHOW 313 signal () exact
334 TRADE_SKILL_UPDATE 314 signal () exact
335 TRADE_TARGET_ITEM_CHANGED 323 signal2 %d exact
336 TRADE_UPDATE 321 signal () exact
337 TRAINER_CLOSED 311 signal () exact
338 TRAINER_SHOW 309 signal () exact
339 TRAINER_UPDATE 310 signal () exact
340 TUTORIAL_TRIGGER 384 signal2 %d exact
341 UI_ERROR_MESSAGE 224 ? none
342 UI_INFO_MESSAGE 225 ? none
343 UNIT_ATTACK 332 signal2 %s exact
344 UNIT_ATTACK_POWER 159|160|161 unit-field-bridge %s exact
345 UNIT_ATTACK_SPEED 120|121 unit-field-bridge %s exact
346 UNIT_AURA 41|107 token-fanout|unit-field-bridge %s exact
347 UNIT_CLASSIFICATION_CHANGED 187 token-fanout %s exact
348 UNIT_COMBAT 182 signal2 %s%s%s%d%d exact
349 UNIT_DAMAGE 128|129|130|131 unit-field-bridge %s exact
350 UNIT_DEFENSE 333 signal2 %s exact
351 UNIT_DISPLAYPOWER 30 token-fanout|unit-field-bridge %s exact
352 UNIT_DYNAMIC_FLAGS 137 unit-field-bridge %s exact
353 UNIT_ENERGY 20 unit-field-bridge %s exact
354 UNIT_FACTION 29 token-fanout|unit-field-bridge %s exact
355 UNIT_FLAGS 40 unit-field-bridge %s exact
356 UNIT_FOCUS 19 unit-field-bridge %s exact
357 UNIT_HAPPINESS 21 signal2|unit-field-bridge %s exact
358 UNIT_HEALTH 16 signal|token-fanout|unit-field-bridge %s|() exact
359 UNIT_INVENTORY_CHANGED 186 signal2|token-fanout %s exact
360 UNIT_LEVEL 28 signal2|token-fanout|unit-field-bridge %s exact
361 UNIT_LOYALTY 132 unit-field-bridge %s exact
362 UNIT_MANA 17|167|174 signal|unit-field-bridge %s|() exact
363 UNIT_MAXENERGY 26 unit-field-bridge %s exact
364 UNIT_MAXFOCUS 25 signal2|unit-field-bridge %d%d%d%d%d%d%d%d%d%d|%s exact
365 UNIT_MAXHAPPINESS 27 unit-field-bridge %s exact
366 UNIT_MAXHEALTH 22 token-fanout|unit-field-bridge %s exact
367 UNIT_MAXMANA 23 signal2|unit-field-bridge %s exact
368 UNIT_MAXRAGE 24 unit-field-bridge %s exact
369 UNIT_MODEL_CHANGED 185 token-fanout %s exact
370 UNIT_NAME_UPDATE 183 token-fanout %s exact
371 UNIT_PET 0|2 signal|signal2|unit-field-bridge %s|() exact
372 UNIT_PET_EXPERIENCE 135|136 unit-field-bridge %s exact
373 UNIT_PET_TRAINING_POINTS 143 unit-field-bridge %s exact
374 UNIT_PORTRAIT_UPDATE 184 token-fanout %s exact
375 UNIT_QUEST_LOG_CHANGED 522 token-fanout %s exact
376 UNIT_RAGE 18 signal2|unit-field-bridge %d%d|%s exact
377 UNIT_RANGEDDAMAGE 122|165|166 unit-field-bridge %s exact
378 UNIT_RANGED_ATTACK_POWER 162|163|164 signal2|unit-field-bridge %s exact
379 UNIT_RESISTANCES 149|150|151|152|153|154|155 unit-field-bridge %s exact
380 UNIT_STATS 144|145|146|147|148 unit-field-bridge %s exact
381 UPDATE_BATTLEFIELD_SCORE 420 signal () exact
382 UPDATE_BATTLEFIELD_STATUS 419 signal () exact
383 UPDATE_BINDINGS 386 signal () exact
384 UPDATE_BONUS_ACTIONBAR 217 signal () exact
385 UPDATE_CHAT_COLOR 226 signal2 %s%f%f%f exact
386 UPDATE_CHAT_WINDOWS 395 signal () exact
387 UPDATE_EXHAUSTION 406 signal () exact
388 UPDATE_FACTION 372 signal () exact
389 UPDATE_GM_STATUS 400 signal2 %d exact
390 UPDATE_INSTANCE_INFO 539 signal () exact
391 UPDATE_INVENTORY_ALERTS 501 signal () exact
392 UPDATE_LFG 389 ? none
393 UPDATE_LFG_LIST 532 signal () exact
394 UPDATE_LFG_TYPES 531 signal () exact
395 UPDATE_MACROS 393 signal () exact
396 UPDATE_MASTER_LOOT_LIST 504 signal () exact
397 UPDATE_MOUSEOVER_UNIT 336 signal () exact
398 UPDATE_PENDING_MAIL 500 signal () exact
399 UPDATE_SHAPESHIFT_FORMS 387 signal () exact
400 UPDATE_TICKET 394 signal2 %d%s|%d%s%f%f%f%d%d advisory 0x5e4527 declares more varargs than it pushes — do not copy; 0x5e7960 declares more varargs than it pushes — do not copy
401 UPDATE_TRADESKILL_RECAST 502 signal () exact
402 UPDATE_WORLD_STATES 526 signal () exact
403 USE_BIND_CONFIRM 290 signal () exact
404 VARIABLES_LOADED 430 signal () exact
405 WHO_LIST_UPDATE 388 signal () exact
406 WORLD_MAP_NAME_UPDATE 366 signal () exact
407 WORLD_MAP_UPDATE 365 signal () exact
408 ZONE_CHANGED 203 ? none
409 ZONE_CHANGED_INDOORS 204 ? none
410 ZONE_CHANGED_NEW_AREA 205 signal () exact
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
# The 1.12.1 client's FrameScript events fired FROM INSIDE A LUA VERB, keyed by the verb.
# GENERATED — scripts/gen-reference-verb-events.py. Source: wow-5875-re's
# re/audit/binding-shapes.tsv + re/events/event-firesites.tsv + re/names/out/names-5875.tsv
# + system/ui/scratch/disasm-full.txt.
#
# shape body = a fire site inside the verb's own extent.
# helper = a fire site inside a function that ONLY registered verbs call
# (at most 8 of them); `via` is that helper. A helper with any
# non-verb caller is a state event's producer and is NOT here.
# sites the fire sites themselves, for the grep into wow-re.
# A floor, not a census: no row is not a claim of no fire (see the generator's header).
# Sites are attributed against the ledgers' verified function extents; a site past its
# candidate's extent is left out rather than mis-filed (decision 2257).
# 43 pairs over 41 verbs and 27 events; 2 site(s) past a verified extent.
verb event shape via sites
AddSkillUp SKILL_LINES_CHANGED helper 0x4d32d0 0x4d346f
CancelSkillUps SKILL_LINES_CHANGED body - 0x4d3e3a
ChangeActionBarPage ACTIONBAR_PAGE_CHANGED body - 0x4e7655
ChangeChatColor UPDATE_CHAT_COLOR body - 0x4a098b
CheckInbox MAIL_INBOX_UPDATE body - 0x4aeaf8
ClickAuctionSellItemButton NEW_AUCTION_UPDATE body - 0x4ce576
ClickSendMailItemButton MAIL_SEND_INFO_UPDATE body - 0x4ae0de
ClickTargetTradeButton TRADE_REPLACE_ENCHANT helper 0x496170 0x496230
CloseCraft CRAFT_CLOSE helper 0x4f5fe0 0x4f5fef
CloseTrade PLAYER_TRADE_MONEY body - 0x4bfdce
CloseTradeSkill TRADE_SKILL_CLOSE helper 0x4fc870 0x4fc884
CollapseCraftSkillLine CRAFT_UPDATE helper 0x4f6be0 0x4f6bf0
CollapseQuestHeader QUEST_LOG_UPDATE helper 0x4ded30 0x4ded98|0x4dedba|0x4dedd6
CollapseTradeSkillSubClass TRADE_SKILL_UPDATE helper 0x4fd750 0x4fd765
CollapseTrainerSkillLine TRAINER_UPDATE helper 0x4d8cd0 0x4d8ce0
ConfirmLootRoll CANCEL_LOOT_ROLL helper 0x61bdf0 0x61bf2a
ConfirmLootRoll CONFIRM_LOOT_ROLL helper 0x61bdf0 0x61be8b
DoTradeSkill UPDATE_TRADESKILL_RECAST helper 0x4fd770 0x4fd781
DropCursorMoney PLAYER_MONEY helper 0x494cc0 0x494d10
ExpandCraftSkillLine CRAFT_UPDATE helper 0x4f6be0 0x4f6bf0
ExpandQuestHeader QUEST_LOG_UPDATE helper 0x4ded30 0x4ded98|0x4dedba|0x4dedd6
ExpandTradeSkillSubClass TRADE_SKILL_UPDATE helper 0x4fd750 0x4fd765
ExpandTrainerSkillLine TRAINER_UPDATE helper 0x4d8cd0 0x4d8ce0
PetDismiss PET_DISMISS_START helper 0x4bd6e0 0x4bd722
PickupPlayerMoney PLAYER_MONEY helper 0x494cc0 0x494d10
PickupTradeMoney PLAYER_MONEY helper 0x494cc0 0x494d10
RemoveSkillUp SKILL_LINES_CHANGED helper 0x4d3480 0x4d3598
RollOnLoot CANCEL_LOOT_ROLL helper 0x61bdf0 0x61bf2a
RollOnLoot CONFIRM_LOOT_ROLL helper 0x61bdf0 0x61be8b
SelectGossipOption GOSSIP_ENTER_CODE helper 0x4e2320 0x4e237e
SendMail MAIL_SEND_INFO_UPDATE body - 0x4ae98d
SetBinding UPDATE_BINDINGS body - 0x4b805b
SetCVar CVAR_UPDATE body - 0x488cd5
SetGuildRecruitmentMode UPDATE_CHAT_WINDOWS body - 0x4a00a9
SetSendMailCOD SEND_MAIL_COD_CHANGED helper 0x4adc70 0x4adc88
SetSendMailMoney SEND_MAIL_MONEY_CHANGED helper 0x4adbe0 0x4adc44
SetTradeSkillInvSlotFilter TRADE_SKILL_UPDATE helper 0x4fd730 0x4fd745
SetTradeSkillSubClassFilter TRADE_SKILL_UPDATE helper 0x4fd710 0x4fd725
SetTrainerServiceTypeFilter TRAINER_UPDATE helper 0x4d8c90 0x4d8ca0
SetTrainerSkillLineFilter TRAINER_UPDATE helper 0x4d8cb0 0x4d8cc0
SortWho WHO_LIST_UPDATE body - 0x5ad9f2
TogglePetAutocast PET_BAR_UPDATE helper 0x4bcbb0 0x4bcc81
ToggleSpellAutocast PET_BAR_UPDATE helper 0x4bccb0 0x4bcdcb
1 # The 1.12.1 client's FrameScript events fired FROM INSIDE A LUA VERB, keyed by the verb.
2 # GENERATED — scripts/gen-reference-verb-events.py. Source: wow-5875-re's
3 # re/audit/binding-shapes.tsv + re/events/event-firesites.tsv + re/names/out/names-5875.tsv
4 # + system/ui/scratch/disasm-full.txt.
5 #
6 # shape body = a fire site inside the verb's own extent.
7 # helper = a fire site inside a function that ONLY registered verbs call
8 # (at most 8 of them); `via` is that helper. A helper with any
9 # non-verb caller is a state event's producer and is NOT here.
10 # sites the fire sites themselves, for the grep into wow-re.
11 # A floor, not a census: no row is not a claim of no fire (see the generator's header).
12 # Sites are attributed against the ledgers' verified function extents; a site past its
13 # candidate's extent is left out rather than mis-filed (decision 2257).
14 # 43 pairs over 41 verbs and 27 events; 2 site(s) past a verified extent.
15 verb event shape via sites
16 AddSkillUp SKILL_LINES_CHANGED helper 0x4d32d0 0x4d346f
17 CancelSkillUps SKILL_LINES_CHANGED body - 0x4d3e3a
18 ChangeActionBarPage ACTIONBAR_PAGE_CHANGED body - 0x4e7655
19 ChangeChatColor UPDATE_CHAT_COLOR body - 0x4a098b
20 CheckInbox MAIL_INBOX_UPDATE body - 0x4aeaf8
21 ClickAuctionSellItemButton NEW_AUCTION_UPDATE body - 0x4ce576
22 ClickSendMailItemButton MAIL_SEND_INFO_UPDATE body - 0x4ae0de
23 ClickTargetTradeButton TRADE_REPLACE_ENCHANT helper 0x496170 0x496230
24 CloseCraft CRAFT_CLOSE helper 0x4f5fe0 0x4f5fef
25 CloseTrade PLAYER_TRADE_MONEY body - 0x4bfdce
26 CloseTradeSkill TRADE_SKILL_CLOSE helper 0x4fc870 0x4fc884
27 CollapseCraftSkillLine CRAFT_UPDATE helper 0x4f6be0 0x4f6bf0
28 CollapseQuestHeader QUEST_LOG_UPDATE helper 0x4ded30 0x4ded98|0x4dedba|0x4dedd6
29 CollapseTradeSkillSubClass TRADE_SKILL_UPDATE helper 0x4fd750 0x4fd765
30 CollapseTrainerSkillLine TRAINER_UPDATE helper 0x4d8cd0 0x4d8ce0
31 ConfirmLootRoll CANCEL_LOOT_ROLL helper 0x61bdf0 0x61bf2a
32 ConfirmLootRoll CONFIRM_LOOT_ROLL helper 0x61bdf0 0x61be8b
33 DoTradeSkill UPDATE_TRADESKILL_RECAST helper 0x4fd770 0x4fd781
34 DropCursorMoney PLAYER_MONEY helper 0x494cc0 0x494d10
35 ExpandCraftSkillLine CRAFT_UPDATE helper 0x4f6be0 0x4f6bf0
36 ExpandQuestHeader QUEST_LOG_UPDATE helper 0x4ded30 0x4ded98|0x4dedba|0x4dedd6
37 ExpandTradeSkillSubClass TRADE_SKILL_UPDATE helper 0x4fd750 0x4fd765
38 ExpandTrainerSkillLine TRAINER_UPDATE helper 0x4d8cd0 0x4d8ce0
39 PetDismiss PET_DISMISS_START helper 0x4bd6e0 0x4bd722
40 PickupPlayerMoney PLAYER_MONEY helper 0x494cc0 0x494d10
41 PickupTradeMoney PLAYER_MONEY helper 0x494cc0 0x494d10
42 RemoveSkillUp SKILL_LINES_CHANGED helper 0x4d3480 0x4d3598
43 RollOnLoot CANCEL_LOOT_ROLL helper 0x61bdf0 0x61bf2a
44 RollOnLoot CONFIRM_LOOT_ROLL helper 0x61bdf0 0x61be8b
45 SelectGossipOption GOSSIP_ENTER_CODE helper 0x4e2320 0x4e237e
46 SendMail MAIL_SEND_INFO_UPDATE body - 0x4ae98d
47 SetBinding UPDATE_BINDINGS body - 0x4b805b
48 SetCVar CVAR_UPDATE body - 0x488cd5
49 SetGuildRecruitmentMode UPDATE_CHAT_WINDOWS body - 0x4a00a9
50 SetSendMailCOD SEND_MAIL_COD_CHANGED helper 0x4adc70 0x4adc88
51 SetSendMailMoney SEND_MAIL_MONEY_CHANGED helper 0x4adbe0 0x4adc44
52 SetTradeSkillInvSlotFilter TRADE_SKILL_UPDATE helper 0x4fd730 0x4fd745
53 SetTradeSkillSubClassFilter TRADE_SKILL_UPDATE helper 0x4fd710 0x4fd725
54 SetTrainerServiceTypeFilter TRAINER_UPDATE helper 0x4d8c90 0x4d8ca0
55 SetTrainerSkillLineFilter TRAINER_UPDATE helper 0x4d8cb0 0x4d8cc0
56 SortWho WHO_LIST_UPDATE body - 0x5ad9f2
57 TogglePetAutocast PET_BAR_UPDATE helper 0x4bcbb0 0x4bcc81
58 ToggleSpellAutocast PET_BAR_UPDATE helper 0x4bccb0 0x4bcdcb
+87
View File
@@ -0,0 +1,87 @@
# `reference/` — verified facts about the 1.12.1 client, vendored
Generated artifacts describing **the reference client**, committed so benilla can measure itself
against them on any day without a re-derivation. Not documentation, not state: data, with a
generator beside it in `scripts/`.
Nothing here is Blizzard content. A catalogue of *names* is not the thing named — the same reason
`decisions/` may quote a byte address and `MAP.md` may name a file.
## `1.12-globals.tsv`
The 1.12.1 client's entire global namespace — 21,555 names, `name<TAB>type<TAB>origin`.
**Where it comes from.** wow-5875-re 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 is the
authoritative answer to "what can an addon see in 1.12", and it is strictly better evidence than
the binary's registration table would be: it is the actual runtime table, so it carries
FrameXML-defined globals as well as engine ones — which is exactly the surface an addon sees, and
therefore exactly the surface an addon-compatibility target needs (decision 1189).
**Plus what a capture structurally cannot see** (decision 1200). The twelve `Blizzard_*` addons are
**LoadOnDemand**: unless the player had opened the talent window, the trainer, the auction house and
the rest before the dump, none of their globals is in it — so the capture alone reported
`TalentFrame`, `MacroFrame`, `TradeSkillFrame`, `CraftFrame`, `TrainerFrame` and `InspectFrame` as
absent from 1.12 while listing their `*_LoadUI` stubs. The table is therefore the capture **union**
every global the shipped-UI corpus *defines*, and those 1,986 extra rows carry type **`lod`** so
the two halves stay distinguishable. The question this artifact answers is "is this a 1.12 name?",
not "was this name live at one moment in one session".
**The `origin` column** answers a second question the capture alone cannot — *whose job is it to
provide this?* — by attributing every name to its definition site in a complete 1.12 shipped-UI
corpus:
| origin | count | meaning |
|---|---|---|
| `engine` | 1,104 | the C client provides it. **benilla implements these in Rust.** |
| `framexml` | 20,427 | the shipped UI defines it. **benilla transcribes these into `assets/ui/`.** |
| `lua` | 24 | Lua 5.0's own runtime. mlua gives us these. |
Of the `framexml` rows, **1,986 carry type `lod`**: defined by the shipped UI but absent from the
capture, which is what a LoadOnDemand addon looks like from a live dump (decision 1200).
The `framexml` bulk is UI objects — `ContainerFrame1Item16IconTexture` and its 11,000 siblings —
which materialize from whatever XML we ship and are nobody's to implement. The number that
matters for the API surface is the function split: **1,100 engine functions, 1,075 FrameXML
functions.** This is the `tContains` distinction (1187 got it right for one name) applied
wholesale: a name defined in shipped Lua is FrameXML's, and hardcoding it in Rust is a category
error even when it works.
**Regenerating** — `scripts/gen-reference-globals.py`, whose header carries the full method and
the traps. It needs the director's 1.12 install and the sibling RE repo, so it is a manual
regeneration like `genmap.sh`, not something CI can run. The artifact is stable: it describes a
client that shipped in 2006.
**Reading it** — `scripts/api-coverage.sh` is the instrument. It asks a real `UiScript::new()`
what benilla exposes and reports `have / missing / beyond-1.12`, always with 1178's split.
`crates/benilla-ui/src/script/tests/reference_surface.rs` is the gate: it fails if benilla grows a
global that 1.12 does not have and nobody wrote down why.
## `1.12-shapes.tsv` and `1.12-events.tsv`
wow-5875-re's binding-shape table (what each Lua verb takes and returns; decision 1842, gated by
`ui_script/shape_gate.rs`) and the event table with the arguments each producer pushes
(decision 2140, `scripts/gen-reference-events.py`, gated by `ui_script/event_shape_gate.rs`).
Each file's header carries its column contract.
## `1.12-verb-events.tsv`
Every event the reference fires **from inside a Lua verb**, keyed by the verb — the verb's own
body, or a helper that only registered verbs call (`0x4d8c90` is the trainer's). The third
question on the event seam: the two tables above say what an event carries and whether something
fires it; this one says *who*. A stock file that calls a verb for its side effect of an event
repaints nothing when the verb fires nothing, which is how the trainer filter regressed twelve
days after its window went stock (decision 2244), and the class every migrated window shares.
**Regenerating** — `scripts/gen-reference-verb-events.py`, whose header carries the rule, the
two families it drops (glue-space verbs, the unit-field bridge) and why getters are excluded (a
getter's fire is a cache-miss re-query, a state event benilla's feeds fire off the packet). A fire
site is attributed to a function against wow-re's verified extents (the ledgers' `size=`) plus the
disassembly's padding boundaries, and a site past its candidate's extent is left out rather than
mis-filed (decision 2257: four of the first table's rows were callbacks reached by pointer, or a
site past an extent). It reads the sibling RE repo's disassembly, so it is manual, like the others.
**Reading it** — `crates/benilla-app/src/ui_script/verb_event_gate.rs` is the gate: for every
pair whose verb benilla registers, the module that registers the verb fires the event, or the
pair is declared there — `ELSEWHERE` (a feed fires it on the state the verb changes, and the
named file is checked to) or `GAP` (nothing does, with the reason). Decision 2251.
+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