a bad macro fails on its own, and script errors name their frame

- a macro with a Lua error fails on its own and lands in the script error channel instead of aborting the UI function that ran it; RunScript names its chunk by the source text, and a bad or empty argument is a quiet no-op (2136)
- a script error or debugstack frame names its handler as Frame:OnClick instead of a whole CreateFrame sentence, an inline <Script> block is no longer mistaken for the .lua beside it, and a handler's line numbers stop reading one too high (2136)
- GetAddOnInfo and its seven siblings raise the reference's own Usage and out-of-range messages, GetAddOnInfo("2") is the second addon, and assert(a, b) answers one value, as the client's Lua 5.0 does (2139)
- frillDensity and gxApi are real settings: pfUI's hdgraphic module reads world detail through frillDensity, its system tooltip names the live render backend, and the two spellings of the ground clutter knob stay one knob (2151)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 20:54:29 +07:00
co-authored by Claude Fable 5.1
parent e3c44456ce
commit 7d100d8a36
20 changed files with 1341 additions and 206 deletions
+14 -6
View File
@@ -1069,12 +1069,20 @@
-- numbering our page does not use, so each row names the child it drives instead.
--
-- NOT bound, and named rather than faked: pfUI's module goes on to hook `SetWorldDetail`
-- (unbound) and drive `ConsoleExec("frillDensity ...")`, `lodDist`, `nearClip`, `maxLOD`.
-- `ConsoleExec` itself IS bound (1948 — it writes a registered CVar or pushes a console
-- command the app dispatches), but none of those four CVars is registered, so those
-- writes reach nothing. Neither verb is called at load, so the module loads and its
-- slider write lands; its EXTENDED arm cannot work here, and registering a CVar nothing
-- reads would be exactly the silent pretence 1203 exists to forbid.
-- and `GetWorldDetail` (both unbound) and drive `ConsoleExec("frillDensity ...")`,
-- `lodDist`, `nearClip`, `maxLOD`. `ConsoleExec` itself IS bound (1948 — it writes a
-- registered CVar or pushes a console command the app dispatches), and `frillDensity` is
-- now registered over the clutter knob (2151), so THAT write lands; `lodDist`, `nearClip`
-- and `maxLOD` have no benilla knob behind them and reach nothing, because registering a
-- CVar nothing reads would be exactly the silent pretence 1203 exists to forbid.
--
-- Neither WorldDetail verb is called at load, so the module loads and its slider write
-- lands. Its EXTENDED arm still cannot RUN here — that arm is `SetWorldDetail`, and its
-- replacement `GetWorldDetail` calls a nil hook at any stop <= 48. Nothing in benilla
-- calls either today (this page drives the CVar directly), which is what keeps that
-- unreachable; the migration this file is scaffolding for is what changes it, since the
-- stock `OptionsFrame_Load` prefers `getglobal("Get"..func)` over `GetCVar(func)`
-- (`OptionsFrame.lua` l.110/135). Binding the pair is named as the next move in 2151.
OptionsFrameSliders = {
{ text = "UI Scale", func = "uiscale",
minValue = 0.64, maxValue = 1.0, valueStep = 0.01, benillaRow = "RowUiScale" },
+189 -32
View File
@@ -309,12 +309,26 @@ fn fire_probe_key(
}
}
/// The PROBE LUA one-shot (`WOW_PROBE_LUA="<chunk>"`, delay via `WOW_PROBE_LUA_AT` seconds,
/// default 10): run one Lua chunk in the live UI VM once we are in-world — the "press the button
/// headlessly" instrument. The chunk drives the REAL FrameXML API surface (`CastSpell`,
/// `UseAction`, `TargetUnit`, …), so whatever it triggers takes the exact app path a click
/// takes — a headless wire probe can measure the server, but only the live VM exercises the
/// button feed and the widget clock.
/// The PROBE LUA driver (`WOW_PROBE_LUA="<chunk>"`, first fire at `WOW_PROBE_LUA_AT` seconds,
/// default 10; re-armed `WOW_PROBE_LUA_AGAIN` seconds into every LATER world entry, default 4):
/// run one Lua chunk in the live UI VM — the "press the button headlessly" instrument. The chunk
/// drives the REAL FrameXML API surface (`CastSpell`, `UseAction`, `TargetUnit`, …), so whatever
/// it triggers takes the exact app path a click takes — a headless wire probe can measure the
/// server, but only the live VM exercises the button feed and the widget clock.
///
/// **The chunk fires once per WORLD ENTRY, not once per process** (decision 2116's named gap).
/// The UI VM is destroyed and rebuilt at every logout/login (1290/1291), taking `_G` and the
/// `ProbeLog` channel below with it — so while this was a process-wide one-shot, a probe could
/// not read the same value before and after a relog *at all*. 2116 needed exactly that (the
/// cooldown triple across a `/logout`) and had to fall back to `WOW_PROBE_CHAT` sending
/// `/script … error("…")` on a schedule and reading the warn line the input drain logs — a trick
/// standing in for an instrument, and a morning instead of an hour.
///
/// **World entry, and deliberately not "VM rebuild"**, which is the wider edge and the wrong one:
/// `ReloadUI()` rebuilds the VM without leaving the world, and `WOW_PROBE_LUA="ReloadUI()"` is a
/// probe we actually run (decision 2028). Re-arming on the VM would make that chunk reload the
/// interface forever. [`ProbeLog`](install_probe_log) *does* follow the VM, because it is a
/// channel rather than an action.
pub(crate) struct ProbeLuaPlugin;
impl Plugin for ProbeLuaPlugin {
@@ -324,48 +338,77 @@ impl Plugin for ProbeLuaPlugin {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10.0);
let again = std::env::var("WOW_PROBE_LUA_AGAIN")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(4.0);
app.insert_resource(ProbeLua {
chunk,
at,
fired: false,
again,
..default()
})
.add_systems(Update, fire_probe_lua);
}
}
/// [`ProbeLuaPlugin`] state: the chunk, the fire time, and the once-latch.
#[derive(Resource)]
/// [`ProbeLuaPlugin`] state: the chunk, the two schedules, and the per-entry bookkeeping that
/// replaced the process-wide once-latch.
#[derive(Resource, Default)]
struct ProbeLua {
chunk: String,
/// `WOW_PROBE_LUA_AT` — the FIRST fire, on the run's own [`ProbeClock`].
at: f32,
fired: bool,
/// `WOW_PROBE_LUA_AGAIN` — the settle every LATER entry gets, on its own clock.
again: f32,
/// World entries that have become able to take a chunk — counted on the rising edge.
entries: u32,
/// Entries already fired into; `fired < entries` is "this entry is still owed its chunk".
fired: u32,
/// When the current entry became ready — the re-arm's zero.
ready_at: f32,
/// Last frame's readiness, so the count above is an edge and not a per-frame increment.
was_ready: bool,
/// The VM session `ProbeLog` was last installed into. `0` is "none" —
/// [`benilla_ui::script::UiScript::session`] hands out from 1, so a fresh probe matches no VM
/// and the first ready frame installs.
logged_vm: u64,
}
/// Run the probe chunk once the delay has elapsed AND the session is in-world.
fn fire_probe_lua(
mut probe: ResMut<ProbeLua>,
time: ProbeClock,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
) {
if probe.fired || probe.chunk.is_empty() || time.elapsed_secs() < probe.at {
return;
/// **Is this entry's chunk due?** — the whole schedule, as one decision a test can hold.
///
/// The first fire keeps `WOW_PROBE_LUA_AT`'s run-relative meaning ([`ProbeClock`], whose zero
/// trails process start by the boot), and that is not inertia: every recorded probe line in
/// `decisions/` is tuned against it (`WOW_PROBE_LUA_AT=34` in `scripts/summon-live.sh`, and a
/// dozen records besides), and re-reading it as "34 s after the loading screen" would quietly
/// stop reproducing all of them. A later entry cannot use that
/// clock at all — "34 s into the run" is long past by the time a relog lands — so it runs on
/// its own settle instead, measured from the moment that entry could take a chunk.
fn probe_lua_due(probe: &ProbeLua, now: f32) -> bool {
if probe.fired >= probe.entries {
return false; // this entry has had its chunk (or there is no entry yet)
}
if self_player.is_empty() {
return; // not in-world yet — keep waiting past the delay
if probe.fired == 0 {
now >= probe.at
} else {
now >= probe.ready_at + probe.again
}
let Some(script) = script else {
return;
};
probe.fired = true;
// `ProbeLog(text)` — the chunk's data channel OUT of the VM (greppable `probe-log:` lines);
// until now a probe could only report through screenshots or by erroring. Installed only
// when a probe chunk actually fires — never part of the shipping API surface.
let install = script.lua().create_function(|_, text: String| {
}
/// Install `ProbeLog(text)` — the chunk's data channel OUT of the VM, as greppable `probe-log:`
/// lines; before it existed a probe could only report through screenshots or by erroring.
///
/// **Into whichever VM is live, and re-installed whenever that changes** (decision 2116). A
/// global installed once is gone at the next logout/login *and* at the next `ReloadUI()`, both of
/// which build a new Lua state (1290/1291) — so a chunk that logged happily on its first run died
/// on a nil call afterwards, which is precisely the shape of failure that reads as "the probe did
/// nothing". Installed only while a probe chunk is configured; never part of the shipping API
/// surface.
fn install_probe_log(script: &benilla_ui::script::UiScript) {
match script.lua().create_function(|_, text: String| {
info!("probe-log: {text}");
Ok(())
});
match install {
}) {
Ok(f) => {
if let Err(e) = script.lua().globals().set("ProbeLog", f) {
error!("probe-lua: installing ProbeLog: {e}");
@@ -373,7 +416,63 @@ fn fire_probe_lua(
}
Err(e) => error!("probe-lua: creating ProbeLog: {e}"),
}
info!("probe-lua: running {:?}", probe.chunk);
}
/// Run the probe chunk once per world entry, on [`probe_lua_due`]'s schedule.
fn fire_probe_lua(
mut probe: ResMut<ProbeLua>,
time: ProbeClock,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
state: Res<State<crate::char_select::ClientState>>,
entry_ui_pending: Option<Res<crate::ui_script::PendingEntryUiLoad>>,
) {
if probe.chunk.is_empty() {
return;
}
let Some(script) = script else {
// No VM in the world at all — the deferral window between the world-entry edge and the
// entry load, where the boot VM is parked out of every feed's reach
// (`ui_script::ParkedBootVm`). Not an entry yet.
probe.was_ready = false;
return;
};
// **Ready = this entry can take a chunk**, which is three more facts on top of the VM the
// block above just took. A chunk driving `CastSpell` against the *boot* VM — strings, emote
// tokens and fonts, not one frame — dies on a nil, and "attempt to call a nil value" out of a
// probe reads as a benilla bug rather than as a mis-scheduled probe.
//
// - **`InWorld`** — the entry edge has been taken, which is what ARMS the deferral window
// below. Without it the window has a hole on its leading side: the entered-world message
// and our first object update can arrive in the same drain, a frame ahead of the state
// transition that parks the VM, so a chunk already past `at` would run there.
// - **`PendingEntryUiLoad` gone** — the same window named from the other side: the in-game
// UI is built, not merely owed.
// - **a self player** — `UnitName("player")` is in half the chunks anyone writes.
let in_world = *state.get() == crate::char_select::ClientState::InWorld;
let ready = in_world && entry_ui_pending.is_none() && !self_player.is_empty();
let now = time.elapsed_secs();
if ready && !probe.was_ready {
probe.entries += 1;
probe.ready_at = now;
info!("probe-lua: world entry {} at {now:.1}s", probe.entries);
}
probe.was_ready = ready;
if !ready {
return;
}
if probe.logged_vm != script.session() {
install_probe_log(&script);
probe.logged_vm = script.session();
}
if !probe_lua_due(&probe, now) {
return;
}
probe.fired = probe.entries;
info!(
"probe-lua: entry {} at {now:.1}s — running {:?}",
probe.entries, probe.chunk
);
if let Err(e) = script.run(&probe.chunk) {
error!("probe-lua: {e}");
}
@@ -749,3 +848,61 @@ fn fire_probe_drag(
}
probe.phase += 1;
}
#[cfg(test)]
mod tests {
use super::{probe_lua_due, ProbeLua};
/// A probe configured the way `WOW_PROBE_LUA=… WOW_PROBE_LUA_AT=10` configures one, with the
/// per-entry bookkeeping set by hand.
fn probe(entries: u32, fired: u32, ready_at: f32) -> ProbeLua {
ProbeLua {
chunk: "ProbeLog('x')".into(),
at: 10.0,
again: 4.0,
entries,
fired,
ready_at,
..Default::default()
}
}
/// The first entry is still `WOW_PROBE_LUA_AT` on the PROCESS clock — the meaning every
/// recorded probe line in `decisions/` is tuned against.
#[test]
fn first_fire_waits_for_the_process_delay() {
let p = probe(1, 0, 2.0);
assert!(!probe_lua_due(&p, 9.9), "before WOW_PROBE_LUA_AT");
assert!(probe_lua_due(&p, 10.0), "at WOW_PROBE_LUA_AT");
}
/// Nothing fires before there is a world entry to fire into — the login and character
/// screens sit here however long they take.
#[test]
fn nothing_fires_before_the_first_entry() {
assert!(!probe_lua_due(&probe(0, 0, 0.0), 60.0));
}
/// One chunk per entry, and no more: the frames after a fire are not a second one.
#[test]
fn an_entry_takes_exactly_one_chunk() {
assert!(!probe_lua_due(&probe(1, 1, 2.0), 60.0));
assert!(!probe_lua_due(&probe(2, 2, 40.0), 90.0));
}
/// **The gap 2116 hit head-on**: the second entry re-arms, on its OWN clock. The process
/// delay is long past by the time a relog lands, so reusing it would fire the chunk into the
/// loading screen; the settle is measured from the moment that entry could take a chunk.
#[test]
fn a_later_entry_re_arms_on_its_own_clock() {
let p = probe(2, 1, 40.0);
assert!(
!probe_lua_due(&p, 41.0),
"still settling after the re-entry"
);
assert!(
probe_lua_due(&p, 44.0),
"WOW_PROBE_LUA_AGAIN past the re-entry"
);
}
}
+218 -34
View File
@@ -491,8 +491,47 @@ pub(crate) const REGISTERED: &[Registered] = &[
// default: our stop 1 scatters ×2 (32) where the reference's boot `frillDensity` is 16
// registered, 24 after `hwDetect` reads `VideoHardware.dbc` (row 170 on any D3D9-class part,
// 8 on the weakest). 24 is on no stop of ours; 1649 broke that tie toward the denser stop,
// because erring sparse is the worse failure for a knob about ground cover.
// because erring sparse is the worse failure for a knob about ground cover. **That divergence
// is a row of its own now** — 2151 registered the CVar it lives in, immediately below, so it
// is on the deviation inventory instead of only in this paragraph.
same("WorldDetail", "1"),
// The SAME knob in the reference's own unit (2151), and the CVar 1.12 actually registers for
// it: `0x68862e` passes name `0x8423d8` `"frillDensity"`, default string `0x864644` `"16"`,
// help "Terrain frill density", flags `1`, callback `0x688de0`, record `[0xc7f2f4]` (wow-re
// `re/cvar/cvar-register-sites.tsv` row 185). The value is **cells visited per chunk**: the
// callback clamps `[1, 256]` and hands the number to `0x6725a0` → `[0xc7b494]`, which bounds
// the detail-doodad scatter loop at `0x6bfcfb`/`0x6bff1c`. Our scatter is the byte-exact port
// of that loop, so `frillDensity` is not a new dial — it is the unit
// `benilla_formats::scatter_ground_doodads` has always counted in, and
// `ClutterConfig::frill_density` is the conversion.
//
// **Two names for one knob is the reference's own shape, not ours.** `SetWorldDetail 0x488dd0`
// writes this CVar per stop (16/32/48) alongside `SmallCull` — the row above is that stop,
// this row is what the stop wrote. Writing either moves the same ground cover here, and each
// keeps the clamp its own writer has: the stop's `[0, 2]`, the cells' `[1, 256]`. So a console
// `frillDensity 200` is honoured, exactly as it is there, and the panel row then reads
// off-grid — which is already this pair's stated posture for an off-grid multiplier.
//
// **It has a live Lua consumer, which is why it is registered now** (the module doc's rule):
// pfUI's `hdgraphic` replaces `GetWorldDetail` with `tonumber(GetCVar("frillDensity")) > 48`,
// and unregistered that is `nil > 48` — an error, not a fallback. Its extended arm drives the
// knob the other way, `ConsoleExec("frillDensity " .. (arg+1)*16)` up to 256, which is the
// whole reason the reference's range is wider than its slider.
//
// **The deviation is 1649's grass, finally visible as a row.** 1804 recorded it in prose and
// could not table it, because the CVar it is a deviation *in* was not registered: our stop 1
// scatters 32 where the reference's boot value is 16 registered, and 24 after `hwDetect`
// (`0x639a60` CVar::Sets sixteen video CVars from the matched `VideoHardware.dbc` row; field
// `+0x18` holds 8/12/16/24 across the table, 24 on the videoID 170 that the reference
// install's own `Logs/gx.log` resolves to). 24 is on no stop of ours.
deviates(
"frillDensity",
"32",
"16",
"1649/1804: the reference's registered 16 is stop 0 and its post-`hwDetect` 24 is on no \
stop at all, so every stop diverges; Medium (32) is the nearest one no sparser than a \
fresh install, and erring sparse is the worse failure for ground cover",
),
// ── The combat log's display ranges: the reference's own `0x8629e0` table, in yards ─────────
//
// Eight rows, registered by the reference in ONE place — `0x626d00`, a loop over the
@@ -713,6 +752,39 @@ pub(crate) const REGISTERED: &[Registered] = &[
// skip-default rule). No host knob: its consumers are the load walk (via the persisted value,
// [`CvarPersist::addon_version_check`]) and the gate's live per-query read in the VM.
same("checkAddonVersion", "1"),
// **Which graphics API this run is actually on** (2151) — 1.12's own `gxApi`, byte-read at
// `0x63a833`: name `0x842a64`, default string `0x864f7c` `"direct3d"`, help "graphics api",
// flags `3` (registered | latched), callback `0x63b030`, record `[0xc4ea94]`. There it is a
// real selector — `0x63a3c4` compares the live value case-insensitively against `"OpenGl"`
// (`0x842a5c`) and `GxDevCreate` builds `CGxDeviceD3d` on anything else — but no shipped
// `WTF` overrides it, so the stock client is always D3D9 and the whole GL arm is dead code
// image-wide (wow-re states this from a dozen nodes; `models/scratch/part-additive-combine.md`
// §"the gxApi selector" is the decoded compare).
//
// **Here it DESCRIBES, it does not steer** — the `gxColorBits`/`gxDepthBits` posture. benilla
// renders through wgpu, which has no D3D9 backend to name and no chooser to offer: the backend
// is the adapter's, picked before the first frame, and this row is the honest report of it
// (`wgpu::Backend::to_str` — `metal`, `vulkan`, `dx12`, `gl`). Answering `"direct3d"` on a Mac
// would be a name with no behaviour behind it, which is the one thing 1203 forbids outright.
//
// **Default EMPTY, and pushed live** — the `realmName` posture (1140), for the same reason:
// the value is a fact about the machine, written from `RenderAdapterInfo` the moment the VM's
// table is seeded, so the default only ever describes a client with no render adapter (a
// headless test). Inventing a backend for that case would be worse than admitting we have
// none. And because it is the machine's fact rather than the player's choice, it is
// **session-owned**: `SetCVar` consumes it and `config.toml` never carries it, so a GPU swap
// or a `WGPU_BACKEND` run cannot leave a stale renderer name pinned in the file.
//
// Its live Lua consumer is pfUI's system panel — `panel.lua:185` does
// `"|cffffffff" .. GetCVar("gxApi")` in a tooltip, which on a nil is a concat error rather
// than a blank row.
deviates(
"gxApi",
"",
"direct3d",
"2151: descriptive, not a selector — benilla renders through wgpu, which has no D3D9 \
backend and no chooser; the value is the live adapter's own and is never persisted",
),
// Vertical Sync — 1.12's own `gxVSync`, the Video Options checkbox at index 5
// (`OptionsFrame.lua`'s `OptionsFrameCheckButtons["VERTICAL_SYNC"]`, in the install's
// FrameXML). The knob is [`crate::video::VideoConfig::vsync`], which the window's
@@ -917,10 +989,17 @@ struct LocalConfig {
#[derive(Resource, Default)]
pub(crate) struct CvarPersist {
/// The file's `[cvars]` entries, verbatim spelling — the merge base every save starts from
/// (unknown keys ride through untouched, env-overridden keys keep their stored value).
/// (unknown keys ride through untouched, session-owned keys keep their stored value).
file: BTreeMap<String, String>,
/// Lowercased names whose value came from an env var this session (never saved).
env_overridden: HashSet<String>,
/// Lowercased names this SESSION owns rather than the player — never saved, and the file's
/// own entry for them is left exactly as it was found.
///
/// Almost all of them are env levers (`$WOW_UI_SCALE`, `$WOW_MSAA`, `$WOW_HOST`, …): a value
/// that stuck in `config.toml` would make an A/B or an instrument run sticky across
/// relaunches. `gxApi` (2151) is the member that is not — it is owned by the session because
/// it is a fact about the *machine* (the render adapter's backend), which is nobody's setting
/// to persist. The field was `env_overridden` until it gained that one.
session_owned: HashSet<String>,
/// The engine table has been registered + seeded — **once per VM**, not once per process
/// (decision 1290). A login builds a fresh VM, so the seed has to happen again: an
/// unregistered table answers every `GetCVar` with nil, and [`save_config`] composes
@@ -932,8 +1011,8 @@ pub(crate) struct CvarPersist {
}
impl CvarPersist {
/// The saved-base pairs a VM's table is seeded from — the file's entries minus the ones an
/// env var owns this session, which are never persisted.
/// The saved-base pairs a VM's table is seeded from — the file's entries minus the ones the
/// session owns ([`CvarPersist::session_owned`]), which are never persisted.
///
/// Extracted so `ui_script::lifecycle`'s world-entry edge can run the same seed before the
/// interface loads (decision 2115): the reference's own `UIOptionsFrame.xml` reads two CVars
@@ -944,7 +1023,7 @@ impl CvarPersist {
pub(crate) fn saved_base(&self) -> impl Iterator<Item = (String, String)> + '_ {
self.file
.iter()
.filter(|(k, _)| !self.env_overridden.contains(&k.to_ascii_lowercase()))
.filter(|(k, _)| !self.session_owned.contains(&k.to_ascii_lowercase()))
.map(|(k, v)| (k.clone(), v.clone()))
}
@@ -1180,6 +1259,13 @@ fn apply_string_valued(key: &str, name: &str, value: &str, knobs: &mut Knobs) ->
// posture — so the value is CONSUMED rather than falling to a numeric parse that can only
// reject it, and so a toggle still dirties the config.
"realmname" => {}
// Descriptive, not a knob (2151): the live value is the render adapter's backend, pushed
// into the table by [`sync_cvars`]. Claimed for the same reason `realmname` is — so a
// write is CONSUMED rather than falling to a numeric parse that can only reject it — and
// it goes no further: the reference latches this CVar for the next `GxDevCreate`, and we
// have no device to re-create it on. `load_config` marks it session-owned, so the write
// also never reaches `config.toml`.
"gxapi" => {}
_ => {}
}
true
@@ -1190,7 +1276,7 @@ fn apply_string_valued(key: &str, name: &str, value: &str, knobs: &mut Knobs) ->
/// registered row whose default does not parse as a number is named here
/// ([`every_string_valued_row_is_claimed_before_the_numeric_parse`]).
fn is_string_valued(key: &str) -> bool {
matches!(key, "gxresolution" | "realmlist" | "realmname")
matches!(key, "gxapi" | "gxresolution" | "realmlist" | "realmname")
}
/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this
@@ -1302,6 +1388,12 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// The panel's 0/1/2 lands as the density multiplier ×1/×2/×3; the clamp is the 1.12
// slider's own range (an off-grid hand-edit rides between stops, like every slider).
"worlddetail" => knobs.clutter.density = v.clamp(0.0, 2.0) + 1.0,
// The SAME knob in the reference's own cells-per-chunk (2151), with the reference's own
// `[1, 256]` clamp rather than the stop's — `ClutterConfig::set_frill_density` carries
// both, and `terrain_stream::rescatter_clutter` re-scatters the loaded tiles off the
// resulting density change exactly as it does for the row above (0992's setter law, which
// is the callback's own chunk rebuild).
"frilldensity" => knobs.clutter.set_frill_density(v),
// The two zoom indices (1131) clamp exactly like the client's `set_zoom` (`0x6daa10`:
// clamp at 5) — the widget clamps again on the way in, so a hand-edited level lands
// in range whichever path it takes.
@@ -1410,61 +1502,80 @@ fn zoom_index(v: f32) -> u8 {
v.clamp(0.0, f32::from(MINIMAP_ZOOM_LEVELS - 1)) as u8
}
/// The two registered spellings of `ClutterConfig::density`, lowercased (2151) — `WorldDetail`'s
/// panel stop and `frillDensity`'s cells-per-chunk.
///
/// Named as a **pair**, because that is the thing about them that is easy to get wrong: anything
/// which takes the knob for the session has to take *both* keys. `$WOW_CLUTTER_DENSITY` marked
/// only `worlddetail` for exactly as long as it was the only spelling, and the moment the second
/// row landed that would have let an A/B lever ride into `config.toml` through the other name and
/// pin itself on every later launch.
const CLUTTER_DENSITY_CVARS: [&str; 2] = ["worlddetail", "frilldensity"];
/// Startup: read `benilla-config/config.toml` (absent file = all defaults, not an error) and apply it
/// to the knob resources — except keys the environment overrides this session (their resources
/// already read the env var in their `Default`s). The VM does not exist yet; [`sync_cvars`]
/// seeds the table when it does.
/// to the knob resources — except the keys this session owns rather than the player
/// ([`CvarPersist::session_owned`]): an env lever's resource has already read the variable in its
/// `Default`, and `gxApi` is the machine's own. The VM does not exist yet; [`sync_cvars`] seeds
/// the table when it does.
fn load_config(mut persist: ResMut<CvarPersist>, mut params: KnobParams) {
let mut knobs = params.knobs();
if std::env::var_os("WOW_UI_SCALE").is_some() {
persist.env_overridden.insert("uiscale".into());
persist.session_owned.insert("uiscale".into());
}
if std::env::var_os("WOW_FARCLIP").is_some() {
persist.env_overridden.insert("farclip".into());
persist.session_owned.insert("farclip".into());
}
// The clutter A/B env drives the same knob WorldDetail lands on — same session-only law.
// The clutter A/B env drives the same knob WorldDetail lands on — same session-only law, over
// BOTH of that knob's spellings ([`CLUTTER_DENSITY_CVARS`]).
if std::env::var_os("WOW_CLUTTER_DENSITY").is_some() {
persist.env_overridden.insert("worlddetail".into());
for key in CLUTTER_DENSITY_CVARS {
persist.session_owned.insert(key.into());
}
}
// `$WOW_NOVSYNC=1` is the measurement uncap: session-only, exactly like the taste-iteration
// overrides above. Pinning it into the config would make an instrument run sticky.
if crate::video::novsync_env() {
persist.env_overridden.insert("gxvsync".into());
persist.session_owned.insert("gxvsync".into());
}
// The filter policy's A/B levers, under the same law: pricing mode 3 against mode 5 on one
// machine in one session is exactly what these are for, and a value that stuck in
// `config.toml` would silently denominate every later reading.
if std::env::var_os("WOW_TRILINEAR").is_some() {
persist.env_overridden.insert("trilinear".into());
persist.session_owned.insert("trilinear".into());
}
if std::env::var_os("WOW_ANISO").is_some() {
persist.env_overridden.insert("anisotropic".into());
persist.session_owned.insert("anisotropic".into());
}
// `$WOW_WIN`, a capture scenario, or any instrumented run owns the window's geometry for the
// session (decision 1627), so the two CVars that would otherwise move it mid-run are
// session-only under exactly the same law as the four above.
if crate::video::windowed_env() {
persist.env_overridden.insert("gxwindow".into());
persist.env_overridden.insert("gxresolution".into());
persist.session_owned.insert("gxwindow".into());
persist.session_owned.insert("gxresolution".into());
}
// `$WOW_MSAA` is the multisampling A/B lever (1629), session-only under the same law as every
// override above: a value pinned into the file would make a measurement sticky across
// relaunches.
if std::env::var_os("WOW_MSAA").is_some() {
persist.env_overridden.insert("gxmultisample".into());
persist.session_owned.insert("gxmultisample".into());
}
// `$WOW_RENDER_SCALE` is the render-scale A/B lever (1639), and doubly session-only: it is
// also the supersampling instrument this machine prices pixels with, and an instrument run
// that pinned 4× into the file would come back at 4× the next time the client opened.
if std::env::var_os("WOW_RENDER_SCALE").is_some() {
persist.env_overridden.insert("renderscale".into());
persist.session_owned.insert("renderscale".into());
}
// `$WOW_HOST` is the realmlist for the session (1667) — every probe, smoke run and harness leg
// sets it, and a value pinned into the file would silently repoint the player's client at
// whatever a test dialed. `Realmlist::default()` has already taken it; this keeps it off disk.
if std::env::var_os("WOW_HOST").is_some() {
persist.env_overridden.insert("realmlist".into());
persist.session_owned.insert("realmlist".into());
}
// The one member with no env var behind it (2151): `gxApi` reports the render adapter's
// backend, which is a fact about the machine rather than a setting the player chose. It is
// pushed live by `sync_cvars` on every launch, so persisting it could only ever write a name
// that the next launch overwrites — or, worse, a stale one that outlives the GPU it described.
persist.session_owned.insert("gxapi".into());
let cvars = match stored_config() {
StoredConfig::Absent => return, // no file, hermetic capture, or no install
StoredConfig::Bad(msg) => {
@@ -1485,8 +1596,8 @@ fn load_config(mut persist: ResMut<CvarPersist>, mut params: KnobParams) {
warn!("config: unknown cvar '{name}' — preserved, not applied");
continue;
}
if persist.env_overridden.contains(&key) {
info!("config: {name} overridden by env for this session (file value kept)");
if persist.session_owned.contains(&key) {
info!("config: {name} is owned by this session, not the file (file value kept)");
continue;
}
apply_to_knobs(name, value, &mut knobs);
@@ -1561,6 +1672,7 @@ fn sync_cvars(
script: Option<NonSendMut<UiScript>>,
mut persist: ResMut<CvarPersist>,
mut params: KnobParams,
adapter: Option<Res<bevy::render::renderer::RenderAdapterInfo>>,
) {
let Some(mut script) = script else {
return;
@@ -1625,7 +1737,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 67] = [
let session: [(&str, String); 69] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1673,8 +1785,12 @@ fn sync_cvars(
(crate::vplates::CVAR_FRIENDS, flag(plates.friends)),
// The session density on the panel scale (×1..×3 → 0..2). An env-driven off-grid
// multiplier seeds off-grid honestly — the dropdown shows the raw number, checks
// nothing (the 0959 out-of-range posture, dropdown-flavored).
// nothing (the 0959 out-of-range posture, dropdown-flavored). A console
// `frillDensity` past the top stop reads off-grid here for the same reason, which is
// the reference's own inconsistency between its two writers (2151).
("WorldDetail", (clutter.density - 1.0).to_string()),
// …and the same density in the reference's own cells-per-chunk (2151).
("frillDensity", clutter.frill_density().to_string()),
("ChatBubbles", flag(bubbles.all)),
("ChatBubblesParty", flag(bubbles.party)),
("profanityFilter", flag(text_filter.profanity)),
@@ -1683,6 +1799,15 @@ fn sync_cvars(
("gameTip", game_tip.next.to_string()),
("minimapZoom", minimap.outdoor.to_string()),
("minimapInsideZoom", minimap.inside.to_string()),
// **The machine's, not the player's** (2151): the live render backend, so `GetCVar`
// and pfUI's system tooltip answer what this run is actually on. `None` only in a
// headless app with no renderer, where the registered `""` stands and says so.
(
"gxApi",
adapter
.as_ref()
.map_or_else(String::new, |a| a.backend.to_str().to_string()),
),
("gxVSync", flag(video.vsync)),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
(
@@ -1833,21 +1958,21 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
if snapshot.is_empty() {
return; // a VM that never registered (a capture) has nothing to say about the file
}
persist.file = compose_file(&persist.file, &persist.env_overridden, &snapshot);
persist.file = compose_file(&persist.file, &persist.session_owned, &snapshot);
}
/// Compose the file to save: the previous file as the merge base, every registered var that
/// moved off its default written, every one back at its default removed — env-overridden keys
/// untouched (the session value is the env's, not the player's).
/// moved off its default written, every one back at its default removed — session-owned keys
/// untouched (that value is the env's or the machine's, not the player's).
fn compose_file(
previous: &BTreeMap<String, String>,
env_overridden: &HashSet<String>,
session_owned: &HashSet<String>,
snapshot: &[(String, String, String)],
) -> BTreeMap<String, String> {
let mut out = previous.clone();
for (name, value, default) in snapshot {
let key = name.to_ascii_lowercase();
if env_overridden.contains(&key) {
if session_owned.contains(&key) {
continue;
}
// Match any existing entry case-insensitively so a hand-edited spelling doesn't fork.
@@ -1901,7 +2026,7 @@ fn save_config(
persist.dirty = false; // nothing to save, and retrying every frame changes nothing
return;
}
let cvars = compose_file(&persist.file, &persist.env_overridden, &snapshot);
let cvars = compose_file(&persist.file, &persist.session_owned, &snapshot);
let body = toml::to_string(&LocalConfig {
cvars: cvars.clone(),
})
@@ -2017,6 +2142,8 @@ mod tests {
vec![
"SoundReverb",
"autoSelfCast",
"frillDensity",
"gxApi",
"gxColorBits",
"gxDepthBits",
"gxResolution",
@@ -2139,6 +2266,13 @@ mod tests {
// scale. The weld is the point: the CVar's default and the engine's must be the same
// ground cover, or a fresh config writes a row the world does not agree with.
assert_eq!(d["WorldDetail"], 1.0);
// …and its twin in the reference's own unit (2151) welds to it, not beside it: the two
// rows are one knob read two ways, so a default that disagreed would ship a client whose
// panel stop and whose cells-per-chunk describe different ground.
assert_eq!(
d["frillDensity"],
(d["WorldDetail"] + 1.0) * benilla_formats::FRILL_DENSITY as f32
);
// The bubble pair (1139) welds to BubbleConfig's defaults — both the binary's own since
// 1804 (`ChatBubbles` "1", `ChatBubblesParty` "0"; the party half was 0598's director pin).
let bubbles = BubbleConfig::default();
@@ -2393,6 +2527,47 @@ mod tests {
assert_eq!(knobs.clutter.density, 1.0);
assert!(apply_to_knobs("worlddetail", "7", &mut knobs));
assert_eq!(knobs.clutter.density, 3.0);
// frillDensity: the SAME field in the reference's cells-per-chunk (2151), and the two
// arms' clamps are deliberately different — the stop's `[0, 2]` above, the cells'
// `[1, 256]` here (callback `0x688de0`). The stops round-trip through both spellings,
// which is the property that makes them one knob rather than two that agree by habit.
assert!(apply_to_knobs("frillDensity", "48", &mut knobs));
assert_eq!(knobs.clutter.density, 3.0);
assert!(apply_to_knobs("frilldensity", "16", &mut knobs));
assert_eq!(knobs.clutter.density, 1.0);
// Past the top stop is HONOURED, not clamped to it — pfUI's `hdgraphic` drives exactly
// this, `ConsoleExec("frillDensity " .. (arg+1)*16)` for arg up to 15.
assert!(apply_to_knobs("frillDensity", "256", &mut knobs));
assert_eq!(knobs.clutter.density, 16.0);
// …and the reference's own bounds hold at both ends. `0` is NOT clutter-off: the callback
// pins it to 1, and turning grass off stays the `$WOW_CLUTTER_DENSITY` instrument's.
assert!(apply_to_knobs("frillDensity", "9000", &mut knobs));
assert_eq!(knobs.clutter.density, 16.0);
assert!(apply_to_knobs("frillDensity", "0", &mut knobs));
assert_eq!(knobs.clutter.density, 1.0 / 16.0);
// The row `GetCVar` answers is the same field seen the other way round.
assert!(apply_to_knobs("WorldDetail", "1", &mut knobs));
assert_eq!(knobs.clutter.density, 2.0);
assert_eq!(knobs.clutter.frill_density(), 32.0);
// And the pair is NAMED as a pair, in the registered spelling and the lowercased one, so
// `$WOW_CLUTTER_DENSITY` cannot take one spelling of this knob for the session and leave
// the other free to persist the lever (2151).
for key in CLUTTER_DENSITY_CVARS {
assert!(
REGISTERED.iter().any(|r| r.name.eq_ignore_ascii_case(key)),
"{key}: named as a clutter-density spelling but not registered"
);
assert_eq!(key.to_ascii_lowercase(), key, "the set is lowercased keys");
}
// Both of them reach the same field, from a state neither of them holds.
for key in CLUTTER_DENSITY_CVARS {
knobs.clutter.density = 0.5;
assert!(apply_to_knobs(key, "48", &mut knobs));
assert_ne!(knobs.clutter.density, 0.5, "{key}: reached no knob");
}
// Back to the shipped stop, so the rows after this one read the default ground cover.
assert!(apply_to_knobs("WorldDetail", "1", &mut knobs));
assert_eq!(knobs.clutter.density, 2.0);
// The zoom pair (1131): each index lands on its own field, clamped like `set_zoom`.
assert!(apply_to_knobs("minimapZoom", "5", &mut knobs));
assert_eq!(knobs.minimap.outdoor, 5);
@@ -2739,7 +2914,10 @@ mod tests {
.map(|r| r.name)
.collect();
strings.sort_unstable(); // the list is the claim, not where the rows sit in the table
assert_eq!(strings, vec!["gxResolution", "realmList", "realmName"]);
assert_eq!(
strings,
vec!["gxApi", "gxResolution", "realmList", "realmName"]
);
let default_of = |name: &str| {
REGISTERED
.iter()
@@ -2748,6 +2926,12 @@ mod tests {
.expect("registered")
};
assert_eq!(default_of("realmName"), "");
// **`gxApi` defaults EMPTY on the same argument** (2151): the value is the render
// adapter's backend, written by `sync_cvars` on every launch, so the default only ever
// describes a client with no renderer. Naming one — `"direct3d"` least of all, which is
// the reference's and is a backend wgpu does not have — would be a claim about a machine
// we have not looked at.
assert_eq!(default_of("gxApi"), "");
assert_eq!(
crate::video::parse_resolution(default_of("gxResolution")),
Some(crate::video::DEFAULT_WINDOWED)
+3 -2
View File
@@ -141,8 +141,9 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_CHAT").is_ok() {
app.add_plugins(crate::capture::ProbeChatPlugin);
}
// The probe-lua one-shot: `WOW_PROBE_LUA="CastSpell(…)"` runs a chunk in the live UI VM once
// in-world — the "press the button headlessly" instrument (see `capture::ProbeLuaPlugin`).
// The probe-lua driver: `WOW_PROBE_LUA="CastSpell(…)"` runs a chunk in the live UI VM once
// per world entry — the "press the button headlessly" instrument, re-armed across a relog
// so a probe can read the same value on both sides of one (see `capture::ProbeLuaPlugin`).
if std::env::var("WOW_PROBE_LUA").is_ok() {
app.add_plugins(crate::capture::ProbeLuaPlugin);
}
@@ -32,6 +32,14 @@ use super::Realms;
/// How long each leg may take before the walk gives up and says which one stalled.
const LEG_TIMEOUT: f32 = 25.0;
/// How long the walk waits to REACH character select before it says why it never started.
///
/// The whole login is inside this — dial, auth, realm list, roster — which is why it is not
/// [`LEG_TIMEOUT`], and why it exists at all: leg 0 was the one leg with no predecessor to name
/// it, so a walk that never reached the roster screen said nothing and ran out the caller's own
/// timeout instead. `scripts/smoke.sh` then reported "the realm walk did not finish within the
/// timeout", which is true of every possible failure and a cause for none of them.
const ENTRY_TIMEOUT: f32 = 60.0;
/// A beat with the dialog up, so a human watching sees it and a stuck frame has time to show.
const DWELL: f32 = 1.0;
@@ -42,6 +50,8 @@ pub(super) struct Walk {
/// The roster generation we are waiting to move past — a realm entered is a roster replaced.
rosters: u32,
seen: u32,
/// The one-shot startup refusal has run.
armed: bool,
}
/// The walk. Every leg names itself on the way in, so a timeout says which boundary stalled
@@ -67,6 +77,42 @@ pub(super) fn debug_realm_smoke(
error!("realm-smoke: FAILED — {why}");
exit.write(AppExit::error());
};
// **The walk needs the roster SCREEN, and two envs structurally deny it** — so refuse here,
// on the first frame, instead of spending a login and a timeout discovering it. `WOW_CHAR` is
// the enter-the-world fast path and `WOW_RIG` seats its own derived body; either one answers
// the very roster this walk stands on, and neither can be waited out.
//
// Inherited rather than typed is the case worth naming. `scripts/smoke.sh` runs this leg out
// of the same shell as everything else and now scrubs `WOW_*` before it starts — but this
// check is the guard for the leg itself, not for that one caller: an agent reproducing a
// realm-list report by hand types `WOW_REALM_SMOKE=1 cargo run` into a shell that has been
// exporting probe credentials all session, and gets told why in the first second.
if !walk.armed {
walk.armed = true;
if let Some((key, value)) = ["WOW_CHAR", "WOW_RIG"]
.into_iter()
.find_map(|k| std::env::var_os(k).map(|v| (k, v)))
{
fail(&format!(
"{key}={} seats a body — this walk drives CHARACTER SELECT and there is no roster \
screen to drive once one is seated. Unset it (`scripts/smoke.sh` does).",
value.to_string_lossy(),
));
walk.phase = u8::MAX;
return;
}
}
if walk.phase == 0 && now - walk.mark > ENTRY_TIMEOUT {
fail(&format!(
"never reached character select in {ENTRY_TIMEOUT:.0}s (state {:?}, {} characters, \
{} realms) — nothing was driven, so the fault is upstream of the walk",
state.get(),
roster.chars.len(),
realms.realms.len(),
));
walk.phase = u8::MAX;
return;
}
if walk.phase > 0 && now - walk.mark > LEG_TIMEOUT {
fail(&format!(
"leg {} stalled for {LEG_TIMEOUT:.0}s (state {:?}, dialog {}, rosters {}/{})",
+87 -29
View File
@@ -123,6 +123,14 @@ fn rows_of_kind(kind: &str) -> Vec<Row> {
if f.len() < 9 || f[4] != kind || f[8] != "exact" {
return None;
}
// **`agree` is necessary and not sufficient, and the second condition is the table's
// own too** (2150 + wow-re `scratch/binding-kinds-stack-overwrite.md`). The kinds
// come from tracing each binding's `lua_push*` calls; a row whose notes carry
// `delegated-push` has a CALLEE that pushes on its own behalf, which the trace sees
// one level too high up, so its tuple covers the binding's own ops only. `eax` is
// unaffected, so arity stays gated on every row — it is the kinds claim alone that
// narrows.
let delegated = f.len() > 11 && f[11].contains("delegated-push");
Some(Row {
name: f[0].to_string(),
table_va: f[3].to_string(),
@@ -130,7 +138,7 @@ fn rows_of_kind(kind: &str) -> Vec<Row> {
// ADVISORY unless `kinds_conf == agree` — the table's own column contract. A row
// that did not agree carries no kinds here at all, so nothing downstream can read
// one by accident.
kinds: if f.len() > 10 && f[10] == "agree" {
kinds: if f.len() > 10 && f[10] == "agree" && !delegated {
f[9].to_string()
} else {
String::new()
@@ -277,30 +285,70 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
/// whole time. `gcinfo … 2 exact (number,number) agree` and `collectgarbage … 0 exact () agree`
/// were both in this file before anyone answered either one wrong.
///
/// **Only the no-argument-safe names are probed, by an explicit list rather than a prefix rule.**
/// The global arm can filter to query verbs because `Get*`/`Is*` names them; the base library has
/// no such convention, and calling it blind means calling `error`, `pcall` and `setfenv` for their
/// arities. So the list below is what is safe to call with nothing, and everything else is
/// uncovered — a loss of coverage, never a wrong assertion, exactly as the global arm's own filter
/// is. `seterrorhandler`/`setfenv`/`setglobal` are excluded for mutating the VM, not for raising.
const BASELIB_NO_ARG_PROBES: &[&str] = &[
"collectgarbage",
"date",
/// **The base library is probed by an explicit ladder — a name and the arguments to call it
/// with** — rather than a prefix rule. The global arm can filter to query verbs because
/// `Get*`/`Is*` names them; the base library has no such convention, and calling it blind means
/// calling `error`, `pcall` and `setfenv` for their arities.
///
/// ## Why an argument ladder is safe HERE and was not for the global arm
///
/// 2129 §Why left the ladder question open on exactly one hazard: *a wrong-but-plausible argument
/// can produce a legitimate kind tuple outside the reference's reachable set, which is a false
/// positive — the most expensive gate failure.* That hazard is real for a game binding, where the
/// argument's meaning is the unknown (what index does `GetInboxItem` accept, and what does the
/// mailbox hold when it does?).
///
/// It does not transfer to the base library, and the reason is worth stating rather than
/// assuming: these are stock Lua 5.0 functions whose argument **types** are fixed by the language
/// and whose bodies were read at the bytes. `type` takes any value, `rawget` a table and a key,
/// `pairs` a table. There is no state to be in the wrong shape, so the argument below is not a
/// guess about the reference — it is the only shape the function accepts.
///
/// The arguments are chosen to **discriminate**, not merely to avoid a raise: `assert` is called
/// with TWO arguments precisely because its `arity 1 exact` row is only falsifiable that way — a
/// one-argument call answers one value in both dialects and proves nothing. (That probe is what
/// found 5.1's `return lua_gettop(L)` still in place here; see `script::lua50::install_assert`.)
///
/// Excluded, and why: `error`/`pcall`/`xpcall` and the rows whose `arity_conf` is not `exact`
/// (`next`, `unpack`, `ipairs`, `loadstring`) are filtered out upstream anyway; `setfenv` is left
/// out because it mutates the environment every later probe runs in, which is a different risk
/// from raising.
const BASELIB_PROBES: &[(&str, &str)] = &[
("collectgarbage", ""),
("date", ""),
// The five shipped stubs — `xor eax,eax; ret` in the image (`lua-dialect.md` §3a), so calling
// one is as safe here as it is there, and their `0 exact ()` rows are still worth holding.
"debugbreak",
"debugdump",
"debuginfo",
"debugload",
"debugprint",
"debugprofilestart",
"debugprofilestop",
"debugstack",
"debugtimestamp",
"gcinfo",
"geterrorhandler",
"getfenv",
"time",
("debugbreak", ""),
("debugdump", ""),
("debuginfo", ""),
("debugload", ""),
("debugprint", ""),
("debugprofilestart", ""),
("debugprofilestop", ""),
("debugstack", ""),
("debugtimestamp", ""),
("gcinfo", ""),
("geterrorhandler", ""),
("getfenv", ""),
("time", ""),
// ── the ladder (2136's "left open", closed) ──────────────────────────────────────────────
// TWO arguments on purpose: the row is `arity 1 exact` and only a multi-argument call can
// tell 5.0's `lua_settop(L,1); return 1` from 5.1's `return lua_gettop(L)`.
("assert", "1, 2"),
("getglobal", "\"BenillaShapeGateAbsent\""),
("getmetatable", "{}"),
("pairs", "{}"),
("rawequal", "1, 1"),
("rawget", "{}, 1"),
("rawset", "{}, 1, 1"),
// Both write, and both are namespaced or self-restoring so the VM the next probe sees is the
// one it would have seen anyway.
("seterrorhandler", "geterrorhandler()"),
("setglobal", "\"BenillaShapeGateProbe\", 1"),
("setmetatable", "{}, nil"),
("tonumber", "\"1\""),
("tostring", "nil"),
("type", "nil"),
];
/// The base library's own shrinking list — same rules as [`NOT_YET_ASSERTED`]. **Empty.**
@@ -316,16 +364,17 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
);
let s = benilla_ui::script::UiScript::new().expect("VM");
let mut checked = 0usize;
let mut kinds_checked = 0usize;
let mut mismatches: Vec<String> = Vec::new();
for r in &rows {
if !BASELIB_NO_ARG_PROBES.contains(&r.name.as_str()) {
let Some((_, args)) = BASELIB_PROBES.iter().find(|(n, _)| *n == r.name) else {
continue;
}
};
let name = &r.name;
let probe = format!(
"if type({name}) ~= 'function' then return -1 end \
local ok, n = pcall(function() return select('#', {name}()) end) \
local ok, n = pcall(function() return select('#', {name}({args})) end) \
if not ok then return -1 end return n"
);
let got: i64 = match s.eval(&probe) {
@@ -348,17 +397,19 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
continue;
}
// Kinds, wherever the table calls them trustworthy — the same `kinds_conf = agree` rule
// the global arm uses, and the column that types `gcinfo` as `(number,number)`.
// the global arm uses, and the column that types `gcinfo` as `(number,number)`. The
// `delegated-push` narrowing is applied once, in [`rows_of_kind`], for every arm.
if r.kinds.is_empty() {
continue;
}
let Ok(kinds) = s.eval::<String>(&format!(
"local t = {{ {name}() }} local out = '' \
"local t = {{ {name}({args}) }} local out = '' \
for i = 1, {} do out = out .. (i > 1 and ',' or '') .. type(t[i]) end return out",
r.arity
)) else {
continue;
};
kinds_checked += 1;
let got_tuple = format!("({kinds})");
let acceptable = r.kinds.split('|').map(str::trim).any(|alt| {
alt == got_tuple
@@ -386,9 +437,16 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
// A floor, so a change that stops the probe measuring anything fails loudly.
// A floor, not a target: raise it when coverage rises, never lower it to fit a change.
assert!(
checked >= 15,
checked >= 28,
"the base-library gate measured only {checked} bindings"
);
// **The kinds half gets its own floor.** Arity coverage says nothing about how many rows the
// kinds comparison actually ran on, and the `kinds_conf`/`delegated-push` narrowing is exactly
// the kind of change that can quietly take it to zero while the arity floor stays green.
assert!(
kinds_checked >= 16,
"the base-library gate compared kinds on only {kinds_checked} bindings"
);
assert!(
mismatches.is_empty(),
"{} of {checked} probed base-library bindings diverge:\n {}",
+1 -1
View File
@@ -67,7 +67,7 @@ use bevy::prelude::*;
use bevy::window::{MonitorSelection, PresentMode, PrimaryWindow, WindowMode, WindowResolution};
/// `$WOW_NOVSYNC=1` — the session-only measurement override. It wins over the config for the run
/// and never reaches `config.toml` (registered in [`crate::cvars`]'s `env_overridden` set), so a
/// and never reaches `config.toml` (registered in [`crate::cvars`]'s `session_owned` set), so a
/// headless FPS-journal run can uncap without making the player's setting sticky.
pub(crate) fn novsync_env() -> bool {
std::env::var("WOW_NOVSYNC").as_deref() == Ok("1")
+19 -5
View File
@@ -186,10 +186,23 @@ impl BlizzardRandomizer {
}
/// The client's `frillDensity` CVar default — the number of (randomly-chosen, with-repeats) cells the
/// client visits per chunk (RE'd from `WoW.exe`; range 1..256, `SetWorldDetail` sets 16/32/48). It is
/// **not** all 64 cells — that's what makes the real clutter patchy (some cells hit 2–3×, ~50 left bare)
/// rather than an even carpet.
const FRILL_DENSITY: u32 = 16;
/// client visits per chunk. It is **not** all 64 cells — that's what makes the real clutter patchy
/// (some cells hit 2–3×, ~50 left bare) rather than an even carpet.
///
/// **Byte-read, not chosen**: `CVar::Register 0x63db90` at `0x68862e` passes name `0x8423d8`
/// `"frillDensity"`, default string `0x864644` `"16"`, help "Terrain frill density", record
/// `[0xc7f2f4]` (wow-re `re/cvar/cvar-register-sites.tsv` row 185). `SetWorldDetail 0x488dd0`
/// writes 16/32/48 over it per slider stop, so a stop IS a multiple of this constant — which is
/// why [`scatter_ground_doodads`] takes a multiplier and the CVar host converts at its edge
/// ([`crate::ground_effects::FRILL_DENSITY_MAX`] is that conversion's other bound).
pub const FRILL_DENSITY: u32 = 16;
/// The top of the reference's own `frillDensity` range — its change callback `0x688de0` clamps the
/// written value to `[1, 256]`, so this is the densest ground cover the real client will scatter
/// (its renderer saturates first: `0x6b1d4b` computes the detail-doodad instance count as
/// `frillDensity << 6` capped at `0x2000`, reached at 128). Named here because both the scatter's
/// own clamp below and the `frillDensity` CVar arm in `benilla-app` have to agree on it.
pub const FRILL_DENSITY_MAX: u32 = 256;
/// Scatter ground-clutter doodads over one terrain chunk — a **bit-exact port of the client's
/// `CMapChunk::CreateDetailDoodads`** (RE'd from `WoW.exe` @ `0x6bfc10`). One RNG stream per chunk,
@@ -219,7 +232,8 @@ pub fn scatter_ground_doodads(
if chunk.positions.len() < 145 || density_scale <= 0.0 {
return Vec::new();
}
let frill = ((FRILL_DENSITY as f32 * density_scale).round() as i64).clamp(0, 256) as u32;
let frill = ((FRILL_DENSITY as f32 * density_scale).round() as i64)
.clamp(0, i64::from(FRILL_DENSITY_MAX)) as u32;
if frill == 0 {
return Vec::new();
}
+1 -1
View File
@@ -132,7 +132,7 @@ pub use item_random_properties::{
mod ground_effects;
pub use ground_effects::{
load_ground_effect_catalog, scatter_ground_doodads, GroundDoodadPlacement, GroundEffect,
GroundEffectCatalog,
GroundEffectCatalog, FRILL_DENSITY, FRILL_DENSITY_MAX,
};
mod light;
pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
+24 -3
View File
@@ -393,9 +393,18 @@ impl Loader<'_> {
/// source snippet, and the separator is `\` because that is the shape an addon parsing its own
/// `debugstack()` matches against (`crate::script::addon_chunk_name`).
fn run(&self, chunk: &[u8], path: &str) -> mlua::Result<()> {
// A FILE chunk is `"@%s"` (`0x8716e0`) over the resolved path — `0x704bc0` builds it that
// way for every `.lua` the loader touches, and `luaO_chunkid`'s `@` branch prints it
// plainly (wow-re `scratch/include-lua-dispatch.md` §7).
self.run_named(chunk, &format!("@{}", path.replace('/', "\\")))
}
/// Run a chunk under a chunk name given **whole** — for the producers whose name is not a
/// path. See the inline `<Script>` arm in [`Self::load_doc`].
fn run_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
self.lua
.load(chunk)
.set_name(format!("@{}", path.replace('/', "\\")))
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.exec()
}
@@ -516,8 +525,20 @@ impl Loader<'_> {
// this would make every reported line a confident, checkable lie.
let mut chunk = "\n".repeat(line.saturating_sub(1) as usize).into_bytes();
chunk.extend_from_slice(body.as_bytes());
let path = self.path.clone();
if let Err(e) = self.run(&chunk, &path) {
// **Not a file name.** The reference names an inline `<Script>` body
// `"%s:<Scripts>"` (`0x871074`) over the XML document's own path, with NO `@`
// — `0x6ee0ed push ebx` / `0x6ee0ee push 0x871074` → `0x6ee0ff` sprintf →
// `0x6ee10f call 0x704cd0`. So `luaO_chunkid` takes its third branch and the
// frame reads `[string "Interface\FrameXML\Foo.xml:<Scripts>"]`, not a path.
// Writing `@path` here made an inline block indistinguishable from the `.lua`
// file beside it — including to the addons that split a `debugstack` frame on
// `\AddOns\`.
//
// The padding above stays (1214): the reference's line numbers here are
// body-relative, ours are the XML file's, and the file's are the ones a reader
// can go and check — the name now carries the path that makes them checkable.
let name = format!("{}:<Scripts>", self.path.replace('/', "\\"));
if let Err(e) = self.run_named(&chunk, &name) {
self.report.errors.push(format!("inline <Script>: {e}"));
}
}
+32 -5
View File
@@ -17,7 +17,22 @@ impl Loader<'_> {
dbg: &str,
) -> Option<Function> {
let mut onload = None;
// **The chunk name of an XML handler body is `"<GetName()>:<Handler>"`, read off the
// WRAPPER — not off the loader's diagnostic string.** `0x7025fd` calls the script object's
// `GetName` through its own vtable (`[[esi]+4]`), falls back to `<unnamed>` (`0x84c7f0`)
// at `0x702611` when that is NULL, formats `"%s:%s"` (`0x872a28`) at `0x70261b`, and hands
// the result to `0x704c70` at `0x70263c` as the chunk name.
//
// Reading it here rather than plumbing `dbg` down is the fidelity: `dbg` is a *sentence*
// on the `CreateFrame` path (`CreateFrame("Button", "Foo", inherits="Bar")`), which made
// every template-inherited handler's errors and `debugstack` frames read as one, and an
// unnamed element wrote `<Button>` where the image writes `<unnamed>`.
for scripts in children_named(el, "Scripts") {
let owner = wrapper
.call_method::<Option<String>>("GetName", ())
.ok()
.flatten()
.unwrap_or_else(|| "<unnamed>".to_string());
for handler in &scripts.children {
let name = handler.tag.clone();
// **AN EMPTY BODY IS A CLEAR, AND WHITESPACE IS NOT EMPTY** — the two byte
@@ -50,7 +65,7 @@ impl Loader<'_> {
let func = if cleared {
None
} else {
match self.compile_handler(handler, &name, dbg) {
match self.compile_handler(handler, &name, &owner, dbg) {
Some(f) => Some(f),
None => continue,
}
@@ -145,12 +160,24 @@ impl Loader<'_> {
///
/// So the fallback lives **here**, once, rather than as `(self or this)` sprinkled through
/// ~20 handler bodies: any XML body in any file — ours or an addon's — now works under both
/// callers. It is written on line 1 of the wrapper on purpose, so `{body}` still starts at
/// line 2 and every traceback line number keeps pointing at the source the author wrote.
/// callers.
///
/// ## The prologue shares the body's first line, and that is a line-number fix
///
/// The reference compiles the handler body **as the chunk**: `0x704c70` takes the raw text and
/// `luaL_loadbuffer`s it, so body line *n* is chunk line *n*. Our wrapper used to end with a
/// newline, which pushed every body line down by one — a `<OnClick>` whose code sat on the
/// element's second line reported `:3:` where the reference reports `:2:`. Keeping the
/// prologue on the body's own first line restores the reference's numbering exactly; the
/// closing `end` is the only line we add, and it lands *after* the body.
///
/// `owner` is the frame's `GetName()` (or `<unnamed>`) — see [`Self::apply_scripts`] for the
/// bytes; `dbg` names the load for the report and is deliberately not the same string.
pub(super) fn compile_handler(
&mut self,
handler: &Element,
name: &str,
owner: &str,
dbg: &str,
) -> Option<Function> {
// **The raw body, NOT a trimmed one** — 1.12 hands `node->text` straight to
@@ -160,12 +187,12 @@ impl Loader<'_> {
let body = handler.body.as_str();
if !body.is_empty() {
let src = format!(
"return function(self, ...) if self == nil then self = this end\n{body}\nend"
"return function(self, ...) if self == nil then self = this end {body}\nend"
);
match self
.lua()
.load(&src)
.set_name(format!("{dbg}:{name}"))
.set_name(format!("{owner}:{name}"))
.set_mode(mlua::ChunkMode::Text)
.eval::<Function>()
{
+104 -3
View File
@@ -846,6 +846,105 @@ mod loader_tests {
);
}
/// **An XML handler body's chunk name is `"<GetName()>:<Handler>"`, and its lines are the
/// body's own.** `0x7025fd` calls the script object's `GetName` through its vtable, falls back
/// to `<unnamed>` (`0x84c7f0`) at `0x702611`, formats `"%s:%s"` (`0x872a28`) at `0x70261b`,
/// and hands that to `0x704c70` at `0x70263c` — which `luaL_loadbuffer`s the raw body, so body
/// line *n* is chunk line *n*.
///
/// Three producers, because they used to disagree. A named element was already right; an
/// unnamed one wrote `<Button>` where the image writes `<unnamed>`; and a `CreateFrame` off a
/// template wrote the *whole call* —
/// `CreateFrame("Button", "Made", inherits="ProbeTmpl"):OnClick` — into every error message
/// and every `debugstack` frame that handler produced.
#[test]
fn an_xml_handler_chunk_is_named_by_its_frame_and_handler() {
let s = UiScript::new().unwrap();
let doc = parse(
"<Ui>\n\
<Button name=\"NamedBtn\">\n\
<Scripts>\n\
<OnClick>\n\
error(\"boom\")\n\
</OnClick>\n\
</Scripts>\n\
</Button>\n\
<Button>\n\
<Scripts>\
<OnLoad>BenillaAnon = this</OnLoad>\
<OnClick>error(\"anon\")</OnClick>\
</Scripts>\n\
</Button>\n\
<Button name=\"ProbeTmpl\" virtual=\"true\">\n\
<Scripts><OnClick>error(\"tmpl\")</OnClick></Scripts>\n\
</Button>\n\
</Ui>",
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s.run(r#"Made = CreateFrame("Button", "Made", nil, "ProbeTmpl")"#)
.unwrap();
let raised = |lua: &str| -> String {
s.eval::<String>(&format!("local ok, e = pcall({lua}) return tostring(e)"))
.unwrap()
};
// A named element. The body sits on the element's SECOND line, and the reference numbers a
// handler chunk from the body's own first line — so `:2:`, never `:3:`. Our wrapper used
// to end with a newline, which pushed every body line down by one.
let named = raised(r#"NamedBtn:GetScript("OnClick")"#);
assert!(
named.starts_with(r#"[string "NamedBtn:OnClick"]:2: boom"#),
"{named}"
);
// A `CreateFrame` instance is named by the INSTANCE — what `GetName()` answers — not by
// the call that made it and not by the template it wore.
let made = raised(r#"Made:GetScript("OnClick")"#);
assert!(
made.starts_with(r#"[string "Made:OnClick"]:1: tmpl"#),
"{made}"
);
// The nameless element takes the image's own fallback literal.
let anon = raised(r#"BenillaAnon:GetScript("OnClick")"#);
assert!(
anon.starts_with(r#"[string "<unnamed>:OnClick"]:1: anon"#),
"{anon}"
);
}
/// **An inline `<Script>` body is `"<xml path>:<Scripts>"`, with no `@`** — `0x6ee0ed push ebx`
/// (the document's own path) / `0x6ee0ee push 0x871074` (`"%s:<Scripts>"`) → `0x6ee0ff` sprintf
/// → `0x6ee10f call 0x704cd0`. `luaO_chunkid` therefore takes its *third* branch and the frame
/// reads `[string "…"]`, not a bare path — which is how a reader (and every addon that splits a
/// `debugstack` frame on `\AddOns\`) tells an inline block from the `.lua` file beside it.
///
/// `<Script file="…">` keeps the file form, `"@%s"` (`0x8716e0`), and both are asserted here so
/// the two arms cannot drift into each other.
#[test]
fn an_inline_script_chunk_is_named_for_the_document_not_as_a_file() {
let s = UiScript::new().unwrap();
let doc = parse(
"<Ui>\n<Script file=\"Sibling.lua\"/>\n<Script>\nBenillaInline = ({}).missing.deeper\n</Script>\n</Ui>",
);
let report = load_in(&s, &doc, "Interface/AddOns/Probe/Probe.xml", &|p: &str| {
(p == "Interface/AddOns/Probe/Sibling.lua")
.then(|| b"BenillaFile = ({}).missing.deeper".to_vec())
});
assert_eq!(report.errors.len(), 2, "{:?}", report.errors);
assert!(
report.errors[0].contains("Interface\\AddOns\\Probe\\Sibling.lua:1:"),
"a <Script file=> chunk is still a plain `@`-path frame: {}",
report.errors[0]
);
assert!(
report.errors[1]
.contains("[string \"Interface\\AddOns\\Probe\\Probe.xml:<Scripts>\"]:4:"),
"an inline body is a `[string \"…\"]` frame naming the document: {}",
report.errors[1]
);
}
#[test]
fn button_xml_extras_apply() {
let mut s = UiScript::new().unwrap();
@@ -2213,6 +2312,8 @@ mod chunk_name_tests {
let report = load_in(&s, &doc, "Bagnon/src/main.xml", &no_files);
let err = report.errors.join("\n");
// The file, in the shape an addon's own `debugstack()` matches (backslashes, no `@`).
// The `:<Scripts>` suffix and the `[string "…"]` wrapper are the reference's own
// (`"%s:<Scripts>"` `0x871074`, `luaO_chunkid`'s third branch), not decoration of ours.
assert!(
err.contains("Bagnon\\src\\main.xml:"),
"the raise must name the document, got: {err}"
@@ -2220,7 +2321,7 @@ mod chunk_name_tests {
// Line 5 of the literal above is `error("boom")` — the body starts on line 4 and the
// padding carries it there. Without the pad this reads `:2:`.
assert!(
err.contains("main.xml:5:"),
err.contains("main.xml:<Scripts>\"]:5:"),
"the line must be the FILE's line, not the block's, got: {err}"
);
}
@@ -2239,7 +2340,7 @@ mod chunk_name_tests {
let report = load_in(&s, &doc, "Addon/outer.xml", &files);
let err = report.errors.join("\n");
assert!(
err.contains("Addon\\sub\\inner.xml:3:"),
err.contains("Addon\\sub\\inner.xml:<Scripts>\"]:3:"),
"the INCLUDED file and its line, not the includer's: {err}"
);
assert!(
@@ -2269,7 +2370,7 @@ mod chunk_name_tests {
let report = load_in(&s, &doc, "Ours/Bag.xml", &no_files);
let err = report.errors.join("\n");
assert!(
err.contains("Ours\\Bag.xml:6:"),
err.contains("Ours\\Bag.xml:<Scripts>\"]:6:"),
"line 6 is `error(\"cdata boom\")` in the literal above, got: {err}"
);
}
+141 -58
View File
@@ -99,28 +99,101 @@ pub struct AddOnInfo {
pub chain: bool,
}
/// Resolve a Lua index-or-name argument to a position in the registry.
/// The verbs' own `Usage:` literals, read out of `.data` rather than reconstructed — the message
/// an addon's error handler prints, so the spelling is the contract. `%s`-free: each is a whole
/// string in the image.
const USAGE_INFO: &str = "Usage: GetAddOnInfo(index or \"name\")"; // 0x842d68
const USAGE_METADATA: &str = "Usage: GetAddOnMetadata(index or \"name\", \"variable\")"; // 0x842d90
const USAGE_DEPENDENCIES: &str = "Usage: GetAddOnDependencies(index or \"name\")"; // 0x842dc8
const USAGE_ENABLE: &str = "Usage: EnableAddOn(index or \"name\")"; // 0x842df8
const USAGE_DISABLE: &str = "Usage: DisableAddOn(index or \"name\")"; // 0x842e1c
const USAGE_LOAD_ON_DEMAND: &str = "Usage: IsAddOnLoadOnDemand(index or \"name\")"; // 0x842e44
const USAGE_LOADED: &str = "Usage: IsAddOnLoaded(index or \"name\")"; // 0x842e70
const USAGE_LOAD: &str = "Usage: LoadAddOn(index or \"name\")"; // 0x842e98
/// **The `index or "name"` argument every in-game addon verb opens with — and its two raises.**
///
/// The reference's verbs all take either, and an addon in the wild passes whichever it has —
/// `IsAddOnLoaded("Bagnon")` from a dependant, `GetAddOnInfo(i)` from a list walker. Names compare
/// case-insensitively for the same reason dependency lookup does: a `.toc` may spell a name any way.
fn resolve(model: &Model, key: &Value) -> Option<usize> {
let by_index = |n: i64| usize::try_from(n).ok()?.checked_sub(1);
/// All eight bindings (`GetAddOnInfo 0x48e390`, `GetAddOnMetadata 0x48e530`,
/// `GetAddOnDependencies 0x48e5e0`, `EnableAddOn 0x48e690`, `DisableAddOn 0x48e760`,
/// `IsAddOnLoadOnDemand 0x48e840`, `IsAddOnLoaded 0x48e8e0`, `LoadAddOn 0x48e980`) open with the
/// *same eleven instructions*, and the shape has three arms, not one:
///
/// ```text
/// edx=1; call 0x6f34d0 ; lua_isnumber — tag 3 OR a numeric string
/// je <string>
/// call 0x6f3620 / 0x40a2b0 ; lua_tonumber, then double->int32 TRUNCATING toward zero
/// dec eax ; the index is 1-BASED
/// call 0x51df00 ; name-at(index0); NULL is the bounds failure
/// jne <shared>
/// call 0x51def0 ; the addon COUNT
/// luaL_error("AddOn index must be in the range of 1 to %d" /*0x837d70*/, count)
/// <string>:
/// call 0x6f3510 ; lua_isstring
/// je <usage>
/// call 0x6f3690 ; lua_tostring — used AS GIVEN, never validated here
/// <usage>:
/// luaL_error("Usage: <Verb>(index or \"name\")")
/// ```
///
/// **`luaL_error 0x6f4940` does not return** ([`super::binding_abi`]): it longjmps, so both arms
/// abandon the caller's statement. We answered a placeholder, a `nil` or a silent no-op for every
/// one of them — the failure mode that module's header names: *"a client that answers `nil` there
/// keeps executing a statement the real client never finishes."*
///
/// Three consequences that are easy to get wrong and are all byte-read:
///
/// - **The bound is UNSIGNED** (`0x51df00 cmp ecx,[0xbe1b90]; jb`), so `f(0)` decrements to
/// `0xFFFFFFFF` and raises by the same route as `f(count+1)`.
/// - **A numeric STRING is an INDEX**, because `0x6f34d0` coerces one — `GetAddOnInfo("2")` is the
/// second addon, not an addon named `2`.
/// - **A name is never checked against the registry by this prologue.** Each verb's own body
/// decides what a miss means, and they do not agree: `GetAddOnInfo` echoes the name back with
/// placeholders, `IsAddOnLoaded` answers nil, `GetAddOnDependencies` answers nothing.
enum AddonKey {
/// A bounds-checked 0-based position in the registry.
Index(usize),
/// A name exactly as the caller spelled it — unvalidated, per the prologue above.
Name(String),
}
/// The prologue itself. `usage` is the verb's own `.data` literal, verbatim.
fn addon_key(lua: &Lua, model: &Model, key: &Value, usage: &'static str) -> mlua::Result<AddonKey> {
// `lua_isnumber 0x6f34d0` — and `coerce_number` is its exact analogue, numeric strings and all.
if let Some(n) = lua.coerce_number(key.clone())? {
// `_ftol 0x40a2b0` truncates toward zero (not `floor`), then `dec eax`, then an unsigned
// compare — so the whole out-of-range family collapses onto one `u32` bound test.
let index0 = (n as i64 as i32).wrapping_sub(1) as u32 as usize;
return match index0 < model.addons.len() {
true => Ok(AddonKey::Index(index0)),
false => Err(mlua::Error::RuntimeError(format!(
"AddOn index must be in the range of 1 to {}",
model.addons.len()
))),
};
}
// `lua_isstring 0x6f3510` → `lua_tostring 0x6f3690`. Lossy for the reason 1193/2138 give: a
// 5.0 string is bytes, and a stray one costs a glyph, not the call.
match key {
// 1-based, like every indexed API in the tree. A Lua number literal arrives as either
// Integer or Number depending on how it was written, so both arms are real.
Value::Integer(i) => by_index(*i),
Value::Number(n) => by_index(*n as i64),
Value::String(s) => {
let want = s.to_str().ok()?;
model
.addons
.iter()
.position(|a| a.name.eq_ignore_ascii_case(&want))
}
_ => None,
Value::String(s) => Ok(AddonKey::Name(s.to_string_lossy())),
_ => Err(mlua::Error::RuntimeError(usage.into())),
}
}
/// A name → a position, folded. Names compare case-insensitively for the same reason dependency
/// lookup does: a `.toc` may spell a name any way.
fn by_name(model: &Model, name: &str) -> Option<usize> {
model
.addons
.iter()
.position(|a| a.name.eq_ignore_ascii_case(name))
}
/// A validated key → a row, for the verbs whose miss answer is "not found" rather than a raise.
fn row_of(model: &Model, key: &AddonKey) -> Option<usize> {
match key {
AddonKey::Index(i) => Some(*i),
AddonKey::Name(n) => by_name(model, n),
}
.filter(|i| *i < model.addons.len())
}
/// Lower the registry into the gate's rows — ONE adapter, so every verb consults the same law
@@ -213,29 +286,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetAddOnInfo",
lua.create_function(|lua, key: Value| {
let model = lua.app_data_ref::<Model>().expect("model");
let Some(i) = resolve(&model, &key) else {
// **The two argument forms miss differently** (decision 1845), and ours answered
// zero values for both.
//
// A NUMERIC index out of range **raises**, and the bounds check is *unsigned* — so
// index `0` raises too, not just a negative or an over-count. That is not an arity
// divergence at all, which is why a gate comparing counts alone would have pushed
// this toward a placeholder branch that does not exist for numbers.
if matches!(key, Value::Integer(_) | Value::Number(_)) {
return Err(mlua::Error::runtime("GetAddOnInfo: index out of range"));
}
// The STRING form is not existence-checked at all: it answers seven placeholders,
// two of which are not nil. Slot 4 is `enabled` in-game (the glue table's fourth is
// `url`, and its eighth is an appended `newVersion` — nothing shifts).
return Ok(MultiValue::from_vec(vec![
lua_str(lua, "NoSuchAddon")?,
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
lua_str(lua, "MISSING")?,
lua_str(lua, "INSECURE")?,
]));
// **The two argument forms miss differently** (decision 1845). The numeric one cannot
// reach here at all — [`addon_key`] has already raised the range error for it.
let i = match addon_key(lua, &model, &key, USAGE_INFO)? {
AddonKey::Index(i) => i,
AddonKey::Name(name) => match by_name(&model, &name) {
Some(i) => i,
// The STRING form is not existence-checked: it answers seven placeholders, two
// of which are not nil — and **slot 1 is the caller's own string echoed back**
// (`0x48e401`, non-NULL by `lua_isstring`). We used to answer the literal
// `"NoSuchAddon"`, which is the wow-re note's *example call*, not a constant
// the image contains. Slot 4 is `enabled` in-game (the glue table's fourth is
// `url`, and its eighth is an appended `newVersion` — nothing shifts).
None => {
return Ok(MultiValue::from_vec(vec![
lua_str(lua, &name)?,
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
lua_str(lua, "MISSING")?,
lua_str(lua, "INSECURE")?,
]))
}
},
};
// The one arbiter (decision 1292): loaded short-circuit, then `AddOn_CanLoad` in the
// in-game flavour — so NOT_DEMAND_LOADED and INTERFACE_VERSION are reachable here,
@@ -261,8 +335,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"IsAddOnLoaded",
lua.create_function(|lua, key: Value| {
let model = lua.app_data_ref::<Model>().expect("model");
let key = addon_key(lua, &model, &key, USAGE_LOADED)?;
// `0x51e6f0` takes the resolved NAME and answers `[rec+0x18]`; a miss pushes nil
// (`0x48e95b`), so an unknown name is one value, not a raise.
Ok(flag(
resolve(&model, &key).is_some_and(|i| model.addons[i].loaded),
row_of(&model, &key).is_some_and(|i| model.addons[i].loaded),
))
})?,
)?;
@@ -271,8 +348,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"IsAddOnLoadOnDemand",
lua.create_function(|lua, key: Value| {
let model = lua.app_data_ref::<Model>().expect("model");
let key = addon_key(lua, &model, &key, USAGE_LOAD_ON_DEMAND)?;
Ok(flag(
resolve(&model, &key).is_some_and(|i| model.addons[i].load_on_demand),
row_of(&model, &key).is_some_and(|i| model.addons[i].load_on_demand),
))
})?,
)?;
@@ -283,7 +361,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetAddOnDependencies",
lua.create_function(|lua, key: Value| {
let model = lua.app_data_ref::<Model>().expect("model");
let Some(i) = resolve(&model, &key) else {
let key = addon_key(lua, &model, &key, USAGE_DEPENDENCIES)?;
// A name the registry does not hold reaches `0x51e350` and comes back NULL, which
// takes `0x48e68a` — zero values, no raise.
let Some(i) = row_of(&model, &key) else {
return Ok(MultiValue::new());
};
let mut out = Vec::new();
@@ -297,9 +378,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// The raw `## Key: Value`, by key — how an addon reads its own `## Version`.
g.set(
"GetAddOnMetadata",
lua.create_function(|lua, (key, field): (Value, String)| {
lua.create_function(|lua, (key, field): (Value, Value)| {
let model = lua.app_data_ref::<Model>().expect("model");
let Some(i) = resolve(&model, &key) else {
let key = addon_key(lua, &model, &key, USAGE_METADATA)?;
// Argument 2 is gated by its own `lua_isstring` (`0x48e59c`, edx=2) onto the SAME
// usage raise (`0x48e5cb`) — so `GetAddOnMetadata("Foo")` raises rather than
// answering nil.
let field = super::binding_abi::string_arg(lua, field, USAGE_METADATA)?;
let Some(i) = row_of(&model, &key) else {
return Ok(Value::Nil);
};
match model.addons[i]
@@ -321,20 +407,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `0x51df00`); an unknown NAME is where we diverge, disclosed: the reference creates a
// phantom enable-hash entry for the typo, we no-op — the safer direction, and `resolve`'s
// established semantics (1191).
for (name, on) in [("EnableAddOn", true), ("DisableAddOn", false)] {
for (name, on, usage) in [
("EnableAddOn", true, USAGE_ENABLE),
("DisableAddOn", false, USAGE_DISABLE),
] {
g.set(
name,
lua.create_function(move |lua, key: Value| {
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Value::Integer(_) | Value::Number(_) = key {
if resolve(&model, &key).is_none() {
return Err(mlua::Error::runtime(format!(
"{}: addon index out of range",
if on { "EnableAddOn" } else { "DisableAddOn" }
)));
}
}
if let Some(i) = resolve(&model, &key) {
let key = addon_key(lua, &model, &key, usage)?;
if let Some(i) = row_of(&model, &key) {
model.addons[i].enabled = on;
}
Ok(())
@@ -381,7 +463,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, key: Value| {
let index = {
let model = lua.app_data_ref::<Model>().expect("model");
resolve(&model, &key)
let key = addon_key(lua, &model, &key, USAGE_LOAD)?;
row_of(&model, &key)
};
let Some(i) = index else {
return Ok(MultiValue::from_vec(vec![
+70 -25
View File
@@ -80,32 +80,65 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `RunScript(text)` — compile and run a chunk in the shared global state.
//
// The reference's is `RunScript 0x48b980` (pair `0x83e288`, name `0x83ea60`), and it is how a
// macro body, a `/script` slash command, and every addon's "evaluate this snippet" helper
// reach Lua. It inherits the same sandbox as our chunk loader: there is no `setfenv` here and
// none in the reference either — a script runs with full API access.
// `RunScript 0x48b980` (pair `0x83e288`, name `0x83ea60`) is how a macro body, a `/script`
// slash command and every addon's "evaluate this snippet" helper reach Lua. It inherits the
// same sandbox as our chunk loader: there is no `setfenv` here and none in the reference
// either — a script runs with full API access.
//
// **The chunk NAME here is ours, not the image's, and that is an open question** (2136's
// "left open"). wow-re's chunk-source census (`lowhealth-playerstatus-onupdate-dead.md` §3)
// places this binding's feed precisely — `0x48b9cb` into `0x704cd0`, the source-string arm of
// `FS_DoBuffer 0x704ae0` — but records the name argument only for its sibling arms: an XML
// `<Script>` inline body is `"%s:<Scripts>"` (`0x871074`) and the one C-resident chunk is
// `"compat.lua"`. Until `0x704cd0`'s own name is read, `[RunScript]` is a placeholder that
// renders plausibly rather than a verified spelling.
// Read end to end, its contract is four facts, and three of them were wrong here (2136's
// "left open", closed at the bytes):
//
// (The address here read `0x7044c0` until 2136. That is not `RunScript` and it is not
// anything: `scripts/addrs.py` finds no wow-re ledger row for it at all.)
// **1 · The chunk name is the SOURCE ITSELF, verbatim.** `0x48b9c7 mov edx,eax` and
// `0x48b9c9 mov ecx,eax` hand `lua_tostring 0x6f3690`'s one return to
// `0x704cd0 FrameScript_Execute(code, chunkname)` as BOTH arguments. `0x704cd0` strlens the
// code (`0x704cd7`) and passes the name straight through `FS_DoBuffer 0x704ae0`
// (`0x704b06 push ecx`, the fourth argument of `luaL_loadbuffer 0x6f5690` at `0x704b0c`)
// untouched. No `=`, no `@` — so `luaO_chunkid 0x6f5c40` renders it by its third rule,
// `[string "…"]`, exactly as `loadstring`'s default source-name does (2136 §4). `=[RunScript]`
// was a placeholder that rendered plausibly; `=` is chunkid's *print-verbatim* marker and
// `[RunScript]` is not a literal the image contains.
//
// A compile or runtime error is **raised**, not swallowed. The reference propagates it to the
// caller's error handler, which is what puts a red line in the chat frame; returning nil would
// make a broken macro look like a working one that did nothing.
// **2 · A bad argument is a SILENT NO-OP, not a raise.** `0x48b988 call 0x6f3510`
// (`lua_isstring` — tag-based, so a *number* passes) failing takes `0x48b98f je` straight to
// `0x48b9f3 xor eax,eax; ret`. So does a NULL from `lua_tostring` (`0x48b99f`) and an EMPTY
// string (`0x48b9a1 cmp byte ptr [eax],0`). There is no `luaL_error 0x6f4940` anywhere in the
// function — `RunScript(nil)` does nothing at all.
//
// **3 · An error does NOT reach the caller.** `0x704ae0` pushes the registered error handler
// from the registry first (`0x704afe`, `[0x8722c8]` at `LUA_REGISTRYINDEX 0xffffd8f0`) and
// runs the chunk as `lua_pcall(L, 0, 0, -2)` (`0x704b68`); a *compile* failure takes the other
// leg and pcalls that same handler with the message (`0x704b42`). Either way the raise is
// consumed, the red line is the handler's doing, and the caller's next statement runs. Raising
// here made one bad `RunScript` abort whatever ran it — a macro could take FrameXML down with
// it.
//
// **4 · It answers zero values on every path** (`xor eax,eax` at both exits).
g.set(
"RunScript",
lua.create_function(|lua, text: String| {
lua.load(&text)
.set_name("=[RunScript]")
lua.create_function(|lua, text: Value| {
// `lua_isstring` + `lua_tostring`, in one: strings and numbers coerce, everything
// else is `None` — and `None` is the silent return, per fact 2.
let Some(s) = lua.coerce_string(text)? else {
return Ok(());
};
let raw = s.as_bytes();
if raw.is_empty() {
return Ok(());
}
// The name is the source bytes as given. Same rule as `loadstring`'s default
// (`stdlib::loadstring`), and for the same reason: the image passes one pointer twice.
let name = String::from_utf8_lossy(&raw).into_owned();
if let Err(e) = lua
.load(&*raw)
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.exec()
{
lua.app_data_mut::<crate::script::Model>()
.expect("model")
.record_script_error(super::stdlib::lua_message(&e));
}
Ok(())
})?,
)?;
@@ -306,16 +339,28 @@ mod tests {
);
}
/// `RunScript` runs its text in the shared global state, and an error in it is raised.
/// `RunScript` runs its text in the shared global state, and a failure is **recorded, not
/// raised** — `0x704ae0` runs the chunk under `lua_pcall(L, 0, 0, -2)` with the registry's
/// error handler, so the raise never reaches the caller. (The full contract, and the three
/// silent legs, are in `script::tests::stdlib`.)
///
/// This test used to assert the opposite, on the reasoning that "a silent nil is a broken
/// macro that looks fine". The reasoning was sound and the premise was not: the reference is
/// not silent, it hands the message to the error handler and keeps going. Failing loudly *at
/// the caller* is the part it does not do, and doing it meant one bad macro could abort the
/// FrameXML function that ran it.
#[test]
fn run_script_evaluates_in_the_shared_state_and_raises() {
let s = UiScript::new().unwrap();
fn run_script_evaluates_in_the_shared_state_and_reports_without_raising() {
let mut s = UiScript::new().unwrap();
s.run(r#"RunScript("RunScriptProbe = 41 + 1")"#).unwrap();
assert_eq!(s.eval::<i64>("return RunScriptProbe").unwrap(), 42);
// A script that fails must fail loudly — a silent nil is a broken macro that looks fine.
s.run(r#"RunScript("this is not lua")"#)
.expect("a malformed script is caught inside RunScript, not raised at its caller");
let errs = s.take_errors();
assert!(
s.run(r#"RunScript("this is not lua")"#).is_err(),
"a malformed script must raise, not vanish"
errs.iter()
.any(|e| e.starts_with("[string \"this is not lua\"]:1:")),
"it must not vanish either — the handler channel gets it: {errs:?}"
);
}
+96
View File
@@ -173,6 +173,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
install_bit(lua)?;
install_gc(lua)?;
install_assert(lua)?;
Ok(())
}
@@ -379,6 +380,56 @@ fn install_gc(lua: &Lua) -> mlua::Result<()> {
}
/// The type name `luaL_typerror` would print — `luaT_typenames 0x811cd0`'s spelling.
/// **`assert` answers ONE value in 5.0, and all of its arguments in 5.1.**
///
/// `luaB_assert 0x7031a0` is five calls: `luaL_checkany 0x6f4bb0` on argument 1,
/// `lua_toboolean 0x6f3660`, and on the truthy leg `0x7031e5 call 0x6f3080` — `lua_settop(L, 1)`
/// — then `0x7031ea mov eax,1`. The false leg is
/// `luaL_optlstring(L, 2, "assertion failed!" /*0x872bdc*/)` into `luaL_error 0x6f4940`, which
/// longjmps. 5.1 replaced the settop/`mov eax,1` pair with `return lua_gettop(L)`, so
/// `assert(a, b)` answers two values there and one here.
///
/// Found by the shape gate's base-library arm once it grew an argument ladder — the row
/// (`arity 1 exact`) has been in `reference/1.12-shapes.tsv` since it was vendored, and a
/// no-argument probe could never reach it because `assert()` raises.
///
/// **Reach, measured rather than assumed:** 5 647 `assert(` sites across the 219-addon corpus and
/// 73 in the shipped FrameXML, and **zero** of them bind more than one name from the result. So
/// this buys the contract, not a fixed addon — the same footing as `collectgarbage`'s argument
/// (2136 §3). The next caller to write `local a, b = assert(f())` is the one that finds out.
fn install_assert(lua: &Lua) -> mlua::Result<()> {
let f = lua.create_function(|lua, args: mlua::MultiValue| {
let mut it = args.into_iter();
// `luaL_checkany 0x6f4bb0` — an ABSENT argument 1 raises; an explicit `nil` does not, it
// is the falsy leg below.
let Some(v) = it.next() else {
return Err(mlua::Error::RuntimeError(
"bad argument #1 to `assert' (value expected)".into(),
));
};
if matches!(v, Value::Nil | Value::Boolean(false)) {
// `luaL_optlstring` — a string or a number; anything else is its own bad-argument
// raise, in 5.0's own quoting (2122).
let msg = match it.next() {
None | Some(Value::Nil) => "assertion failed!".to_string(),
Some(other) => match lua.coerce_string(other.clone())? {
Some(s) => s.to_string_lossy(),
None => {
return Err(mlua::Error::RuntimeError(format!(
"bad argument #2 to `assert' (string expected, got {})",
type_name(&other)
)))
}
},
};
return Err(mlua::Error::RuntimeError(msg));
}
// `lua_settop(L, 1)`: the first argument, alone, whatever else was passed.
Ok(v)
})?;
lua.globals().set("assert", f)
}
fn type_name(v: &Value) -> &'static str {
match v {
Value::Nil => "no value",
@@ -522,6 +573,51 @@ mod tests {
);
}
/// **`assert` answers ONE value — 5.0's `lua_settop(L, 1); return 1`, not 5.1's
/// `return lua_gettop(L)`** (`luaB_assert 0x7031a0`, settop at `0x7031e5`, `mov eax,1` at
/// `0x7031ea`).
///
/// Found by the shape gate's base-library arm once it grew an argument ladder; the row has
/// said `arity 1 exact` since the table was vendored, and only a call with MORE than one
/// argument can tell the two dialects apart.
#[test]
fn assert_answers_one_value_and_keeps_5_0_s_messages() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select('#', assert(1, 2, 3))")
.unwrap(),
1,
"5.0 truncates to the first argument; 5.1 returns them all"
);
assert_eq!(s.eval::<i64>("return (assert(7, 'x'))").unwrap(), 7);
// A truthy `false`-adjacent value is still truthy: only nil and false take the raise.
assert_eq!(s.eval::<i64>("return select('#', assert(0))").unwrap(), 1);
let raised = |call: &str| -> String {
s.eval::<String>(&format!(
"local ok, e = pcall(function() {call} end) return tostring(e)"
))
.unwrap()
};
// `luaL_optlstring(L, 2, "assertion failed!" /*0x872bdc*/)`, then `luaL_error`.
assert!(raised("assert(false)").contains("assertion failed!"));
assert!(raised("assert(nil, 'my message')").contains("my message"));
// A number is a string to `luaL_optlstring`; anything else is its own bad-argument raise,
// in 5.0's backtick quoting (2122).
assert!(raised("assert(false, 42)").contains("42"));
assert!(
raised("assert(false, {})").contains("bad argument #2 to `assert' (string expected"),
"{}",
raised("assert(false, {})")
);
// `luaL_checkany 0x6f4bb0`: an ABSENT argument 1 raises, an explicit nil does not.
assert!(
raised("assert()").contains("bad argument #1 to `assert' (value expected)"),
"{}",
raised("assert()")
);
}
/// **The 61-addon fix, as the addon actually writes it.**
///
/// This is `AceLibrary.lua`'s own opening, verbatim in shape: ask the client which client it
+1 -1
View File
@@ -27,7 +27,7 @@ use mlua::{Lua, Value, Variadic};
/// The Lua-level message inside an mlua error — mlua decorates its `Display` with a category
/// word (`syntax error: `, `runtime error: `) that the reference's own `lua_pushstring` leg never
/// adds. `loadstring`'s second return is that message verbatim, so the decoration is stripped.
fn lua_message(e: &mlua::Error) -> String {
pub(super) fn lua_message(e: &mlua::Error) -> String {
match e {
mlua::Error::SyntaxError { message, .. } => message.clone(),
mlua::Error::RuntimeError(m) => m.clone(),
@@ -0,0 +1,174 @@
//! **The `index or "name"` prologue every in-game addon verb opens with, and its two raises**
//! (2139's "left open", closed at the bytes).
//!
//! Eight bindings — `GetAddOnInfo 0x48e390`, `GetAddOnMetadata 0x48e530`,
//! `GetAddOnDependencies 0x48e5e0`, `EnableAddOn 0x48e690`, `DisableAddOn 0x48e760`,
//! `IsAddOnLoadOnDemand 0x48e840`, `IsAddOnLoaded 0x48e8e0`, `LoadAddOn 0x48e980` — share one
//! eleven-instruction prologue, and `luaL_error 0x6f4940` does not return from either of its
//! failure arms. We answered a placeholder, a `nil` or a silent no-op for all of them.
use crate::script::{AddOnInfo, UiScript};
fn seeded() -> UiScript {
let mut s = UiScript::new().unwrap();
s.register_addons(
vec![
AddOnInfo {
name: "Alpha".into(),
title: Some("Alpha Title".into()),
interface: 11200,
enabled: true,
..Default::default()
},
AddOnInfo {
name: "Beta".into(),
interface: 11200,
enabled: true,
..Default::default()
},
],
None,
None,
None,
);
s
}
/// The message of a raise, or `"<no raise>"` if the call completed.
fn raised(s: &UiScript, call: &str) -> String {
s.eval::<String>(&format!(
"local ok, e = pcall(function() {call} end) \
if ok then return \"<no raise>\" end return tostring(e)"
))
.unwrap()
}
/// **Every verb raises its own `Usage:` literal on a non-number, non-string argument.** Read out
/// of `.data` (`0x842d68` … `0x842e98`) rather than reconstructed: the message reaches an addon's
/// error handler, so the spelling is the contract.
#[test]
fn a_bad_argument_type_raises_each_verbs_own_usage_string() {
let s = seeded();
for (call, usage) in [
("GetAddOnInfo({})", "Usage: GetAddOnInfo(index or \"name\")"),
(
"GetAddOnMetadata({}, \"Version\")",
"Usage: GetAddOnMetadata(index or \"name\", \"variable\")",
),
(
"GetAddOnDependencies({})",
"Usage: GetAddOnDependencies(index or \"name\")",
),
("EnableAddOn({})", "Usage: EnableAddOn(index or \"name\")"),
("DisableAddOn({})", "Usage: DisableAddOn(index or \"name\")"),
(
"IsAddOnLoadOnDemand({})",
"Usage: IsAddOnLoadOnDemand(index or \"name\")",
),
(
"IsAddOnLoaded({})",
"Usage: IsAddOnLoaded(index or \"name\")",
),
("LoadAddOn({})", "Usage: LoadAddOn(index or \"name\")"),
] {
let got = raised(&s, call);
assert!(
got.contains(usage),
"{call} must raise `{usage}`, got: {got}"
);
}
// `nil` and a missing argument fail the same two tests a table does — `lua_isnumber` reports
// NULL past `L->top` as "not a number" — so they take the same raise.
for call in ["LoadAddOn(nil)", "LoadAddOn()", "IsAddOnLoaded(true)"] {
assert!(
raised(&s, call).contains("index or \"name\""),
"{call}: {}",
raised(&s, call)
);
}
// The second argument has its own `lua_isstring` (`0x48e59c`) onto the SAME raise.
assert!(raised(&s, "GetAddOnMetadata(1)").contains("Usage: GetAddOnMetadata"));
}
/// **An out-of-range numeric index raises, on every verb, with the count in the message** —
/// `"AddOn index must be in the range of 1 to %d"` (`0x837d70`), `%d` from `0x51def0()`.
///
/// The bound is **unsigned** (`0x51df00 cmp ecx,[0xbe1b90]; jb`), so `f(0)` decrements to
/// `0xFFFFFFFF` and raises by the same route as `f(count+1)`. Only three of the eight raised at
/// all before this, and those three said something the image does not.
#[test]
fn an_out_of_range_index_raises_with_the_registrys_own_count() {
let s = seeded();
let want = "AddOn index must be in the range of 1 to 2";
for verb in [
"GetAddOnInfo",
"GetAddOnDependencies",
"EnableAddOn",
"DisableAddOn",
"IsAddOnLoadOnDemand",
"IsAddOnLoaded",
"LoadAddOn",
] {
for arg in ["0", "3", "-1", "2.9"] {
// 2.9 truncates toward zero (`_ftol 0x40a2b0` chops) to 2, which is IN range — the
// one of the four that must NOT raise.
let got = raised(&s, &format!("{verb}({arg})"));
if arg == "2.9" {
assert!(!got.contains("must be in the range"), "{verb}(2.9): {got}");
} else {
assert!(got.contains(want), "{verb}({arg}) must raise: {got}");
}
}
}
assert!(raised(&s, "GetAddOnMetadata(3, \"Version\")").contains(want));
}
/// **A numeric STRING is an index, not a name** — `lua_isnumber 0x6f34d0` coerces one, so the
/// number arm claims it before `lua_isstring` is ever reached.
#[test]
fn a_numeric_string_takes_the_index_arm() {
let s = seeded();
assert_eq!(
s.eval::<String>(r#"return (GetAddOnInfo("2"))"#).unwrap(),
"Beta",
"\"2\" is the second addon, not an addon named `2`"
);
assert!(raised(&s, r#"GetAddOnInfo("9")"#).contains("must be in the range"));
}
/// **`GetAddOnInfo`'s string miss echoes the caller's own name back** (`0x48e401`, non-NULL by
/// `lua_isstring`), then five metadata misses, `"MISSING"` and `"INSECURE"`. We answered the
/// literal `"NoSuchAddon"` — which is wow-re's *example call*, not a constant in the image.
///
/// The name form is never existence-checked by the prologue; only the numeric one is.
#[test]
fn an_unknown_name_echoes_itself_rather_than_a_placeholder_literal() {
let s = seeded();
assert_eq!(
s.eval::<Vec<String>>(
r#"local n,t,no,e,l,r,sec = GetAddOnInfo("Nope")
return { n, tostring(t), tostring(no), tostring(e), tostring(l), r, sec }"#
)
.unwrap(),
vec![
"Nope".to_string(),
"nil".into(),
"nil".into(),
"nil".into(),
"nil".into(),
"MISSING".into(),
"INSECURE".into(),
]
);
// The other name-miss answers are each verb's own, and they do not agree with GetAddOnInfo's.
assert_eq!(
s.eval::<String>(r#"return tostring(IsAddOnLoaded("Nope"))"#)
.unwrap(),
"nil"
);
assert_eq!(
s.eval::<i64>(r##"return select("#", GetAddOnDependencies("Nope"))"##)
.unwrap(),
0
);
}
@@ -4,6 +4,7 @@
//!
//! Split by subject; the shared `script()` fixture lives in [`common`].
mod addon_argument_abi;
mod anchors;
mod backdrop;
mod button;
@@ -420,3 +420,92 @@ fn time_is_epoch_seconds_and_date_formats_them() {
let leap: String = s.eval(r#"return date("%Y-%m-%d %A", 951782400)"#).unwrap();
assert_eq!(leap, "2000-02-29 Tuesday");
}
// ── RunScript, at the image's own contract (2136's "left open", closed) ──────────────────────────
/// `RunScript`'s chunk name is the SOURCE, not a label of ours.
///
/// `0x48b9c7 mov edx,eax` / `0x48b9c9 mov ecx,eax` hand `lua_tostring`'s one return to
/// `FrameScript_Execute 0x704cd0` as both the code and the name, so the name reaches
/// `luaL_loadbuffer` unprefixed and `luaO_chunkid 0x6f5c40` wraps it — `[string "…"]`. We used to
/// write `=[RunScript]`, whose leading `=` is chunkid's *print this verbatim* marker.
#[test]
fn a_runscript_chunk_is_named_by_its_own_source() {
let mut s = script();
// The error is consumed by RunScript (fact 3), so read it off the recorded channel rather
// than off a raise.
s.run("RunScript(\"error('boom')\")").unwrap();
let errs = s.take_errors();
assert!(
errs.iter()
.any(|e| e.starts_with("[string \"error('boom')\"]:1: boom")),
"the chunk names itself by its source: {errs:?}"
);
}
/// The three silent legs, and the raise that must NOT happen.
///
/// `lua_isstring 0x6f3510` is tag-based, so a number passes and runs as its own text; every other
/// type takes `0x48b98f je 0x48b9f3` to `xor eax,eax; ret`. An empty string takes the same exit at
/// `0x48b9a1`. There is no `luaL_error 0x6f4940` in the function at all.
#[test]
fn runscript_swallows_a_bad_argument_instead_of_raising() {
let mut s = script();
// Each of these must return normally AND leave the next statement running.
s.run(
"BenillaRan = 0
RunScript(nil)
RunScript({})
RunScript(false)
RunScript('')
BenillaRan = 1",
)
.expect("a bad RunScript argument is a no-op, not a raise");
assert_eq!(s.eval::<i64>("return BenillaRan").unwrap(), 1);
assert!(
s.take_errors().is_empty(),
"a silent no-op records nothing either"
);
// A number IS a string to `lua_isstring`, so it compiles — and `42` is not a statement.
s.run("RunScript(42)").unwrap();
assert!(
s.take_errors()
.iter()
.any(|e| e.contains("[string \"42\"]")),
"a number coerces and runs as its own text"
);
}
/// A raise inside the snippet does not escape it: `0x704ae0` runs the chunk under
/// `lua_pcall(L, 0, 0, -2)` (`0x704b68`) with the registry's error handler pushed at `0x704afe`,
/// and pcalls that same handler for a *compile* failure (`0x704b42`). Both legs return 0 values.
///
/// This is the one with teeth: raising here let one bad macro abort whatever ran it.
#[test]
fn a_runscript_error_reaches_the_handler_and_not_the_caller() {
let mut s = script();
s.run(
"BenillaAfter = 0
RunScript('error(\"inner\")')
RunScript('this is not lua')
BenillaAfter = 1",
)
.expect("neither a runtime nor a compile error may propagate to the caller");
assert_eq!(
s.eval::<i64>("return BenillaAfter").unwrap(),
1,
"the caller's next statement still runs"
);
let errs = s.take_errors();
assert_eq!(
errs.len(),
2,
"both errors are recorded, not dropped: {errs:?}"
);
assert!(errs[0].contains("inner"), "{errs:?}");
// mlua's `Display` category word ("syntax error: ") is not something the image ever writes.
assert!(
errs[1].starts_with("[string \"this is not lua\"]:1:"),
"a compile failure is reported under the same name, undecorated: {errs:?}"
);
}
+31 -1
View File
@@ -160,7 +160,9 @@ pub(crate) const DETAIL_DOODAD_FADE_FAR: f32 = 70.0;
/// Ground-clutter tunables (read at tile scatter; a `density` change re-scatters LOADED tiles too —
/// `terrain_stream::rescatter_clutter`, the 1.12 setter's own chunk-rebuild law, 0992):
/// `density` multiplies the per-chunk cell-visit count (the client's `frillDensity`, faithful=16 at ×1)
/// — player-settable as the `WorldDetail` CVar (panel 0/1/2 → ×1/×2/×3; the arm in [`crate::cvars`]);
/// — player-settable through **either** registered CVar over this one field, [`ClutterConfig::frill_density`]
/// being the conversion: `WorldDetail` (the panel's stop, 0/1/2 → ×1/×2/×3) or `frillDensity` (the
/// reference's own cells-per-chunk, 1..256), both arms in `benilla-app`'s `cvars`;
/// `scale` resizes each doodad model; `alpha_ref` is the alpha-test cutout threshold
/// ([`DETAIL_DOODAD_ALPHA_REF`]); `fade_far` is the clutter draw-distance horizon (yd,
/// [`DETAIL_DOODAD_FADE_FAR`]; fade starts at 0.75×). Initial values from `$WOW_CLUTTER_DENSITY` /
@@ -173,6 +175,34 @@ pub struct ClutterConfig {
pub fade_far: f32,
}
impl ClutterConfig {
/// This session's ground cover in the **reference's own unit** — `frillDensity`, the number of
/// cells the scatter visits per chunk ([`benilla_formats::FRILL_DENSITY`] at ×1).
///
/// The multiplier above is benilla's spelling of a knob 1.12 keeps in cells: its slider stops
/// are `SetWorldDetail 0x488dd0`'s 16/32/48, which are this constant times 1/2/3. So the two
/// registered CVars over this field — `WorldDetail` (the stop) and `frillDensity` (the cells) —
/// are one knob read two ways, and this pair is where that conversion lives so neither the CVar
/// host nor the scatter carries a bare 16.
pub fn frill_density(&self) -> f32 {
self.density * benilla_formats::FRILL_DENSITY as f32
}
/// Set the ground cover from a `frillDensity`, under the reference's own clamp.
///
/// `[1, 256]` is what the real client's change callback `0x688de0` pins a written value to —
/// **not** `[16, 48]`: the slider's three stops are one writer of this CVar, and a console
/// `frillDensity 200` is another. It is also why the `WorldDetail` arm's clamp is the tighter
/// one: there the stop is the value, here the cells are.
///
/// Zero is deliberately NOT reachable here, matching that callback — clutter-off stays the
/// `$WOW_CLUTTER_DENSITY=0` lever's, which is an instrument rather than a setting.
pub fn set_frill_density(&mut self, frill: f32) {
self.density = frill.clamp(1.0, benilla_formats::FRILL_DENSITY_MAX as f32)
/ benilla_formats::FRILL_DENSITY as f32;
}
}
impl Default for ClutterConfig {
fn default() -> Self {
let env = |k: &str| std::env::var(k).ok().and_then(|s| s.parse::<f32>().ok());