Integrate upstream through 746ad351 with the ruRU community pack

Keep MoonWell branding and deployment support on the stock glue screens
and Rust Lua VM. Preserve locale overlays, Cyrillic names and fonts,
localized spell costs and race/class labels through the new APIs.

Verified on Windows: formatting and the Russian locale/name policy unit
check in both VM kinds. Workspace clippy passed; complete real-data,
no-install, player and engine gates are pending before landing.
This commit is contained in:
2026-10-17 17:33:14 +04:00
1078 changed files with 190467 additions and 66684 deletions
+6 -6
View File
@@ -5,15 +5,15 @@
# 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
#
# The 1.12 side is `reference/1.12-globals.tsv`, the reference client's in-world `_G`
# (scripts/gen-reference-globals.py); ours is a real `UiScript::new()` dumped through `pairs(_G)`.
# The 1.12 side is `reference/lua_globals.tsv`, the reference client's in-world `_G`; ours is a
# real `UiScript::new()` dumped through `pairs(_G)`.
# Never quote the percentage alone: a missing global is an unbuilt feature or a hole in a shipped
# one, and a global beyond 1.12 sends a feature-detecting addon down a path we cannot honour.
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; }
REF="reference/lua_globals.tsv"
[ -f "$REF" ] || { echo "no $REF — run scripts/sync-reference.py" >&2; exit 1; }
show_missing=0
show_beyond=0
@@ -36,7 +36,7 @@ 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
if (FNR == 1) next # the header row
origin[$1] = $3
n_ref[$3]++
if ($2 == "function") n_ref_fn[$3]++
@@ -67,7 +67,7 @@ awk -F'\t' -v ref="$REF" -v show_missing="$show_missing" -v show_beyond="$show_b
n_ref_fn["engine"], n_ref["engine"] - n_ref_fn["engine"]
printf " framexml %5d functions, %5d other <- the stock FrameXML defines these\n", \
n_ref_fn["framexml"], n_ref["framexml"] - n_ref_fn["framexml"]
printf " lua %5d functions, %5d other <- mlua provides these\n", \
printf " lua %5d functions, %5d other <- benilla-lua 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
+6
View File
@@ -102,6 +102,8 @@ run() {
local name="$1"; shift
if ! "$@" >"$log" 2>&1; then
echo "CHECK FAILED: $name"
# The failing test or error first: a test gate's tail is the doc-test noise after it.
grep -E 'panicked at|^---- .* ----$|^test .* FAILED$|^error(\[E[0-9]+\])?:' "$log" | head -40
tail -30 "$log"
exit 1
fi
@@ -110,6 +112,10 @@ run() {
run fmt cargo fmt --all -- --check
run clippy cargo clippy $pflags --all-targets -- -D warnings
# The player build (no `dev` feature) is linted on its own: the run above sees only the dev set.
case " $pkgs " in *" benilla-app "* | *" benilla "*)
run player-clippy cargo clippy -p benilla -p benilla-app --no-default-features -- -D warnings ;;
esac
# --no-fail-fast: one red test binary does not hide the failures in the ones after it.
run test env BENILLA_SKIP_LOG="$skips" cargo test --no-fail-fast $pflags
# Data-gated tests skip without the install or the addon corpus and libtest swallows their line,
+2 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""Fail on a doc link whose target is declared nowhere in the workspace.
It reads rustdoc's unresolved-link warnings and fails on a `crate::`, `super::`, `self::` or
It runs `cargo doc` over the whole workspace (a full doc build, minutes on a cold tree) and reads
rustdoc's unresolved-link warnings and fails on a `crate::`, `super::`, `self::` or
`Self::` path whose leaf name is declared nowhere: the item is gone. Every other warning is a
convention here, not rot (a private module named through its parent, another crate's module
named with `crate::`, a bare name for an unimported bevy type).
+44 -7
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env bash
# The land gate chain: every gate in order, stopping at the first failure.
#
# fmt, clippy, test, test-no-install, doc-links, pass-span-lint, player-tests, enforcer,
# enforcer-no-install. A failure prints the gate's log tail and exits nonzero; a green
# run stamps the tree, which then re-greens without running. GATES_FORCE=1 runs the chain anyway,
# fmt, clippy, reference, test, test-no-install, vm-parity, doc-links, message-catalog,
# pass-span-lint, player-clippy, player-tests, enforcer, enforcer-no-install. A failure prints the
# gate's log tail and exits nonzero; a green run stamps the tree, which then re-greens without
# running. GATES_FORCE=1 runs the chain anyway,
# WOW_DATA picks the install (set and empty: none) and TMPDIR holds the scratch files; the script
# sets BENILLA_REQUIRE_DATA, BENILLA_ADDON_CORPUS, BENILLA_SKIP_LOG and WOW_WORLDVIEW_CHECK for
# its gates.
# its gates. BENILLA_RE names the benilla-re checkout the reference gate compares `reference/` with.
# `.github/workflows/ci.yml` runs the gates that need neither the install nor a display on a fork's
# pull request: a gate added here goes there too, if it can run on a bare Linux runner.
#
@@ -41,11 +42,12 @@ tree_key() {
"$(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 delta only under docs/ or in top-level *.md files keeps the stamped verdict, since no gate
# reads those paths; a gate that starts to read one must narrow this pattern.
# A delta only under docs/, in top-level *.md files or in the issue and PR templates keeps the
# stamped verdict, since no gate reads those paths; a gate that starts to read one must narrow
# this pattern.
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)" ]
[ -z "$(git diff-tree -r --name-only "$1" "$2" 2>/dev/null | grep -vE '^(docs/|[^/]*\.md$|\.github/(ISSUE_TEMPLATE/|pull_request_template\.md$))' || true)" ]
}
key_start="$(tree_key || true)"
if [ "${GATES_FORCE:-}" != "1" ] && [ -n "$key_start" ] && [ -f "$stamp" ]; then
@@ -98,6 +100,8 @@ run() {
if ! "$@" >"$log" 2>&1; then
echo "GATE FAILED: $name ($((SECONDS - t0)) s)"
note_timing "$name" "$((SECONDS - t0))" "FAILED"
# The failing test or error first: a test gate's tail is the doc-test noise after it.
grep -E 'panicked at|^---- .* ----$|^test .* FAILED$|^error(\[E[0-9]+\])?:' "$log" | head -40
tail -30 "$log"
exit 1
fi
@@ -120,6 +124,29 @@ else
"corpus=$([ -d "$root/wow-addons-vanilla" ] && echo yes || echo no)):" \
"data-gated tests skip silently on this machine"
fi
# reference: `reference/` is byte for byte benilla-re's tables at `reference/PIN`. It needs a
# benilla-re checkout ($BENILLA_RE, else `../benilla-re` beside the repo, else beside the main
# checkout when this is a linked worktree) and skips with a line where there is none, unless data
# is required (BENILLA_REQUIRE_DATA, set above where the install and corpus are, or by the caller).
re_checkout=""
primary="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd || true)"
if [ -n "${BENILLA_RE:-}" ] && [ ! -d "$BENILLA_RE/tables" ]; then
echo "GATE FAILED: reference (BENILLA_RE=$BENILLA_RE holds no benilla-re checkout)"
exit 1
fi
for c in "${BENILLA_RE:-}" "$root/../benilla-re" "${primary:+$primary/../benilla-re}"; do
if [ -n "$c" ] && [ -d "$c/tables" ]; then re_checkout="$c"; break; fi
done
if [ -n "$re_checkout" ]; then
run reference scripts/sync-reference.py "$re_checkout" --check
elif [ -n "$require_data" ] || [ "${BENILLA_REQUIRE_DATA:-}" = "1" ]; then
echo "GATE FAILED: reference (no benilla-re checkout: set BENILLA_RE, or clone it beside the repo)"
exit 1
else
echo "gate SKIPPED: reference (no benilla-re checkout found: BENILLA_RE, ../benilla-re)"
note_timing reference 0 SKIPPED
fi
run test env ${require_data:+BENILLA_REQUIRE_DATA=1} BENILLA_SKIP_LOG="$skips" cargo test --no-fail-fast --workspace
report_skips test
@@ -131,10 +158,20 @@ run test-no-install env -u BENILLA_REQUIRE_DATA WOW_DATA= cargo test --no-fail-f
# declared nowhere in the workspace. Narrow on purpose: every other unresolved link is a convention.
run doc-links scripts/doc-links.py
# message-catalog: `crates/benilla-ui/src/messages/catalog.rs` is what the vendored messages table
# generates, byte for byte.
run message-catalog scripts/gen-message-catalog.py --check
# pass-span-lint: refuses a second `pass_span` on a render pass whose span is still open, a fatal
# wgpu validation error on Vulkan and a silent no-op on Metal and DX12, which no macOS run sees.
run pass-span-lint scripts/pass-span-lint.py
# player-clippy: the player build's own lints, which no other gate sees. `clippy` runs the dev
# feature set and player-tests compiles under `cfg(test)`, where test code uses imports and items
# the shipped library does not; this lints the shipped library and the launcher, with no
# `--all-targets`, so a warning confined to the player graph fails here.
run player-clippy cargo clippy -p benilla -p benilla-app --no-default-features -- -D warnings
# player-tests: the player build, `benilla-app` without `dev`, which compiles out the debug panel,
# perf HUD, inspector, capture harness and probes, so it fails when any other code names one of
# them. Its test build compiles every line of the player graph but the `benilla` launcher, a shim
+30 -16
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
r"""Generate `crates/benilla-ui/src/messages/catalog.rs` from `reference/1.12-message-catalog.tsv`.
r"""Generate `crates/benilla-ui/src/messages/catalog.rs` from `reference/messages.tsv`.
The table is the reference's message registry at `0xb4b498`, which `CGGameUI::DisplayError`
`0x496720` indexes by message id; this re-shapes it into a Rust static that ships with benilla.
python3 scripts/gen-message-catalog.py # the vendored table -> the .rs, in place
python3 scripts/gen-message-catalog.py --check # fail when the .rs is not what it generates
python3 scripts/gen-message-catalog.py <tsv> # another table
It refuses rather than emits on a non-dense id range, a duplicate key or an unknown kind.
@@ -13,7 +14,7 @@ import os
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TABLE = os.path.join(REPO, "reference", "1.12-message-catalog.tsv")
TABLE = os.path.join(REPO, "reference", "messages.tsv")
OUT = os.path.join(REPO, "crates", "benilla-ui", "src", "messages", "catalog.rs")
KINDS = {0: "Chat", 1: "Info", 2: "Error"}
@@ -21,7 +22,7 @@ KINDS = {0: "Chat", 1: "Info", 2: "Error"}
HEADER = """\
//! The 1.12 client's message catalog, row for row.
//!
//! @generated by `scripts/gen-message-catalog.py` from `reference/1.12-message-catalog.tsv`.
//! @generated by `scripts/gen-message-catalog.py` from `reference/messages.tsv`.
//! Do not edit by hand; regenerate.
use super::{MessageRecord, MsgKind};
@@ -35,24 +36,24 @@ pub static CATALOG: [MessageRecord; {count}] = [
"""
def main(path: str) -> int:
def main(path: str, check: bool = False) -> int:
if not os.path.exists(path):
raise SystemExit(f"no table at {path}")
rows = []
for line in open(path):
if line.startswith("#") or line.startswith("id_hex"):
if line.startswith("id\t"):
continue
f = line.rstrip("\n").split("\t")
if len(f) != 8:
raise SystemExit(f"malformed row (expected 8 fields, got {len(f)}): {line!r}")
if len(f) != 7:
raise SystemExit(f"malformed row (expected 7 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]),
"id": int(f[0]),
"key": f[2],
"kind": int(f[3]),
"sound": f[4],
"type_tag": int(f[5], 16),
"chat_type": int(f[6]),
}
)
@@ -77,13 +78,26 @@ def main(path: str) -> int:
f'type_tag: {r["type_tag"]:#04x}, chat_type: {r["chat_type"]} }},\n'
)
out.append("];\n")
text = "".join(out)
if check:
have = open(OUT).read() if os.path.exists(OUT) else None
if have != text:
raise SystemExit(
f"{os.path.relpath(OUT, REPO)} is not what {os.path.relpath(path, REPO)} "
"generates; run scripts/gen-message-catalog.py"
)
print(f"{os.path.relpath(OUT, REPO)} matches its table ({len(rows)} rows)")
return 0
with open(OUT, "w") as f:
f.write("".join(out))
f.write(text)
print(f"wrote {os.path.relpath(OUT, REPO)} ({len(rows)} rows)")
return 0
if __name__ == "__main__":
if len(sys.argv) > 2:
args = sys.argv[1:]
check = "--check" in args
args = [a for a in args if a != "--check"]
if len(args) > 1 or (check and args):
raise SystemExit(__doc__)
sys.exit(main(sys.argv[1] if len(sys.argv) == 2 else TABLE))
sys.exit(main(args[0] if args else TABLE, check))
-164
View File
@@ -1,164 +0,0 @@
#!/usr/bin/env python3
"""Regenerate `reference/1.12-events.tsv`: each 1.12 FrameScript event and the arguments it carries.
scripts/gen-reference-events.py [--catalog FILE] [--firesites FILE] [--out FILE]
The inputs are vendored under `reference/`, so this runs from any clone and reproduces the
committed table byte for byte:
- `1.12-event-catalog.tsv` eventId -> name, off the name-pointer array at `.data 0xbe1198`
- `1.12-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
A literal fire site is not every producer; the families are:
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 named event id below
`0xb6` is produced there and has no fire-site row.
token-fanout the same `0x515e50` fan-out reached with a literal id in `edx`: also `%s`,
also absent from the fire sites.
Confidence, and what a consumer may gate on:
exact every contributing producer's shape is known. Gate on this.
advisory a contributing site's format declares more varargs than its caller pushes
(`0x496230` TRADE_REPLACE_ENCHANT, `0x5e4527`/`0x5e7960` UPDATE_TICKET), which hands
Lua undefined values; do not copy it.
none no producer family reaches the name here, which does not mean it has none. Never
gate on it.
Glue-space sites are dropped by address: `0x703d90` loads a separate name table for the glue
screen, so the same id names a different event there. Their names are blank in the input, and
dropping by address keeps a name filled in later from leaking through.
"""
import argparse
import os
import sys
# The `Source\Glue\` fire-site ranges (`1.12-event-firesites.tsv`'s header): glue-space ids.
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 with 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 = "()"
REFERENCE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "reference"))
def rows(path):
if not os.path.exists(path):
sys.exit(f"no table at {path}")
for line in open(path, encoding="utf-8"):
if line.startswith("#"):
continue
f = line.rstrip("\n").split("\t")
if f and f[0] in ("eventId", "fire_site_va"):
continue
yield f
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--catalog", default=os.path.join(REFERENCE, "1.12-event-catalog.tsv"))
ap.add_argument("--firesites", default=os.path.join(REFERENCE, "1.12-event-firesites.tsv"))
ap.add_argument("--out", default=os.path.join(REFERENCE, "1.12-events.tsv"))
a = ap.parse_args()
catalog = {}
for f in rows(a.catalog):
if len(f) >= 2 and f[1]:
catalog.setdefault(int(f[0]), f[1])
if len(catalog) < 300:
sys.exit(f"{a.catalog} 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(a.firesites):
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 too, so "no producer found" differs 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 by scripts/gen-reference-events.py from reference/1.12-event-catalog.tsv\n"
"# and reference/1.12-event-firesites.tsv.\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; which one fires depends\n"
"# on 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 does not mean 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()
-281
View File
@@ -1,281 +0,0 @@
#!/usr/bin/env python3
"""Regenerate `reference/1.12-globals.tsv`: the 1.12.1 client's global namespace, by origin.
scripts/gen-reference-globals.py --capture FILE --framexml DIR [--data DIR] [--drop NAME]...
[--out FILE]
Each name in a capture of the running reference client's in-world `_G` gets one origin:
lua Lua 5.0's own runtime, which mlua provides.
engine the C client provides it; benilla implements these in Rust.
framexml the shipped UI defines it (its Lua, its XML, or a `$parent`-composed child of one);
it runs off the player's own patch chain, never hardcoded in Rust.
Attribution is by definition site in the complete 1.12 shipped-UI corpus:
- a name assigned or `function`-declared in shipped Lua, including Lua inside a FrameXML
document, is FrameXML's; indented assignments count, because FrameXML leaks globals out of
function bodies (`button = getglobal(...)`);
- a `name="..."` on any shipped XML element is FrameXML's, `virtual="true"` included: a virtual
`<Font>` is a real font object, and 1.12 registers named virtual frames too;
- `<a shipped name><a $parent suffix>` is a composed child (`ContainerFrame1Item16IconTexture`);
- LUA_5_0 wins over all of these: FrameXML's `string = getglobal(...)` would otherwise claim
the stdlib table.
The capture (`--capture`, with `--drop` naming the capturing addon's own globals) and the
extracted FrameXML (`--framexml`) are the maintainer's, not in this repo; the MPQs are the
install's (`--data`, else `$WOW_DATA`, else `WoW/Data`). FrameXML alone is not the shipped UI:
the twelve `Blizzard_*` addons live in the MPQs, and their `.lua` is reached through
`<Script file=>` in their XML, not their `.toc`. A complete corpus is 233 files; the count is
printed.
"""
import argparse
import os
import re
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# The twelve addons shipped inside the MPQs; an install's folder for each holds only a `.pub`.
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 globals as the 1.12 client exposes them: it removes `_G`, `print`, `require`,
# `dofile`, `loadfile` and the `io`/`os`/`debug`/`coroutine` tables.
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",
}
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 inside a FrameXML document: an inline `<Script>` body (Fonts.xml's CHAT_FONT_HEIGHTS) and
# every `<Scripts>` handler element (`<OnLoad>`, `<PreClick>`, ...), matched by shape so no handler
# name is missed. `<Script file=>` is skipped: its file is 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, as `loader::join_ref` does."""
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(data, framexml):
"""Every file of the 1.12 shipped UI, as {corpus-relative path: text}."""
archives = [os.path.join(data, f"{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 = 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 addons' `.lua` is reached only from their XML, never from the `.toc`.
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, not a global.
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("--capture", help="a capture of the running client's in-world _G")
ap.add_argument("--framexml", help="the shipped FrameXML, extracted to a folder")
ap.add_argument("--data", help="the install's Data folder, which holds the MPQs",
default=os.environ.get("WOW_DATA") or os.path.join(REPO, "WoW", "Data"))
# The capturing addon's own globals (its holder frame, SavedVariables and slash token) are the
# instrument, not the client. `__framescript_meta` stays in: it is the client's own shared
# frame metatable.
ap.add_argument("--drop", action="append", default=[], help="a global of the capturing addon")
ap.add_argument("--out", default=os.path.join(REPO, "reference", "1.12-globals.tsv"))
args = ap.parse_args()
if not args.capture or not args.framexml:
sys.exit(
"this table is derived from a runtime capture of the reference client's _G and the "
"extracted FrameXML, which are not in this repo: pass --capture and --framexml. The "
"committed reference/1.12-globals.tsv is the surface benilla tracks."
)
if not os.path.exists(args.capture):
sys.exit(f"no capture at {args.capture}")
print("building the 1.12 shipped-UI corpus...")
corpus = build_corpus(args.data, args.framexml)
print(f" TOTAL: {len(corpus)} files" + ("" if len(corpus) == 233 else " ** expected 233 **"))
recs = [(n, t) for n, t in captured_globals(args.capture) if n not in set(args.drop)]
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 names ─────────────────────────────────────────────────────────────────
# The twelve `Blizzard_*` addons are LoadOnDemand, so a live capture holds their globals only
# if their windows were opened first. Every name the shipped UI defines that the capture lacks
# joins with type `lod`: the table answers "is this a 1.12 name?", and the halves stay apart.
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 by scripts/gen-reference-globals.py.\n")
f.write("# Source: a capture of the running client's in-world _G (the maintainer's),\n")
f.write("# attributed against the complete 1.12 shipped-UI corpus.\n")
f.write("# Also every name that corpus defines but the capture lacks, as type `lod`: the\n")
f.write("# twelve Blizzard_* addons are LoadOnDemand, so a live dump misses them unless\n")
f.write("# their windows were opened.\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()
-229
View File
@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""Regenerate `reference/1.12-verb-events.tsv`: the events 1.12 Lua verbs fire, keyed by verb.
scripts/gen-reference-verb-events.py --names FILE --extents FILE... --disasm FILE [--out FILE]
A row says the reference fires `event` on the call path of the registered Lua binding `verb`:
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) fires a state event and is
left out, and so is a hub (`ClearCursor` -> CURSOR_UPDATE, 49 callers).
A site's function is the greatest start at or below it, over the union of: every function the
analysis sizes, every named function start, every `call` target in the client's disassembly,
every registered verb, and every address after a padding run (an `int3` run anywhere, or a `nop`
run after a `ret`; a `nop` run after a `jmp` is a loop head, not a boundary). A site at or past
`start + size` of a sized function is left unattributed rather than handed to it.
Two families are dropped: glue-space verbs and sites (the GlueXML registrar, 0x46a000..0x476000)
and the unit-field bridge and token fan-out ids below 0xb6, which are state events.
A floor, not a census: a verb with no row may still fire an event, because the fire sites are
only the two signal helpers' literal call sites and `shape=helper` stops one call deep.
The fire sites and the binding shapes are the vendored `reference/` tables; the function names
(`--names`), extents (`--extents`, the `size=` on each `fn` row) and disassembly (`--disasm`) are
the maintainer's analysis, not in this repo, so this is a manual regeneration.
"""
import argparse
import bisect
import collections
import os
import re
import sys
# The GlueXML registrar's address range: an id fired there names a glue-screen event.
GLUE = (0x46A000, 0x476000)
# `0x51bbb0`'s walk bound: every named unit-window field below it fires through the generic bridge.
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+)")
REFERENCE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "reference"))
def rows(path):
if not os.path.exists(path):
sys.exit(f"no table at {path}")
for line in open(path, encoding="utf-8"):
if line.startswith("#"):
continue
f = line.rstrip("\n").split("\t")
if f and f[0] in ("name", "addr", "eventId", "fire_site_va"):
continue
yield f
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--names", help="the function names: `addr<TAB>kind` rows, `func` a start")
ap.add_argument("--extents", nargs="+", default=[], help="tables whose `fn` rows carry size=")
ap.add_argument("--disasm", help="the client's disassembly, one objdump line per instruction")
ap.add_argument("--out", default=os.path.join(REFERENCE, "1.12-verb-events.tsv"))
a = ap.parse_args()
if not (a.names and a.extents and a.disasm):
sys.exit(
"this table is derived from the maintainer's function names, extents and disassembly, "
"which are not in this repo: pass --names, --extents and --disasm. The committed "
"reference/1.12-verb-events.tsv is the surface benilla tracks."
)
starts = set()
for f in rows(a.names):
if len(f) >= 2 and f[1] == "func":
starts.add(int(f[0], 16))
if len(starts) < 1000:
sys.exit(f"{a.names} gave only {len(starts)} functions — wrong file?")
# Function extents: `size=` on the `fn` rows of the extent tables.
extent = {} # fn start -> size in bytes
for path in a.extents:
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 extent tables gave only {len(extent)} sized functions — wrong files?")
starts |= set(extent)
verbs = {} # fn address -> name
for f in rows(os.path.join(REFERENCE, "1.12-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's fire is a cache-miss re-query announced when the answer lands
# (`GetInboxText`, `GetAuctionItemInfo`): a state event, fired by benilla's feeds.
continue
verbs.setdefault(fn, f[0])
if len(verbs) < 1000:
sys.exit(f"1.12-shapes.tsv gave only {len(verbs)} verbs — wrong path?")
starts |= set(verbs)
sites = [] # (site va, event name)
for f in rows(os.path.join(REFERENCE, "1.12-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"1.12-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, or a `nop` run right after a `ret`). A `nop` run after a `jmp` is a loop head's
# alignment inside a function, not a start.
edges = []
after_pad = False
ended = False # the previous real instruction was a `ret`
n_lines = 0
disasm = a.disasm
if not os.path.exists(disasm):
sys.exit(f"no disassembly at {disasm}")
for line in open(disasm, 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} gave only {n_lines} lines — wrong file?")
starts |= {t for _, k, t in edges if k == "call"}
ordered = sorted(starts)
def enclosing(va):
"""The function holding `va`; None when `va` lies past its candidate's 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 by scripts/gen-reference-verb-events.py from reference/1.12-shapes.tsv,\n"
"# reference/1.12-event-firesites.tsv and the maintainer's analysis.\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 produces a state event and is not here.\n"
"# sites the fire sites themselves.\n"
"# A floor, not a census: a verb with no row may still fire an event.\n"
"# A site past its candidate function's extent is left out rather than mis-filed.\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()
+305 -3
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env bash
# Generate docs/MAP.md, the map of what is built, from what is on disk.
# Generate docs/MAP.md, the map of what is built, and docs/DEVIATIONS.md, where benilla differs from
# 1.12.1, both from what is on disk.
#
# Regenerated and committed at every land; run it by hand only to look, never edit the output. No
# timestamps, so an identical tree gives an identical map. No `pipefail`/`-e`: a grep that finds
# nothing (a single-file lib, a crate with no bins) must not abort the generator.
# timestamps, so an identical tree gives an identical file. No `pipefail`/`-e`: a grep that finds
# nothing (a single-file lib, a crate with no bins) must not abort the generator. Bash and POSIX
# awk only (macOS's awk is the floor): no gawk extensions, no new dependencies.
set -u
cd "$(dirname "$0")/.."
@@ -123,3 +125,303 @@ field() { grep -m1 "^$2" "$1" 2>/dev/null | sed -E "s/^$2[[:space:]]*=[[:space:]
} > docs/MAP.md
echo "genmap: wrote docs/MAP.md"
# ── docs/DEVIATIONS.md ─────────────────────────────────────────────────────────────────────────────
# Every place benilla writes down that it differs from 1.12.1: the `Deviates` rows of the CVar table,
# and each `Deviation:` comment in the crates (Rust and WGSL `//`, Lua `--`, XML `<!-- -->`). A
# deviation's wording is read from where it lives, so the list cannot drift from the code.
tmp=$(mktemp -d "${TMPDIR:-/tmp}/genmap.XXXXXX") || exit 1
trap 'rm -rf "$tmp"' EXIT
# Test code is left out: a file under `tests/`, named `tests.rs` or `*_tests.rs`, or declared by a
# `#[cfg(test)] mod x;` (listed here, resolved the way rustc does, `#[path]` included), and a
# `#[cfg(test)] mod x { … }` body (skipped in the extractor below, which closes it at the first `}`
# in the module's own column; rustfmt keeps it there).
grep -rlE --include='*.rs' 'cfg\(test\)' crates 2>/dev/null | LC_ALL=C sort | xargs awk '
FNR == 1 { pend = 0; path = ""; base = FILENAME; sub(/[^\/]*$/, "", base)
stem = FILENAME; sub(/.*\//, "", stem); sub(/\.rs$/, "", stem)
own = (stem == "mod" || stem == "lib" || stem == "main") ? base : base stem "/" }
{ t = $0; sub(/^[ \t]+/, "", t) }
t == "#[cfg(test)]" { pend = 1; path = ""; next }
pend && t ~ /^#\[path = "/ { path = t; sub(/^#\[path = "/, "", path); sub(/".*/, "", path); next }
pend && t ~ /^#\[/ { next }
pend {
pend = 0
if (t ~ /^(pub(\([a-z]+\))? )?mod [A-Za-z_0-9]+;/) {
name = t; sub(/^(pub(\([a-z]+\))? )?mod /, "", name); sub(/;.*/, "", name)
if (path != "") print base path
else { print own name ".rs"; print own name "/mod.rs" }
}
}' > "$tmp/testmods" 2>/dev/null
# Stage 1: one TSV row per `Deviation:` comment: crate, module, path in the crate, line, sentence.
LC_ALL=C grep -rIlE 'Deviation:' crates 2>/dev/null | LC_ALL=C sort | grep -vE '/tests/|(^|/)tests\.rs$|_tests\.rs$' \
| xargs env LC_ALL=C awk -v list="$tmp/testmods" '
function indent_of(s, t) { t = s; sub(/[^ \t].*$/, "", t); return length(t) }
function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
# The first sentence of a joined comment: to the first `.`/`?`/`!` that is followed by an
# uppercase word or the end, outside backticks and parentheses, and not an abbreviation.
function first_sentence(t, len, k, ch, tick, par, nx, rest, pre) {
len = length(t); tick = 0; par = 0
for (k = 1; k <= len; k++) {
ch = substr(t, k, 1)
if (ch == "`") { tick = !tick; continue }
if (tick) continue
if (ch == "(") par++
else if (ch == ")") { if (par > 0) par-- }
else if ((ch == "." || ch == "?" || ch == "!") && par == 0) {
nx = substr(t, k + 1, 1)
if (nx == "") return substr(t, 1, k)
if (nx != " ") continue
rest = substr(t, k + 2); sub(/^ +/, "", rest)
if (rest != "" && substr(rest, 1, 1) !~ /[A-Z]/) continue
pre = tolower(substr(t, 1, k - 1))
if (pre ~ /(^|[^a-z])(vs|cf|ca)$/ || pre ~ /(e\.g|i\.e)$/) continue
return substr(t, 1, k)
}
}
return t
}
# The sentence, and the next one too while it is a fragment (`min(I, 1)`, `returns nothing`).
function lead(t, s, rest) {
s = first_sentence(t)
while (length(s) < 70 && length(s) < length(t)) {
rest = substr(t, length(s) + 1); sub(/^ +/, "", rest)
s = s " " first_sentence(rest)
}
return s
}
# Where a `Deviation:` label starts in a comment, 0 if none: one inside backticks is a mention.
function label_at(c, at, off, rest) {
off = 0; rest = c
while ((at = index(rest, "Deviation:")) > 0) {
if (substr(rest, at - 1, 1) != "`" || at == 1) return off + at
off += at + 9; rest = substr(rest, at + 10)
}
return 0
}
function module_of(r, rest, m) {
if (r ~ /^src\//) {
rest = substr(r, 5)
if (index(rest, "/")) { sub(/\/.*/, "", rest); return rest }
sub(/\.[^.]*$/, "", rest)
return (rest == "lib" || rest == "main") ? "(crate root)" : rest
}
if (r ~ /^assets\//) { rest = substr(r, 8); m = "assets"; if (index(rest, "/")) { sub(/\/.*/, "", rest); m = m "/" rest } return m }
sub(/\/.*/, "", r); return r
}
function process(f, n, i, j, s, t, ext, xin, skipping, tindent, pend, p, q, c, kind, code, marker, text, lab, cr, rel, first, cont) {
n = 0; delete L; delete K; delete C; delete Q; delete XI; delete T
while ((getline s < f) > 0) L[++n] = s
close(f)
ext = f; sub(/.*\./, "", ext)
if (ext != "rs" && ext != "wgsl" && ext != "xml" && ext != "lua") return
cr = f; sub(/^crates\//, "", cr); sub(/\/.*/, "", cr)
rel = f; sub(/^crates\/[^\/]*\//, "", rel)
xin = 0; skipping = 0; pend = 0
for (i = 1; i <= n; i++) {
s = L[i]; t = s; sub(/^[ \t]+/, "", t)
T[i] = 0
if (ext == "rs") {
if (skipping) { T[i] = 1; if (indent_of(s) == tindent && t ~ /^}/) skipping = 0 }
else if (t == "#[cfg(test)]") pend = 1
else if (pend) {
if (t !~ /^#\[/) {
pend = 0
if (t ~ /^(pub(\([a-z]+\))? )?mod [A-Za-z_0-9]+ *\{[ \t]*$/) { skipping = 1; tindent = indent_of(s); T[i] = 1 }
}
}
}
XI[i] = xin; K[i] = ""; C[i] = ""; Q[i] = 0
if (xin) {
K[i] = "x"; c = s
if ((q = index(c, "-->")) > 0) { c = substr(c, 1, q - 1); xin = 0 }
C[i] = trim(c)
} else if (ext == "xml" || ext == "lua") {
p = index(s, "<!--")
if (p > 0 && ext == "xml") {
K[i] = "x"; c = substr(s, p + 4); Q[i] = (trim(substr(s, 1, p - 1)) != "")
if ((q = index(c, "-->")) > 0) c = substr(c, 1, q - 1); else xin = 1
C[i] = trim(c)
} else if ((p = index(s, "--")) > 0) {
K[i] = "--"; c = substr(s, p + 2); sub(/^-+/, "", c); Q[i] = (trim(substr(s, 1, p - 1)) != "")
C[i] = trim(c)
}
} else {
p = index(s, "//")
if (p > 0) {
c = substr(s, p + 2); marker = "//"
if (substr(c, 1, 1) == "/") { c = substr(c, 2); marker = "///" }
else if (substr(c, 1, 1) == "!") { c = substr(c, 2); marker = "//!" }
K[i] = marker; Q[i] = (trim(substr(s, 1, p - 1)) != "")
C[i] = trim(c)
}
}
}
for (i = 1; i <= n; i++) {
if (K[i] == "" || (lab = label_at(C[i])) == 0) continue
text = trim(substr(C[i], lab + 10))
# Continue over the comment lines that follow, to its paragraph end: a blank comment line,
# a list item, a code fence or the next `Deviation:` ends it.
for (j = i + 1; j <= n; j++) {
if (K[i] == "x") { if (!XI[j] || K[j] != "x") break }
else if (K[j] != K[i] || Q[j]) break
cont = C[j]
if (cont == "" || cont ~ /^([-*] |```)/ || label_at(cont)) break
text = text " " cont
}
text = trim(lead(trim(text)))
gsub(/\t/, " ", text)
# A rustdoc link [`x`] or [`x`](path) reads as `x`.
while (match(text, /\[`[^`]*`\]\([^)]*\)/)) { q = index(substr(text, RSTART), "]("); text = substr(text, 1, RSTART - 1) substr(text, RSTART + 1, q - 2) substr(text, RSTART + RLENGTH) }
gsub(/\[`/, "`", text); gsub(/`\]/, "`", text)
# A plain lowercase first word starts a sentence; an identifier (`pfUI`, `a.b`) is left alone.
if (match(text, /^[a-z]+/) && (substr(text, RLENGTH + 1, 1) == "" || substr(text, RLENGTH + 1, 1) ~ /^[ ;,-]$/)) text = toupper(substr(text, 1, 1)) substr(text, 2)
sub(/[.;,:]+$/, "", text)
if (text == "") text = "(see the comment)"
if (T[i] || skipfile) { skipped++; continue }
printf "%s\t%s\t%s\t%d\t%s\n", cr, module_of(rel), rel, i, text
}
}
BEGIN {
while ((getline s < list) > 0) tests[s] = 1
close(list)
for (a = 1; a < ARGC; a++) { skipfile = (ARGV[a] in tests); process(ARGV[a]) }
if (skipped) print "genmap: " skipped " deviation comment(s) in test code left out" > "/dev/stderr"
exit
}' > "$tmp/code.tsv"
LC_ALL=C sort -t "$(printf '\t')" -k1,1 -k2,2 -k3,3 -k4,4n "$tmp/code.tsv" > "$tmp/code.sorted"
# Stage 2: the `Deviates` rows of the CVar table. The whole file is scanned as a token stream
# (comments, strings and char literals skipped), so a call may sit on one line or twelve. A literal
# is read with Rust's `\`-newline continuation and `\"`/`\\` escapes; a constant argument
# (`crate::realmlist::CVAR_REALMLIST`) is resolved to its `const NAME: &str = "…";` in the crates.
# `cvars::tests::the_deviates_rows_are_what_the_generator_reads` pins this reading to the table.
LC_ALL=C awk '
{ src = src $0 "\n" }
function resolve(e, id, cmd, line) {
id = e; sub(/.*::/, "", id)
if (id !~ /^[A-Za-z_][A-Za-z_0-9]*$/) return "`" e "`"
cmd = "grep -rhE --include=\"*.rs\" \"const " id ": &str = \\\"\" crates 2>/dev/null"
cmd | getline line; close(cmd)
if (line ~ /= "/) { sub(/^[^"]*"/, "", line); sub(/";.*$/, "", line); return line }
return "`" e "`"
}
function str_at(i, out, c, d) { # i is on the opening quote; returns the decoded text, sets endi
out = ""; i++
while (i <= N) {
c = substr(src, i, 1)
if (c == "\"") { endi = i; return out }
if (c == "\\") {
d = substr(src, i + 1, 1)
if (d == "\n") { i += 2; while (substr(src, i, 1) ~ /[ \t\n]/) i++; continue }
if (d == "\"" || d == "\\" || d == "'"'"'") { out = out d; i += 2; continue }
if (d == "n") { out = out " "; i += 2; continue }
}
out = out c; i++
}
endi = N; return out
}
function skip_noise(i, c, d) { # returns the index past a comment, or i itself
c = substr(src, i, 1); d = substr(src, i + 1, 1)
if (c == "/" && d == "/") { while (i <= N && substr(src, i, 1) != "\n") i++; return i }
if (c == "/" && d == "*") { i += 2; while (i <= N && substr(src, i, 2) != "*/") i++; return i + 2 }
return i
}
function call(i, depth, c, k, arg, a, isstr, lit, expr, j) {
# i is just past the opening paren
depth = 1; k = 1; expr = ""; lit = 0; delete A
while (i <= N && depth > 0) {
j = skip_noise(i); if (j != i) { i = j; continue }
c = substr(src, i, 1)
if (c == "\"") { A[k] = str_at(i); lit = 1; i = endi + 1; isl[k] = 1; continue }
if (c == "(" || c == "[" || c == "{") depth++
if (c == ")" || c == "]" || c == "}") { depth--; if (depth == 0) break }
if (c == "," && depth == 1) { k++; i++; continue }
if (c !~ /[ \t\n]/) { if (!isl[k]) A[k] = A[k] c }
i++
}
for (a = 1; a <= 4; a++) {
arg = A[a]
if (!isl[a]) arg = resolve(arg)
gsub(/[\t\n]/, " ", arg)
printf "%s%s", arg, (a < 4 ? "\t" : "\n")
}
delete isl
return i
}
END {
N = length(src); i = 1; prev = ""
while (i <= N) {
j = skip_noise(i); if (j != i) { i = j; continue }
c = substr(src, i, 1)
if (c == "\"") { str_at(i); i = endi + 1; continue }
if (c == "'"'"'") {
if (substr(src, i + 2, 1) == "'"'"'") i += 3
else if (substr(src, i + 1, 1) == "\\") { i += 2; while (i <= N && substr(src, i, 1) != "'"'"'") i++; i++ }
else i++
continue
}
if (c ~ /[A-Za-z_]/) {
id = c; i++
while (i <= N && substr(src, i, 1) ~ /[A-Za-z_0-9]/) { id = id substr(src, i, 1); i++ }
if (id == "deviates" && prev != "fn" && substr(src, i, 1) == "(") i = call(i + 1)
prev = id; continue
}
i++
}
}' crates/benilla-app/src/cvars/table.rs 2>/dev/null \
| LC_ALL=C sort -f -t "$(printf '\t')" -k1,1 > "$tmp/cvars.tsv"
# Markdown-escape outside code spans (an odd backtick count means no spans: every backtick escaped).
MDESC='
function mdesc(t, cell, out, k, ch, tick, n) {
n = gsub(/`/, "`", t); out = ""; tick = 0
for (k = 1; k <= length(t); k++) {
ch = substr(t, k, 1)
if (ch == "`") { if (n % 2) out = out "\\`"; else { tick = !tick; out = out ch } continue }
if (tick) { out = out ((cell && ch == "|") ? "\\|" : ch); continue }
if (ch ~ /[\\*_<\[\]&]/ || (cell && ch == "|")) out = out "\\"
out = out ch
}
return out
}'
{
echo "# benilla — generated deviations"
echo
echo "> **GENERATED** by \`scripts/genmap.sh\` from the code — do not edit by hand; rerun the script"
echo "> when a deviation is added or reworded. No timestamp — identical code yields an identical file."
echo
echo "benilla is the 1.12.1 client reimplemented: what the reference does is what benilla does. Where it"
echo "differs on purpose, that is a deviation, the maintainer's call alone (\`docs/METHOD.md\`), and it is"
echo "written down where it lives: a setting's \`Deviates\` row in the CVar table, or a \`Deviation:\` comment"
echo "at the code, the Lua or the XML that differs. This file gathers them all, regenerated at every land,"
echo "so the list is read from the code and cannot drift from it. Test code is left out, and so are the"
echo "CVars that are benilla's own knobs with no 1.12.1 counterpart (\`ours\` rows)."
echo
echo "## Settings that ship off the reference default"
echo
echo "A setting boots at the reference's default unless it is listed here (\`docs/METHOD.md\`)."
echo
echo "| CVar | 1.12.1 | benilla | Why |"
echo "| --- | --- | --- | --- |"
LC_ALL=C awk -F '\t' "$MDESC"'
function code(v) { return "`" (v == "" ? "\"\"" : v) "`" }
{ printf "| %s | %s | %s | %s |\n", code($1), code($3), code($2), mdesc($4, 1) }' "$tmp/cvars.tsv"
echo
echo "## Deviations in the code"
echo
echo "Each entry is the first sentence of a \`Deviation:\` comment, linking to where it lives."
echo
LC_ALL=C awk -F '\t' '{ c[$1]++; if (!($1 in seen)) { seen[$1] = 1; order[++n] = $1 } }
END { for (i = 1; i <= n; i++) printf "- [%s](#%s): %d\n", order[i], order[i], c[order[i]] }' "$tmp/code.sorted"
echo
LC_ALL=C awk -F '\t' "$MDESC"'
$1 != crate { crate = $1; module = ""; printf "### %s\n\n", crate }
$2 != module { if (module != "") print ""; module = $2; printf "#### %s\n\n", module }
{ printf "- %s. ([`%s:%d`](../crates/%s/%s#L%d))\n", mdesc($5), $3, $4, $1, $3, $4 }
END { print "" }' "$tmp/code.sorted"
} > docs/DEVIATIONS.md
echo "genmap: wrote docs/DEVIATIONS.md ($(wc -l < "$tmp/code.sorted" | tr -d ' ') code entries, $(wc -l < "$tmp/cvars.tsv" | tr -d ' ') CVar rows)"
+21 -8
View File
@@ -101,6 +101,15 @@ printf '%s' "$plain" | grep -q "logout-smoke: empty roster" &&
fail "$user has no characters — the re-entry leg cannot run (make one, or rig it: WOW_RIG=…)"
printf '%s' "$plain" | grep -q "logout-smoke: done" ||
fail "the round trip never completed (no 'logout-smoke: done')"
# The logout returns to character select on the link the world was entered by and re-lists there
# (`0x5aaeb0` → `0x46b880`): a roster on that world session, and one realmd logon for the walk.
printf '%s' "$plain" | grep -q "logout-smoke: FAILED" &&
fail "$(printf '%s\n' "$plain" | sed -n 's/.*logout-smoke: FAILED — //p' | tail -1)"
printf '%s' "$plain" | grep -q "logout-smoke: roster after the logout on the same link" ||
fail "no roster came on the same link after the logout"
logons="$(printf '%s\n' "$plain" | grep -cF "net: parked at the realm list")"
[ "$logons" -eq 1 ] ||
fail "$logons realmd logons in the round trip, expected 1 — the logout logged on again"
# The re-entered character must be drivable: the leg lists what would block movement (a /logout
# root must end with its session), and anything but `none` fails.
@@ -127,20 +136,24 @@ panics="$(printf '%s\n' "$plain" | grep -cE 'panicked at')"
# 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
# twice per login; half that means an entry adopted the glue VM and its spent `VmMemo`s. One more
# is built once at startup: the boot strings beside the glue VM, read for the slash-command table
# (`ui_chat::commands::build_slash_commands`).
sessions=2 # world entries in this walk
vms=$((sessions * 2 + 1)) # …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"
fail "'$marker' happened $n time(s), expected $vms — one per VM built, two per login and \
the startup strings; $((sessions + 1)) 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
# second reused the first's frame tree. The `Fonts.xml` marker names its folder: the glue's
# `GlueFonts.xml` ends the same way.
for marker in 'FrameXML\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" ] ||
@@ -191,7 +204,7 @@ fi
# 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.
# No WOW_CHAR: the walk drives character select, and `glue_session::realm_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=$?
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Vendor benilla-re's tables into `reference/` at the commit `reference/PIN` names.
scripts/sync-reference.py <benilla-re checkout> copy the tables at the PIN
scripts/sync-reference.py <benilla-re checkout> --check fail when a copy differs
Each file is `git show <PIN>:tables/<name>` byte for byte. To update, put a new 40-hex commit of
benilla-re in `reference/PIN` and run the script without `--check`.
"""
import os
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REFERENCE = os.path.join(REPO, "reference")
# The benilla-re tables benilla reads.
TABLES = [
"bindings.tsv",
"chat_groups.tsv",
"chat_types.tsv",
"cvars.tsv",
"event_args.tsv",
"events.tsv",
"glue_events.tsv",
"glue_surface.tsv",
"lua_globals.tsv",
"messages.tsv",
"verb_events.tsv",
]
def git_show(checkout, pin, name):
r = subprocess.run(
["git", "-C", checkout, "show", f"{pin}:tables/{name}"], capture_output=True
)
if r.returncode != 0:
sys.exit(f"sync-reference: {pin[:12]}:tables/{name} not in {checkout}: {r.stderr.decode().strip()}")
return r.stdout
def main():
args = [a for a in sys.argv[1:] if a != "--check"]
check = "--check" in sys.argv[1:]
if len(args) != 1:
sys.exit("usage: scripts/sync-reference.py <benilla-re checkout> [--check]")
checkout = args[0]
with open(os.path.join(REFERENCE, "PIN")) as f:
pin = f.read().strip()
if len(pin) != 40 or any(c not in "0123456789abcdef" for c in pin):
sys.exit("sync-reference: reference/PIN must hold a full 40-hex commit")
for ref in ("origin/main", "main"):
r = subprocess.run(["git", "-C", checkout, "merge-base", "--is-ancestor", pin, ref],
capture_output=True)
if r.returncode in (0, 1):
if r.returncode == 1:
sys.exit(f"sync-reference: the PIN {pin[:12]} is not on benilla-re's {ref}")
break
stray = sorted(n for n in os.listdir(REFERENCE) if n.endswith(".tsv") and n not in TABLES)
if stray:
sys.exit("sync-reference: reference/ holds tables it does not vendor: " + ", ".join(stray))
bad = []
for name in TABLES:
want = git_show(checkout, pin, name)
path = os.path.join(REFERENCE, name)
have = open(path, "rb").read() if os.path.exists(path) else None
if have == want:
continue
if check:
bad.append(name)
else:
with open(path, "wb") as f:
f.write(want)
print(f"sync-reference: wrote reference/{name}")
if bad:
sys.exit(
"sync-reference: differs from benilla-re at the PIN: "
+ ", ".join(bad)
+ " (run scripts/sync-reference.py <benilla-re checkout>)"
)
if check:
print(f"sync-reference: {len(TABLES)} tables match benilla-re at {pin[:12]}")
if __name__ == "__main__":
main()