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:
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {}/{})",
|
||||
|
||||
@@ -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 {}",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
{
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user