Files
benilla-octo/scripts/smoke.sh
T
Sam SungandClaude Opus 5.5 34b7c30af2 The core loads the chain's Bindings.xml and DefaultBindings.wtf, and every command runs its stock body (#587)
The core now loads the chain's `Interface\FrameXML\Bindings.xml` after the `FrameXML.toc` rows, as the reference's UI load does. The load checks the file at `0x48fff9` and loads it through the addons' loader `0x4b6f70` at `0x490018`. The default keys come from the chain's `WTF\DefaultBindings.wtf` (`0x4b62b0`). Every command now runs the stock file's own body, and the registry compiled into benilla, with its 91 retyped bodies, is deleted.

- **The loader's rules (`0x4b6f70`).** It reads the root's children only and skips empty, `debug="true"` and non-Windows rows. A second definition of a name is skipped, and duplicate headers are refused.
- **Keys and commands are separate tables, as in 1.12.** `SetBinding` never reads the command (`0x4b7490`). `RunBinding` runs the body in the VM. `RunCommand` sets `keystate` around the body and clears it after (`0x4b7c42`).
- **Engine behaviour sits behind the Lua globals the bodies call.** These are the movement, turn and mouse-steer bits. Focus loss clears the direction bits and keeps the mouse pair (`0x514490`). One key's stop clears its bit, as `0x514b70` does, so releasing W while UP is held stops the character.
- **Behaviour changes that follow the stock bodies.**
  - The nameplate keys (V, Shift-V, Ctrl-V) now run the stock bodies.
  - ALT-Z runs `CloseAllWindows(); UIParent:Hide()`.
  - The minimap, sound, stance and bonus-bar keys call their stock functions.
  - The wheel zooms one `CameraZoomIn(1.0)` per notch.
- **Platform.** benilla answers as the PC build, so a `platform="mac"` row does not register on any OS.

Verified:
- New tests: a fixture `Bindings.xml` (reordered, edited, and with debug, empty, nested and Mac rows) drives the command list, defaults and bodies. The V keys follow the stock bodies. No stock body's text remains in the tree. Every stock body's calls resolve after a production load. Each test fails with its change undone.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live run with `WOW_GM=off`:
  - W and Space moved and jumped.
  - B toggled the backpack.
  - 1 reached `UseAction`.
  - V, Shift-V and Ctrl-V behaved as the stock bodies do.

Built on macOS; no platform seam touched.

Fixes #570
Fixes #563

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:51:21 +07:00

