- right-clicking a vendor with an item on the cursor sells it: `0x5df5d0` asks `GetCursorItem` before anything else and a non-zero answer takes the arm to `CMSG_SELL_ITEM` instead of `CMSG_LIST_INVENTORY`, where benilla sent the list unconditionally — the whole stack, to the clicked vendor, with no merchant-window gate anywhere in the arm; the clear is `SetCursorItem(0, …)` rather than `ClearCursor`, which fires `CURSOR_UPDATE` and leaves the source slot greyed until the server's inventory update (1914); and the NPC you are talking to is not clickable: `0x5f0130` refuses a right-click on the NPC whose window is already open, against a global with exactly two writers — the interaction setter, whose fourteen callers are every one a window opener, and its clear — so the first click always gets through and no double-click is eaten, where benilla re-sent the arm's opcode on every click (1866) - a Hated or Hostile questgiver's `!` now disappears, every `!` blinks off and back on when you resurrect, and the minimap no longer dots a corpse, your own pet or an untrackable unit: the sweep tears down on HOSTILITY, not on the flag — `0x5eb134` is a convergent block reached from two branches, so three incompatible readings had each seen one path — and an escort giver's `!` clearing with its flag is the field watch instead, which B257 now rests on; the dots gain `0x4eaa90`'s three preconditions (1906); the quest log's rebuild is all-or-nothing — the reference makes every rebuild a no-op until its template misses are answered, so a cold log shows empty and then lands in one step, never our `"..."` row per in-flight quest — sorted level then title, case-insensitively, with the `id == 0` group first under the reference's own "Missing header! (quest designers)"; and `GetQuestLogTitle`'s `isHeader`/`isCollapsed` answer 1/nil, not booleans - an animation event has a place, not just a time: the M2 event kernel (`0x719370`) places each fired key through its bone, and benilla carried the when and the what and never the where, so every animation-event sound played at the model root — 149 of 244 `$DSL` records sit off their model's origin, out to 67.6 yd on `Maraudon_Waterfall01.m2` — and the point belongs to the record, not the tag, since 75 models author one tag twice; doodad, GameObject and shake sounds now sound where the key is (1904); and the unit dispatcher's arms do not agree about the point they are handed — footsteps and emotes at the event point (the footstep's material foley stays at the origin), `$TRD` at the origin, `$CSD` at attachment 17 rather than its head-mounted records, `$CSS` at the event point on a hit and attachment 1 on a miss — with `0x623b90`'s attachment-else-bias resolver transcribed table and all (1915) - `GetSkillLineInfo` out of range answers the reference's thirteen-value tuple, numbers in the rank slots, not a single nil — the stock skill window adds two of those slots unguarded straight off `SetSelectedSkill(0)`, so one nil raises the moment it drives us; one shape serves every out-of-range case because the bounds test at `0x4d3675` is a single unsigned compare, and our source had asserted the opposite (1919); the outbound wire is traced by name (`out`, mirroring `in`), a line pushed only after a successful write — a 5-second visit to the Goldshire poster reads as 87 lines (1901); the binding shape gate seats a player and checks the `Unit*` verbs it had been skipping, coverage 220 → 249, all agreeing with the reference (1911); and a UI capture scenario opts the UI in itself instead of photographing a UI-less world and exiting 0, the opt-in answering `false` in a player build that has no harness Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
346 lines
14 KiB
Rust
346 lines
14 KiB
Rust
//! **The return-shape gate** — `reference/1.12-shapes.tsv` against what this client actually
|
|
//! answers (decision 1842).
|
|
//!
|
|
//! `reference_surface` compares `_G` to `_G`, so a wrong *name* cannot land. Nothing compared
|
|
//! *shapes*, and that gap produced six decisions in two days — 1818, 1819, 1830, 1834, 1836 and
|
|
//! 1840 — every one of them a right name with a wrong arity, invisible to every gate we had and
|
|
//! invisible to the chain's own files too, because a Lua caller that uses one value of two never
|
|
//! notices the second.
|
|
//!
|
|
//! The table is wow-re's `re/audit/binding-shapes.tsv`, vendored: 1722 rows over all 82 registrar
|
|
//! tables, one per registered binding, generated and differentially tested against that repo's
|
|
//! other harvester. It carries the gate rule **per row** rather than in prose, which is the whole
|
|
//! reason it can be enforced instead of remembered:
|
|
//!
|
|
//! * `arity_conf = exact` — sound. This is the column to gate on.
|
|
//! * `kinds_conf` — trustworthy only where an independent push count agreed; not gated here.
|
|
//! * `argc_conf` — **never** gate on it. It is a deliberate over-flagging superset: any binding
|
|
//! whose direct callee reads a Lua index is marked `lower-bound`, so a `lower-bound` row says
|
|
//! nothing about the real argument count.
|
|
//!
|
|
//! ## Two things that make this narrower than it looks, both on purpose
|
|
//!
|
|
//! **Only query-shaped names are probed.** The gate calls each binding to count what it answers,
|
|
//! and calling an arbitrary global for its arity would mean calling `AbandonQuest`, `Quit` and
|
|
//! `AcceptBattlefieldPort` for theirs. Names are filtered to the query verbs — `Get*`, `Is*`,
|
|
//! `Has*`, `Can*`, `Unit*`, `Num*` — which is a loss of coverage, never a wrong assertion.
|
|
//!
|
|
//! **A call that raises is skipped, not failed.** A raise says nothing about arity, so only a call
|
|
//! that *returns* is measured. That rule used to cost the whole unit surface: the unit bindings
|
|
//! gate their token and raise on a nil one (1834/1836), so a no-argument probe threw for every one
|
|
//! of them, and the 62 `Unit*` rows this table calls `arity_conf = exact` were silently
|
|
//! uncovered — the gap named in this doc and never priced. The probe now **seats a player** and
|
|
//! calls `Unit*` with a real token, which took coverage from **220 bindings to 249**. All 29 newly
|
|
//! measured agreed with the reference on the first run; the value here is that they cannot drift
|
|
//! away from it unnoticed, not that anything was found wrong. The remainder still raise, needing
|
|
//! state a seated body does not supply, and are still skipped.
|
|
//!
|
|
//! ## The name is not a key
|
|
//!
|
|
//! **60 names are registered from more than one table and 21 differ in arity across
|
|
//! registrations** — `GetBuildInfo` is arity 5 in the glue table `0x8373b8` and 3 in the in-game
|
|
//! core `0x83de68`; `GetAddOnInfo` is 8 and 7. This VM is the in-game one, so a name carrying rows
|
|
//! from both is read at its **non-glue** row, and a name whose remaining rows still disagree is
|
|
//! skipped rather than guessed at.
|
|
//!
|
|
//! That hazard is the reason the table is keyed on `fn` upstream, and it is a live merge hazard for
|
|
//! `1.12-globals.tsv`, which is name-keyed.
|
|
|
|
/// Names the gate knows are wrong and does not yet assert — **a list that may only shrink**.
|
|
///
|
|
/// **It is empty.** It held five entries whose miss-branch *contents* the shapes table does not
|
|
/// record — four globals and `Model:GetFogColor` — and 1845 answered all five at the bytes rather
|
|
/// than guessing them. The mechanism stays because the next divergence the gate finds will want it:
|
|
/// an entry needs an arity, a reason, and a dispatched question.
|
|
const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
|
|
|
|
/// The widget half's own shrinking list — see [`NOT_YET_ASSERTED`], same rules. Also empty:
|
|
/// `GetFogColor` came off it with 1845, which found the fog colour was a packed `0xAARRGGBB` dword
|
|
/// all along.
|
|
const WIDGET_NOT_YET_ASSERTED: &[&str] = &[];
|
|
|
|
/// The glue registrar table. This VM is the in-game one, so a duplicated name is read at its
|
|
/// other row (see the module doc).
|
|
const GLUE_TABLE: &str = "0x8373b8";
|
|
|
|
struct Row {
|
|
name: String,
|
|
table_va: String,
|
|
arity: usize,
|
|
}
|
|
|
|
fn rows() -> Vec<Row> {
|
|
let tsv = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../reference/1.12-shapes.tsv"
|
|
);
|
|
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
|
|
text.lines()
|
|
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
|
|
.filter_map(|l| {
|
|
let f: Vec<&str> = l.split('\t').collect();
|
|
// name fn pair_va table_va table_kind argc argc_conf arity arity_conf kinds …
|
|
if f.len() < 9 || f[4] != "global" || f[8] != "exact" {
|
|
return None;
|
|
}
|
|
Some(Row {
|
|
name: f[0].to_string(),
|
|
table_va: f[3].to_string(),
|
|
arity: f[7].parse().ok()?,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Every registered global whose arity the reference states exactly, answered by this client.
|
|
#[test]
|
|
fn every_query_binding_answers_the_reference_s_return_arity() {
|
|
let all = rows();
|
|
assert!(
|
|
all.len() > 800,
|
|
"the vendored table looks wrong: {}",
|
|
all.len()
|
|
);
|
|
|
|
// Resolve the duplicated names the way the module doc describes.
|
|
let mut by_name: std::collections::HashMap<&str, Vec<&Row>> = std::collections::HashMap::new();
|
|
for r in &all {
|
|
by_name.entry(&r.name).or_default().push(r);
|
|
}
|
|
|
|
let mut s = benilla_ui::script::UiScript::new().expect("VM");
|
|
// **Seat a player, so the unit surface can be probed at all.** The unit bindings gate their
|
|
// token and raise on a nil one (1834/1836), so a no-argument probe measured *nothing* for the
|
|
// 62 `Unit*` rows this table calls `arity_conf = exact` — the gate's own doc named that gap in
|
|
// prose and nobody had priced it. With a body seated they answer instead of raising, which is
|
|
// the difference between covering the unit surface and skipping it.
|
|
s.set_unit(
|
|
"player",
|
|
Some(benilla_ui::script::UnitState {
|
|
exists: true,
|
|
name: Some("Shapeprobe".into()),
|
|
level: 60,
|
|
..Default::default()
|
|
}),
|
|
);
|
|
let mut checked = 0usize;
|
|
let mut mismatches: Vec<String> = Vec::new();
|
|
|
|
for (name, rs) in &by_name {
|
|
// Query verbs only — the gate calls what it measures.
|
|
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
|
|
.iter()
|
|
.any(|p| name.starts_with(p))
|
|
{
|
|
continue;
|
|
}
|
|
let candidates: Vec<&&Row> = if rs.len() == 1 {
|
|
rs.iter().collect()
|
|
} else {
|
|
rs.iter().filter(|r| r.table_va != GLUE_TABLE).collect()
|
|
};
|
|
// Still ambiguous after dropping glue: skip rather than guess.
|
|
let Some(first) = candidates.first() else {
|
|
continue;
|
|
};
|
|
if candidates.iter().any(|r| r.arity != first.arity) {
|
|
continue;
|
|
}
|
|
let want = first.arity;
|
|
|
|
// A call that raises still says nothing about arity. The unit bindings are the reason:
|
|
// they gate their arguments and raise on a nil token (1834/1836), so they are called with
|
|
// a real token against the body seated above rather than with nothing.
|
|
let call = if name.starts_with("Unit") {
|
|
format!(r#"{name}("player")"#)
|
|
} else {
|
|
format!("{name}()")
|
|
};
|
|
let probe = format!(
|
|
"if type({name}) ~= 'function' then return -1 end \
|
|
local ok, n = pcall(function() return select('#', {call}) end) \
|
|
if not ok then return -1 end return n"
|
|
);
|
|
let got: i64 = match s.eval(&probe) {
|
|
Ok(n) => n,
|
|
Err(_) => continue,
|
|
};
|
|
if got < 0 {
|
|
continue;
|
|
}
|
|
checked += 1;
|
|
if got as usize != want {
|
|
if let Some((_, known, _)) = NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
|
assert_eq!(
|
|
*known, want,
|
|
"{name} is on the not-yet-asserted list at a stale arity — the table now says \
|
|
{want}"
|
|
);
|
|
continue;
|
|
}
|
|
mismatches.push(format!("{name}: answers {got}, reference states {want}"));
|
|
}
|
|
}
|
|
|
|
// A floor, not a target: it exists so a change that quietly stops the probe from measuring
|
|
// anything fails loudly instead of passing vacuously. Seating a player took the count from
|
|
// **220 to 249** by making the unit bindings answerable, so the old floor of 40 was six times
|
|
// below reality and would have sat green through losing the entire unit surface. Raise this
|
|
// whenever coverage rises; never lower it to make a change fit.
|
|
assert!(
|
|
checked >= 240,
|
|
"the gate measured only {checked} bindings — it has stopped covering anything"
|
|
);
|
|
// The list may only SHRINK. An entry that now agrees is a fix nobody deleted the note for,
|
|
// and leaving it would let the next real divergence hide behind it.
|
|
let stale: Vec<&str> = NOT_YET_ASSERTED
|
|
.iter()
|
|
.map(|(n, ..)| *n)
|
|
.filter(|n| {
|
|
by_name.get(n).is_some_and(|rs| {
|
|
let want = rs[0].arity;
|
|
s.eval::<i64>(&format!(
|
|
"local ok, n = pcall(function() return select('#', {n}()) end) \
|
|
if not ok then return -1 end return n"
|
|
))
|
|
.is_ok_and(|got| got >= 0 && got as usize == want)
|
|
})
|
|
})
|
|
.collect();
|
|
assert!(
|
|
stale.is_empty(),
|
|
"these now answer the reference's arity and must come off NOT_YET_ASSERTED: {stale:?}"
|
|
);
|
|
assert!(
|
|
mismatches.is_empty(),
|
|
"{} of {checked} probed bindings answer the wrong number of values:\n {}",
|
|
mismatches.len(),
|
|
mismatches.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// The widget registrar tables, each mapped to a Lua expression that produces one instance.
|
|
///
|
|
/// The table address IS the widget class — the reference registers one `{name, fn}` table per
|
|
/// widget family, and a method name can appear in several of them with different arities (the
|
|
/// `name-not-unique` case). So a widget method is probed on **its own class**, never on whichever
|
|
/// object happens to answer to the name.
|
|
///
|
|
/// Identified by each table's distinctive members: `GetTexCoord` is Texture's, `GetChecked` is
|
|
/// CheckButton's, `GetHyperlinkFormat` is SimpleHTML's, `GetScrollChild` is ScrollFrame's, and so
|
|
/// on. A table with no entry here is skipped, which costs coverage and never correctness.
|
|
const WIDGET_PROBES: &[(&str, &str, &str)] = &[
|
|
("0x878ec0", "Frame", "PGFrame"),
|
|
("0x879d00", "Button", "PGButton"),
|
|
("0x87bf74", "CheckButton", "PGCheck"),
|
|
("0x87bb68", "EditBox", "PGEdit"),
|
|
("0x87b260", "Slider", "PGSlider"),
|
|
("0x87b010", "StatusBar", "PGStatus"),
|
|
("0x87b3c0", "ScrollFrame", "PGScroll"),
|
|
("0x87ba80", "SimpleHTML", "PGHtml"),
|
|
("0x87abb0", "ColorSelect", "PGColor"),
|
|
("0x87b960", "MessageFrame", "PGMessage"),
|
|
("0x87b5c0", "ScrollingMessageFrame", "PGScrollMsg"),
|
|
("0x878948", "PlayerModel", "PGModel"),
|
|
];
|
|
|
|
/// The two region classes, which are made by a frame rather than by `CreateFrame`.
|
|
const REGION_PROBES: &[(&str, &str)] = &[
|
|
("0x87c128", "PGFrame:CreateTexture('PGTex')"),
|
|
("0x87c1d8", "PGFrame:CreateFontString('PGFS')"),
|
|
];
|
|
|
|
/// **The widget half of the gate** (decision 1843) — the half that would have caught 1840's
|
|
/// `GetTexCoord` without a byte read.
|
|
///
|
|
/// Same rule as the global half: `arity_conf = exact` only, query verbs only, a call that raises is
|
|
/// skipped. The difference is the receiver: each row is probed on an instance of the class its
|
|
/// registrar table belongs to, because the same method name lives in several tables with different
|
|
/// shapes.
|
|
#[test]
|
|
fn every_widget_method_answers_the_reference_s_return_arity() {
|
|
let tsv = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../reference/1.12-shapes.tsv"
|
|
);
|
|
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
|
|
|
|
let s = benilla_ui::script::UiScript::new().expect("VM");
|
|
let mut made: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
|
|
for (table, kind, name) in WIDGET_PROBES {
|
|
if s.run(&format!(
|
|
"{name} = CreateFrame(\"{kind}\", \"{name}\", UIParent)"
|
|
))
|
|
.is_ok()
|
|
{
|
|
made.insert(table, (*name).to_string());
|
|
}
|
|
}
|
|
for (table, expr) in REGION_PROBES {
|
|
let var = format!("PGR{}", made.len());
|
|
if s.run(&format!("{var} = {expr}")).is_ok() {
|
|
made.insert(table, var);
|
|
}
|
|
}
|
|
assert!(
|
|
made.len() >= 8,
|
|
"only {} widget classes could be instantiated — the probe set is broken",
|
|
made.len()
|
|
);
|
|
|
|
let mut checked = 0usize;
|
|
let mut mismatches: Vec<String> = Vec::new();
|
|
for line in text
|
|
.lines()
|
|
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
|
|
{
|
|
let f: Vec<&str> = line.split('\t').collect();
|
|
if f.len() < 9 || f[4] != "widget" || f[8] != "exact" {
|
|
continue;
|
|
}
|
|
let (name, table) = (f[0], f[3]);
|
|
if !["Get", "Is", "Has", "Can", "Num"]
|
|
.iter()
|
|
.any(|p| name.starts_with(p))
|
|
{
|
|
continue;
|
|
}
|
|
let Some(obj) = made.get(table) else { continue };
|
|
let Ok(want) = f[7].parse::<usize>() else {
|
|
continue;
|
|
};
|
|
|
|
let probe = format!(
|
|
"if type({obj}.{name}) ~= 'function' then return -1 end \
|
|
local ok, n = pcall(function() return select('#', {obj}:{name}()) end) \
|
|
if not ok then return -1 end return n"
|
|
);
|
|
let Ok(got) = s.eval::<i64>(&probe) else {
|
|
continue;
|
|
};
|
|
if got < 0 {
|
|
continue;
|
|
}
|
|
checked += 1;
|
|
if got as usize != want {
|
|
if WIDGET_NOT_YET_ASSERTED.contains(&name) {
|
|
continue;
|
|
}
|
|
mismatches.push(format!(
|
|
"{obj}:{name} answers {got}, reference states {want}"
|
|
));
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
checked >= 60,
|
|
"the widget gate measured only {checked} methods — it has stopped covering anything"
|
|
);
|
|
assert!(
|
|
mismatches.is_empty(),
|
|
"{} of {checked} probed widget methods answer the wrong number of values:\n {}",
|
|
mismatches.len(),
|
|
mismatches.join("\n ")
|
|
);
|
|
}
|