diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 935a0d6b..c3c76420 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -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" }, diff --git a/crates/benilla-app/src/capture/probes/act.rs b/crates/benilla-app/src/capture/probes/act.rs index 48ee2e5d..3e05cdf2 100644 --- a/crates/benilla-app/src/capture/probes/act.rs +++ b/crates/benilla-app/src/capture/probes/act.rs @@ -309,12 +309,26 @@ fn fire_probe_key( } } -/// The PROBE LUA one-shot (`WOW_PROBE_LUA=""`, 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=""`, 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, - time: ProbeClock, - script: Option>, - self_player: Query<(), With>, -) { - 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, + time: ProbeClock, + script: Option>, + self_player: Query<(), With>, + state: Res>, + entry_ui_pending: Option>, +) { + 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" + ); + } +} diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index ce21d9fd..3c4a2487 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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, - /// Lowercased names whose value came from an env var this session (never saved). - env_overridden: HashSet, + /// 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, /// 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 + '_ { 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, 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, 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>, mut persist: ResMut, mut params: KnobParams, + adapter: Option>, ) { 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, - env_overridden: &HashSet, + session_owned: &HashSet, snapshot: &[(String, String, String)], ) -> BTreeMap { 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) diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 74a16d63..2bada3d6 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -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); } diff --git a/crates/benilla-app/src/realm_select/smoke.rs b/crates/benilla-app/src/realm_select/smoke.rs index 90adf862..48b75aec 100644 --- a/crates/benilla-app/src/realm_select/smoke.rs +++ b/crates/benilla-app/src/realm_select/smoke.rs @@ -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 {}/{})", diff --git a/crates/benilla-app/src/ui_script/shape_gate.rs b/crates/benilla-app/src/ui_script/shape_gate.rs index 44df337b..4610e49f 100644 --- a/crates/benilla-app/src/ui_script/shape_gate.rs +++ b/crates/benilla-app/src/ui_script/shape_gate.rs @@ -123,6 +123,14 @@ fn rows_of_kind(kind: &str) -> Vec { 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 { // 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 = 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::(&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 {}", diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index c587b163..b3aece0a 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -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") diff --git a/crates/benilla-formats/src/ground_effects.rs b/crates/benilla-formats/src/ground_effects.rs index 475a5096..519121eb 100644 --- a/crates/benilla-formats/src/ground_effects.rs +++ b/crates/benilla-formats/src/ground_effects.rs @@ -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(); } diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 38ed08ff..82828c8e 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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}; diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index 28caf766..8d4cf34e 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -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 `\n", + ); + 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