219 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# The clean-run gate: boot the real client, log out, re-enter, walk the realm list, check the log.
#
# Needs a server and opens a window for about a minute; gates.sh points at it but does not run it.
# Logs in as the checkout's `.probe-identity`, else the shell's WOW_USER/WOW_PASS/WOW_CHAR
# (scripts/probe-identity.sh). WOW_HOST is the server (default localhost), WOW_DATA the install
# watched for writes (default the tree's WoW link), WOW_BG passes through to the client, and
# WOW_SMOKE_KEEP=1 keeps the logs.
#
# scripts/smoke.sh
set -uo pipefail
# Test $PWD's checkout when it is one of this repo, else this file's (the same rule as gates.sh).
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"
# Resolve the account first, then unset every inherited WOW_* the case below does not keep, so
# each leg runs on the switches it names; WOW_BG is kept because it only changes window stacking.
. "$root/scripts/probe-identity.sh"
probe_identity smoke "$root" || exit 1
user="$PROBE_USER"
pass="$PROBE_PASS"
char="$PROBE_CHAR"
inherited=""
for v in $(env | sed -n 's/^\(WOW_[A-Za-z0-9_]*\)=.*/\1/p'); do
case "$v" in WOW_DATA | WOW_HOST | WOW_BG | WOW_SMOKE_KEEP) continue ;; esac
unset "$v"
# The account is in $user/$pass/$char now and each leg passes what it needs by name (the realm
# leg refuses a WOW_CHAR), so it leaves the environment either way. It was only "ignored"
# when the checkout's declared identity took its place.
[ -n "$PROBE_DECLARED" ] || case "$v" in WOW_USER | WOW_PASS | WOW_CHAR) continue ;; esac
inherited="$inherited $v"
done
[ -n "$inherited" ] &&
echo "smoke: ignoring inherited env —$inherited (each leg names its own; a gate is not shell-dependent)"
# Check the server before the build: a refused connection would read as a client bug in the log.
probe_server_or_skip smoke || exit 0
log="$(mktemp "${TMPDIR:-/tmp}/benilla-smoke.XXXXXX")"
stamp="$(mktemp "${TMPDIR:-/tmp}/benilla-smoke-stamp.XXXXXX")"
before="$(mktemp "${TMPDIR:-/tmp}/benilla-smoke-before.XXXXXX")"
# One EXIT trap, so an early `fail` strands no temp file; WOW_SMOKE_KEEP spares the log.
trap 'rm -f "$stamp" "$before"; [ -n "${WOW_SMOKE_KEEP:-}" ] || rm -f "$log"' EXIT
# The install is read-only: the run must add, remove and modify nothing in it. The sorted file
# list catches additions and removals, `-newer` against the stamp catches edits in place; plain
# POSIX `find` because `stat`'s format flag differs between BSD (-f) and GNU (-c).
install_root=""
if [ -n "${WOW_DATA:-}" ]; then
install_root="$WOW_DATA"
elif [ -d "$root/WoW" ]; then
install_root="$root/WoW"
fi
# A running reference client (wow.exe) writes its own WTF files into a shared install, and this
# watch cannot tell those from benilla's; note it so the verdict can say so.
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 before the timed run: the timeout bounds the client, and a cold build takes minutes.
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 client opens no audio device (`sound/mod.rs`), so the run is silent.
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 colour-wrapped, and the checks below match plain text.
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
}
# Before the timeout check, which it usually explains: a WOW_CHAR not on the account leaves the
# client waiting at the roster.
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 re-entered character must be drivable: the leg lists what would block movement (a /logout
# root must end with its session), and anything but `none` fails.
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>} \
(everything the ended session granted its mover dies with it)"
# vmangos skips the logout root when resting, on a taxi or at account security >= `InstantLogout`
# (`MiscHandler.cpp`, `CMSG_LOGOUT_REQUEST`); probe accounts are GM, so report whether the check
# above met a real root. `player::wire_in::session_end_tests` forces the rooted case.
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), so the check above did not run"
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)"
# A marker counts once per edge it fires on. Each world entry builds a world VM beside the
# character screen's glue VM (the reference's `0x490bd0`/`0x48fbf0` pair), so the VM marker counts
# twice per login; half that means an entry adopted the glue VM and its spent `VmMemo`s.
sessions=2 # world entries in this walk
vms=$((sessions * 2)) # …and the Lua states they cost: a glue VM and a world VM each
for marker in "new VM, session"; do
n="$(printf '%s\n' "$plain" | grep -cF "$marker")"
printf ' %-24s %s\n' "$marker" "$n"
[ "$n" -eq "$vms" ] ||
fail "'$marker' happened $n time(s), expected $vms — one per VM built, two per login; \
$sessions would mean the entry adopted the character screen's VM and inherited its spent VmMemos"
done
# The in-game UI, `FrameXML.toc`'s `Fonts.xml` included, and the key-binding commands (the core's
# `Bindings.xml`, loaded after the toc) build once per login; one count for two logins means the
# second reused the first's frame tree.
for marker in "Fonts.xml loaded" "UIParent.xml loaded" "commands registered"; do
n="$(printf '%s\n' "$plain" | grep -cF "$marker")"
printf ' %-24s %s\n' "$marker" "$n"
[ "$n" -eq "$sessions" ] ||
fail "'$marker' happened $n time(s), expected $sessions — once per login: the UI and its binding commands rebuilt per login"
done
# The shutdown tail runs once per session: at the /logout, and at the exit, which closes the window
# as a player does. Counted off the saved-variables line, written only when the UI loaded.
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 (the quit root must be observed in \`Last\`, after PostUpdate's exit_on_all_closed)"
# The read-only verdict names each changed file, the whole lead to whatever wrote it.
if [ -n "$install_root" ]; then
after="$(mktemp "${TMPDIR:-/tmp}/benilla-smoke-after.XXXXXX")"
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."
echo " Every file benilla persists goes through \`crate::local_state\` into"
echo " \`benilla-config/\` beside the binary."
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"
else
echo " install not watched (no WoW link and no WOW_DATA — the read-only rule went unmeasured)"
fi
# ── The realm-list leg ───────────────────────────────────────────────────────────────────────
# The realm list is a dialog over character select that the IO thread serves from the character
# park; this drives Change Realm, Okay, Change Realm, Cancel, Okay against the real server and
# fails anything but a completed walk.
echo "smoke: running the realm-list boundary walk (~12 s, opens a window)…"
rlog="$(mktemp "${TMPDIR:-/tmp}/benilla-smoke-realm.XXXXXX")"
# No WOW_CHAR: the walk drives character select, and `realm_select::smoke` refuses a seated body.
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$([ -n "$install_root" ] && echo ', install untouched')"
[ -n "${WOW_SMOKE_KEEP:-}" ] && echo "log: $log"
exit 0