Files
benilla-octo/crates/benilla-app/src/cvars.rs
T
SamandClaude Fable 5.1 9caacad153 brightness saves, the glue box holds still, and Questie's notes hover
- the Brightness slider in the options window changes the picture and keeps its value; it was writing to no setting at all, so every reopen read 50% again (2186)
- the login, realm and character screens share one letterboxed frame, so clicking down the character roster no longer slides the backdrop and every panel over it; at 4:3 nothing changes (2187)
- Questie's world-map notes show their tooltip on hover: the note hands the tooltip a frame where an anchor name goes, which benilla refused with a script error and the reference reads as the default anchor (2176)
- with OmniCC installed, a warrior in a stance sees the countdown on action bar 1 again: raising that bar's buttons by script carried their cooldown sweep up over the count (2189)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 21:06:04 +07:00

3413 lines
193 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! benilla's **CVar host** — registration, knob sync, and the client's first persistence
//! (decision 0954). The engine holds the table and the Lua API ([`benilla_ui::script`]'s
//! `GetCVar`/`SetCVar`); this module is everything host-side:
//!
//! - **The registered set** ([`REGISTERED`]): only vars something actually reads — a host knob,
//! or (since 1140) a live Lua consumer, which is the same rule seen from the UI side and the
//! only refinement the honest-tree law has needed.
//!
//! **A row's default is the REFERENCE's default** (decision 1804), and every row says where it
//! stands against it — that is [`Registered::reference`], a mandatory third column with no
//! "unknown" variant, so a new row cannot be added without answering the question. Two tests
//! hold it: one checks each row's claim in both directions (a `Same` that drifted, *and* a
//! `Deviates` that quietly came back into agreement), the other pins the deviation set as a
//! readable list. Beside that, and unchanged, each default is still **welded to the code
//! constant it mirrors** — `SoundConfig`, `NameConfig`, `ZoomLimit` and the rest — so the CVar
//! table and the knob it feeds cannot drift apart either.
//!
//! **The reference column has one source**: wow-re's
//! `system/cvar/scratch/registered-defaults-census.md` and the regenerable manifest beside it,
//! `re/cvar/cvar-register-sites.tsv` — all 214 of the reference's `CVar::Register` sites with
//! name, help, flags, default string, callback, category and record global. 1804 dispatched the
//! §5 round that built it; a new row looks its answer up there rather than re-deriving it, and a
//! row that disagrees with it is a contradiction to resolve before it lands (`method.md`).
//! - **Boot**: read `benilla-config/config.toml` ([`crate::local_state`]) and apply it to the knob
//! resources; when the UI VM exists, register the table and push the resolved session values
//! so `GetCVar` answers what the client is actually doing.
//! - **Sync**: drain Lua `SetCVar` changes into the knob resources each frame and mark the
//! config dirty.
//! - **Save**: dirty + one quiet second → rewrite `config.toml` atomically (and flush on
//! `AppExit`). The file holds **only values that moved off their default** — a diff, not a
//! dump — plus any entries this build doesn't know (a newer build's keys survive a downgrade;
//! same posture for a hand-added key: preserved verbatim, warned once).
//!
//! **Env overrides win for the session and never touch the file**: `WOW_UI_SCALE`/`WOW_FARCLIP`
//! beat the loaded config (they exist to make taste iteration a relaunch — pinning one into the
//! config would make the A/B sticky), the session runs and saves around them, and the file keeps
//! whatever it already said for those keys.
use std::collections::{BTreeMap, HashSet};
use std::time::Instant;
use bevy::prelude::*;
use crate::chat_bubble::BubbleConfig;
use crate::minimap::MinimapZoom;
use crate::nameplates::NameConfig;
use crate::player::camera::{
FollowConfig, FollowStyle, LookConfig, ZoomLimit, CAMERA_SPEED_RANGE, FOLLOW_SPEED_RANGE,
MOUSE_SPEED_RANGE,
};
use crate::portrait::PaneRate;
use crate::sound::SoundConfig;
use crate::target::ClickConfig;
use crate::ui_chat::combat::UnitClass as CombatClass;
use crate::ui_loot::LootConfig;
use crate::ui_script::UiScaleCvar;
use crate::video::VideoConfig;
use crate::vplates::VPlateMode;
use crate::world_backdrop::{RenderScale, RENDER_SCALE_RANGE};
use benilla_ui::script::UiScript;
use benilla_ui::widget::MINIMAP_ZOOM_LEVELS;
use benilla_world::clutter::ClutterConfig;
use benilla_world::view::{MsaaSetting, ViewDistance, FARCLIP_RANGE, MSAA_RANGE};
/// One host-backed CVar: its registered name, benilla's shipped default, and — the column that
/// exists so a divergence is a *decision* rather than an accident — **what the reference ships**
/// ([`Reference`]).
///
/// **The standard this table encodes: a benilla option's default IS the reference's.** Shipping
/// something else is allowed and sometimes right, but it costs a [`Reference::Deviates`] row
/// naming the reference's own value and the reason. `Reference` has no `Default` and no "unknown"
/// variant, so adding a row means answering the question; and because [`Reference::Same`] and
/// [`Reference::Deviates`] both carry the reference's value, the test
/// [`tests::defaults_stand_where_the_reference_column_says`] checks the claim in both directions —
/// a `Same` row that stopped matching fails, and so does a `Deviates` row that has quietly come
/// back into agreement.
pub(crate) struct Registered {
/// The registered name, in the reference's own spelling.
pub(crate) name: &'static str,
/// What a fresh `benilla-config` runs at — seeded into the knob, and what `GetCVar` answers
/// until the player moves it.
pub(crate) default: &'static str,
/// Where that value stands against the reference's.
///
/// `#[allow(dead_code)]` because this column's readers are a **human** and
/// [`tests::defaults_stand_where_the_reference_column_says`] — nothing at runtime consults
/// it, and nothing should: it records what the *reference* does, which is an input to the
/// choice above it, never a value this client acts on.
#[allow(dead_code)]
pub(crate) reference: Reference,
}
/// benilla's default, weighed against the reference's own.
///
/// **What "the reference's default" means here** is the **registered factory default** — the
/// string the real client's `CVar::Register` (`0x63db90`) call passes for that name, byte-read —
/// or, for a setting 1.12 keeps FrameXML-side instead of as a CVar, the value
/// `UIOptionsFrame.lua` boots it at. Two readings it deliberately is **not**, both of which have
/// misled a reader before:
///
/// - **Not what the reference install's `Config.wtf` says.** That file is one player's saved
/// *diff*: `SaveConfig 0x63d980` writes only what has moved off its default, so a line's mere
/// presence is proof the registered default is something *else* (wow-re
/// `cvar/scratch/graphics-cost-cvar-census.md` §10 — the trap it exists to close).
/// - **Not, by itself, what a fresh install ends up running at.** `hwDetect` rewrites sixteen
/// video CVars out of `VideoHardware.dbc` before the first frame, and the `useUiScale`-OFF leg
/// computes a UI scale of its own. Where the client's own boot code overrides the registered
/// string like that, benilla follows the *behaviour* and the row says so — that is
/// [`Reference::Overridden`], not a deviation.
///
/// Every row's provenance — the register-site VA, or the FrameXML line — belongs in the comment
/// above it. That is not decoration: it is what lets the next reader re-check the claim instead
/// of trusting this table.
#[allow(dead_code)] // read by a human and by the test — see `Registered::reference`
pub(crate) enum Reference {
/// The reference registers this exact default string, and benilla ships it too. The copy is
/// deliberate: the test compares the two, so an edit to `default` that forgets the reference
/// fails here rather than shipping.
Same(&'static str),
/// The reference *registers* `registered`, but its own boot code overwrites that before the
/// first frame, and benilla's `default` is what that code lands on — faithful to the client's
/// behaviour, which is what the standard asks for. `why` is the override.
Overridden {
registered: &'static str,
why: &'static str,
},
/// The reference ships `value`; benilla knowingly ships something else. `why` is the reason
/// and the decision that ruled it. **This is the variant that answers "where do we differ?"**
/// — every row here is a standing choice somebody made, reviewable as a list.
Deviates {
value: &'static str,
why: &'static str,
},
/// The reference has no such setting to match: benilla's own knob (`renderScale`), or an era
/// name for something 1.12 never made settable. `why` says which — and, where the reference
/// still *behaves* some way, what that behaviour is.
Ours(&'static str),
}
/// A row whose default is the reference's own registered string.
const fn same(name: &'static str, default: &'static str) -> Registered {
Registered {
name,
default,
reference: Reference::Same(default),
}
}
/// A row that follows the reference's *behaviour* where its own boot code overrides the
/// registered string — see [`Reference::Overridden`].
const fn overridden(
name: &'static str,
default: &'static str,
registered: &'static str,
why: &'static str,
) -> Registered {
Registered {
name,
default,
reference: Reference::Overridden { registered, why },
}
}
/// A row that knowingly leaves the reference's default — `value` is the reference's, `why` the
/// recorded reason.
const fn deviates(
name: &'static str,
default: &'static str,
value: &'static str,
why: &'static str,
) -> Registered {
Registered {
name,
default,
reference: Reference::Deviates { value, why },
}
}
/// A row the reference has no counterpart for.
const fn ours(name: &'static str, default: &'static str, why: &'static str) -> Registered {
Registered {
name,
default,
reference: Reference::Ours(why),
}
}
/// The table as the script VM's registrar wants it — `(name, default)` pairs, in table order.
///
/// The registrar has no use for the [`Reference`] column: that column is for *us* (and for the
/// test that holds the standard), never for the engine.
pub(crate) fn registered_pairs() -> impl Iterator<Item = (&'static str, &'static str)> {
REGISTERED.iter().map(|r| (r.name, r.default))
}
/// The host-backed CVars. Grows one row per knob a settings page actually wires — never ahead of
/// the knob (see the module doc) — and every row states where its default stands against the
/// reference's ([`Registered`]).
pub(crate) const REGISTERED: &[Registered] = &[
// The realm the session is on — a REAL 1.12 CVar (`0x83f2d0`, persisted, wow-re
// `savedvariables-protocol.md`: the client builds its SavedVariables path from it), and a live
// Lua consumer in the strongest sense the honest-tree rule asks for. `Ace/AceState.lua:27` does
// `ace.trim(GetCVar("realmName"))` inside `SetGameState`, which every Ace addon runs at
// PLAYER_ENTERING_WORLD — so a nil there was `gsub(nil)` and took the whole Ace family down.
// 18 corpus folders read the name.
//
// The default is EMPTY, deliberately and not as a guess: the value is written from the session's
// real realm the moment addons load (`ui_script::addons::load_third_party`), so the default only
// ever describes a client that has not connected. wow-re records a string
// `"Last realm connected to"` beside the registration, but that reads like the CVar's HELP text
// rather than its value and nothing here needs to resolve it — `""` is what `ace.trim` handles
// cleanly, and inventing a realm name would be worse than admitting we have none yet.
same("realmName", ""),
// The address of the logon server — the reference's own CVar, byte-verified in `WoW.exe`
// (the registration's string neighbours are `realmlist.wtf`, "Address of realm list server"
// and `us.logon.worldofwarcraft.com:3724`; wow-re `mpq/scratch/startup-order-A.md` row 62).
// A **string** row, so it is matched ahead of the numeric parse in `apply_to_knobs`.
// The default diverges knowingly — see `realmlist::DEFAULT_REALMLIST`.
deviates(
crate::realmlist::CVAR_REALMLIST,
crate::realmlist::DEFAULT_REALMLIST,
"us.logon.worldofwarcraft.com:3724",
"1667: that host has not resolved since 2019, so shipping it makes every first launch a \
DNS failure; benilla dials the machine it is running on",
),
// The implicit AFK clear (2088) — a REAL 1.12 CVar, byte-read off its own registration
// (`0x5e24d4 push 0x82e748`, handle taken from the store AFTER the call at `0x5e24ef` into
// `[0xc4d68c]`, whose single reader `0x5eb84b` tests `[cvar+0x28]` for non-zero; wow-re
// `ui/scratch/afk-dnd-command-law.md` §10). Registered default `"1"`.
//
// It gates FIVE implicit clears, not one: any chat send whose type is not `0x14` (which is why
// `/dnd` clears AFK before marking), plus Jump, forward/back, strafe and turn
// (`0x513d36`/`0x514e23`/`0x514f0b`/`0x514fca`). With the CVar off the clear is a **total**
// no-op — no echo, no mirror write, no packet.
same("autoClearAFK", "1"),
same("MasterVolume", "1"),
same("SoundVolume", "1"),
same("MusicVolume", "0.4"),
same("AmbienceVolume", "0.6"),
// The three 1.12 sound enables (registrar defaults all "1", wow-re B10):
// `MasterSoundEffects` is the MASTER "Enable All Sound" checkbox (SoundOptionsFrame.lua
// index 1 — its callback sets the engine-wide pause flag), NOT an SFX-only toggle; 1.12
// has no `EnableSound`/`EnableSFX` at all.
same("MasterSoundEffects", "1"),
same("EnableMusic", "1"),
same("EnableAmbience", "1"),
// Error speech (1815) — the race/sex refusal lines your character says. A real 1.12 CVar
// (`CVar::Register` at `0x457877`, registrar default `"1"`; wow-re
// `re/cvar/cvar-register-sites.tsv` row 54) and a real 1.12 checkbox: SoundOptionsFrame.lua's
// `ENABLE_ERROR_SPEECH`, index 4, which the master enable greys along with Ambience.
same("EnableErrorSpeech", "1"),
// Sound while the window is in the background (1847). **Not a 1.12 CVar and not a 1.12
// checkbox**: none of the reference's 214 `CVar::Register` sites names it
// (`re/cvar/cvar-register-sites.tsv`), and `SoundOptionsFrame.lua` declares seven checkboxes
// (indices 1, 2, 4-8) and four sliders, none of them this. `CVar::Register` is the only
// creation path, so `Config.wtf` can hold no such key either. The spelling is the later-era
// engine's — the `autoLootDefault` / `nameplateShowEnemies` posture, where benilla's
// persistence IS the CVar store (0954) and a setting 1.12 never made settable takes the era
// name rather than an invented one.
//
// **`same`, not `ours`**, for the nameplate pair's reason: the reference has no CVar to match
// but it very much has a *behaviour* to match, and it goes quiet in the background —
// unconditionally, on `WM_ACTIVATE` → event-bus category 2 → `0x7a4860`'s
// `FSOUND_SetMute(-3, active ? 0 : 1)`, music included (wow-re
// `sound/scratch/focus-mute-law.md`, VERIFIED). "0" IS the reference's own behaviour. The knob
// is `SoundConfig::background_sound`, which carries the mechanism and the one disclosed
// divergence.
same("Sound_EnableSoundWhenGameIsInBG", "0"),
// Zone reverb (1153). The binary registers this one `"1"` (`0x4573be`) and we register it
// `"0"` — the only row here that knowingly leaves the registrar's default, because the
// reference's reverb is EAX-over-hardware and that hardware has not existed since Vista:
// `"1"` would ship audio the real client has never actually produced (bug B236).
// `SoundConfig::reverb` carries the evidence.
deviates(
"SoundReverb",
"0",
"1",
"1153: the reference's reverb is EAX-over-hardware and that hardware has not existed \
since Vista, so \"1\" would ship audio the real client has never actually produced \
(B236)",
),
// The mix-ahead depth (1857) — 1.12's own `SoundBufferSize` (`0x4571ca`, flags 2: latched,
// read once at sound-system init), "sound buffer size (milliseconds)": FMOD 3's mix-ahead
// buffer, the distance the software mixer runs ahead of the output device. benilla's own
// output has the same quantity — the render thread's ring ahead of the IO callback
// (`sound::output`) — so the reference's dial drives it, in the reference's unit. The
// registrar's default is a two-way host choice: `0x457520` returns "50" or "100" from an
// OS-version probe (strings at `0x835e10`/`0x835e0c`, byte-read 2026-09-02). Ours is the
// larger of its two, because the stall the crackle was measured from was a whole IO cycle
// long and the depth exists to hide the next one. Applies at the next launch, like the
// reference's.
same("SoundBufferSize", "100"),
// The output limiter (1551) — benilla's own, not a 1.12 CVar. The reference needs no such DSP
// (its mix is FMOD 3's and its headroom lives in the SFX-bus auto-duck); benilla sums into f32
// behind a hard clamp, and every WoW SFX is mastered to full scale, so two overlapping kits
// clip. Registered so the fix can be A/B'd live against the defect it fixes.
ours(
"SoundOutputLimiter",
"1",
"1551: benilla's own — the reference's FMOD 3 mix needs no such DSP; we sum into f32 \
behind a hard clamp, and every WoW SFX is mastered to full scale",
),
overridden(
"uiScale",
"0.9",
"1.0",
"a fresh reference client never consults this CVar: `useUiScale` registers \"0\" \
(`0x48fce4`), and the OFF leg `0x492f70` computes clamp(768/height, 0.9, 1.0) instead — \
0.9 at 854 px tall and up, which is every window we ship against. It is 1.0 at 768 and \
below, where our flat 0.9 does diverge; `ui_script::DEFAULT_UI_SCALE` carries that. \
See `useUiScale` below, whose row this one used to say did not exist",
),
// **`useUiScale` (`0x8430c0`, default `"0"`)** — the switch the row above gates on.
//
// Its absence used to be argued for here as *"nothing reads it, and registering it would only
// offer a switch whose ON path we do not implement"*, and the first half of that has been
// false since the interface went stock: `ContainerFrame.lua:483` and `UIDropDownMenu.lua:525`
// both branch on `GetCVar("useUiScale") == "1"`, and `OptionsFrame.lua:13` gives it a
// checkbox. Nobody noticed because the only thing that said so was a host warning with
// nowhere to go (decision 2135, which is how this was found).
//
// Registering it changes no behaviour today — `nil ~= "1"` and `"0" ~= "1"` take the same
// branch — and makes the read the reference's read rather than an accident. The ON path
// lands where the reference's does, because our `uiScale` default *is* the reference's OFF-leg
// result: at `useUiScale = 1` both clients scale the bag frames and the dropdown list by
// `GetCVar("uiscale")`, and both read 0.9 there on every window we ship against.
same("useUiScale", "0"),
same("farclip", "350"),
// **`nearclip` — farclip's other half, and a knob we had been holding as a constant** (2163).
// `0x68867a` passes name `0x84ffb0` `"nearclip"`, default string `0x84fb48` `"0.1"`, help
// "Near clip plane distance", flags `1`, callback `0x688d90`, record `[0xc7f348]` (wow-re
// `re/cvar/cvar-register-sites.tsv` row 187).
//
// **The reader is the camera, and it re-reads every frame.** `0x511bc0` — the per-frame camera
// outer, sole caller `0x483094` — stamps `[cam+0x38]` from this record's float before the
// `[cam+0x48]` branch and unconditionally: `511bcf mov eax,[0xbe1078]; 511bd4 fld [eax+0x24];
// 511bdc fstp [esi+0x38]`, with `[0xbe1078]` the handle `0x50b728` caches from a `"nearclip"`
// Lookup. `farclip` is the next four instructions. `benilla_world::view::stamp_near_clip` is
// that, and `ViewDistance` is the pair.
//
// **Why it was not registered for so long, and why that reasoning was wrong.** The near plane
// was a `CAM_NEAR = 1.0/9.0` const documented as the reference's own, on the true finding that
// the callback's *derived global* `[0xc7b480]` has one writer and no readers (wow-re
// `cvar/scratch/graphics-cost-cvar-census.md` §8 lists `nearclip` among the eleven dead knobs
// for exactly that). The camera does not read that global; it reads the record. So the ctor's
// `0x3de38e39` = 1/9 is overwritten by the first frame's stamp and never reaches a picture —
// a verified-but-partial mechanism, which the contract §4 names as the classic trap.
//
// pfUI's `hdgraphic` writes it (`ConsoleExec("nearClip " .. arg*2/100)`, 0.06..0.30 across its
// extended stops) — every value inside the reference's own `[0.01, 0.33]`, which is why that
// module could ask for it.
same("nearclip", "0.1"),
// The Controls-page trio (0961). `deselectOnClick`/`mouseInvertPitch` are 1.12's own
// Interface Options CVars (UIOptionsFrame.lua indices 45/1); their defaults are the
// reference behaviors benilla already shipped (empty-world click clears the target; no
// pitch invert). `autoLootDefault` is era's — no 1.12 CVar exists, vanilla only had the
// shift gesture — default off, like era's engine registrar.
same("deselectOnClick", "1"),
// *Block Trades* (decision 1764) — 1.12's own `BlockTrades` (`0x842fbc`), the General-box
// checkbox at index 14 whose tooltip is "Block all incoming trade requests.". Registered
// **"0"**: the reference's own `0x4bf7bc` leg only refuses when the CVar is set, so an
// unset/absent value has to mean "trades allowed" — and a client that shipped with trades
// blocked would refuse every trade until the player found the box. The knob is
// [`crate::ui_trade::BlockTrades`], read by the incoming-request answerer.
same("BlockTrades", "0"),
// 1.12's own `autoSelfCast` — a friendly cast that binds nothing falls back to the caster.
// The behaviour has been here since the cast arm landed, welded to a Resource default; it is a
// CVar now because 1.12's `TOGGLEAUTOSELFCAST` binding is `GetCVar`/`SetCVar` over this exact
// name and there was nothing for it to toggle (decision 1745).
//
// Register site `0x6e731d`, default string `"0"`, record `[0xceac34]`, one reader at
// `0x6e53d7` (wow-re `cvar/scratch/registered-defaults-census.md`, 1804's §5 round). **This
// row used to cite `0x870dc0` as the record; that is the NAME string** — corrected there.
//
// **benilla ships it ON and the reference registers "0"** — a named deviation that predates
// this row (`cast_target::AutoSelfCast`): with it off, an unbindable friendly cast falls into
// the reference's targeting-cursor machine, which is unmodeled, leaving no path at all. Flip
// to the reference's default when that machine lands.
deviates(
"autoSelfCast",
"1",
"0",
"1745: with it off, an unbindable friendly cast falls into the reference's \
targeting-cursor machine, which is unmodeled — leaving no path at all. Flip when that \
machine lands",
),
// The five saved camera views and the live index (decision 1745) — the reference's own
// sixteen names and its own shipped default strings, both read out of `WoW.exe` and owned by
// [`crate::player::camera_view`], which is also the only writer. Registered here so they are
// ordinary CVars: persisted as a diff like everything else, readable from a macro, and
// reachable by `SetCVar` — which is what makes a `SaveView` survive a restart.
same(
crate::player::camera_view::CVAR_ACTIVE_VIEW,
crate::player::camera_view::ACTIVE_VIEW_DEFAULT,
),
same(
crate::player::camera_view::VIEW_CVARS[0][0],
crate::player::camera_view::VIEW_DEFAULTS[0][0],
),
same(
crate::player::camera_view::VIEW_CVARS[0][1],
crate::player::camera_view::VIEW_DEFAULTS[0][1],
),
same(
crate::player::camera_view::VIEW_CVARS[0][2],
crate::player::camera_view::VIEW_DEFAULTS[0][2],
),
same(
crate::player::camera_view::VIEW_CVARS[1][0],
crate::player::camera_view::VIEW_DEFAULTS[1][0],
),
same(
crate::player::camera_view::VIEW_CVARS[1][1],
crate::player::camera_view::VIEW_DEFAULTS[1][1],
),
same(
crate::player::camera_view::VIEW_CVARS[1][2],
crate::player::camera_view::VIEW_DEFAULTS[1][2],
),
same(
crate::player::camera_view::VIEW_CVARS[2][0],
crate::player::camera_view::VIEW_DEFAULTS[2][0],
),
same(
crate::player::camera_view::VIEW_CVARS[2][1],
crate::player::camera_view::VIEW_DEFAULTS[2][1],
),
same(
crate::player::camera_view::VIEW_CVARS[2][2],
crate::player::camera_view::VIEW_DEFAULTS[2][2],
),
same(
crate::player::camera_view::VIEW_CVARS[3][0],
crate::player::camera_view::VIEW_DEFAULTS[3][0],
),
same(
crate::player::camera_view::VIEW_CVARS[3][1],
crate::player::camera_view::VIEW_DEFAULTS[3][1],
),
same(
crate::player::camera_view::VIEW_CVARS[3][2],
crate::player::camera_view::VIEW_DEFAULTS[3][2],
),
same(
crate::player::camera_view::VIEW_CVARS[4][0],
crate::player::camera_view::VIEW_DEFAULTS[4][0],
),
same(
crate::player::camera_view::VIEW_CVARS[4][1],
crate::player::camera_view::VIEW_DEFAULTS[4][1],
),
same(
crate::player::camera_view::VIEW_CVARS[4][2],
crate::player::camera_view::VIEW_DEFAULTS[4][2],
),
same("mouseInvertPitch", "0"),
ours(
"autoLootDefault",
"0",
"0961: 1.12 has no auto-loot CVar at all — vanilla offers only the shift gesture, so OFF \
IS the reference's own behaviour; the spelling is era's",
),
// The overhead-name trio (0992): 1.12's own UnitName* CVars (UIOptionsFrame.lua indices
// 21/30/67) over the nameplates module's gates. Defaults mirror `NameConfig::default()` and
// are the binary's own, byte-read at the `0x6c7470` registrar (wow-re
// `object-layer/scratch/overhead-name.md`, name string / default string per row, folded into
// mask `0xce8720`) — `UnitNamePlayer` `0x86c694` → `"1"` `0x82e748`, `UnitNameNPC` `0x86c6a4`
// and `UnitNameOwn` `0x86c6b0` → `"0"` `0x82e570`. Corroborated the other way by the
// reference install's own `Config.wtf`, which carries `SET UnitNameNPC "1"` and
// `SET UnitNameOwn "1"`: `SaveConfig 0x63d980` writes only what has MOVED off its default, so
// those two lines existing is proof the defaults are not "1".
//
// **npc and own shipped ON here from 2026-07-12 until 1804** — see `NameConfig`'s doc.
same("UnitNamePlayer", "1"),
same("UnitNameNPC", "0"),
same("UnitNameOwn", "0"),
// The fourth of the same registrar's five (2149): `UnitNamePlayerGuild` `0x86c680` -> `"1"`
// `0x82e748`, mask bit `0x10`. It is NOT a show gate like the three above — `ShouldShowName
// 0x6070a0` consults only bits `0x1/0x2/0x4` — it gates ONE LINE of the player stack, the a5
// `"\n<%s>"` guild decoration at `0x609085` (wow-re `object-layer/scratch/overhead-name.md`
// Q4 point 3 + the registrar table). Its fifth sibling `UnitNamePlayerPVPTitle` (bit `0x20`,
// also `"1"`) has no row: a4's rank prefix needs a faction side `ui_unit` cannot resolve for
// an arbitrary player, so there is no reader and 1134 §4 says no key.
same("UnitNamePlayerGuild", "1"),
// The two V-plate toggles over `VPlateMode` — the engine bitmask `[0xc4da34]`'s bit 0 and
// bit 3. 1.12 registers NO nameplate CVar (wow-re, VERIFIED — the bitmask is a plain runtime
// global, persisted FrameXML-side as the `RegisterForSave`'d `NAMEPLATES_ON` /
// `FRIENDNAMEPLATES_ON`), so these take the LATER-era engine's names: the `autoLootDefault`
// posture, where benilla's persistence IS the CVar store (0954) and a setting with no 1.12
// CVar gets the era spelling rather than an invented one.
//
// **`same`, not `ours`, and that distinction is the point**: the reference has no CVar to
// match, but it very much has a *setting* to match, and it boots both halves OFF —
// `UIOptionsFrame_Init` assigns `NAMEPLATES_ON = nil` / `FRIENDNAMEPLATES_ON = nil` and
// `UpdateNameplates` only calls `ShowNameplates()` on a truthy value (the install's
// `Interface\FrameXML\UIOptionsFrame.lua` l.180-183 / l.769-775 — both of them the stock
// file's own, off the chain since 2115; our copies of each are gone). A fresh 1.12 client
// draws no plates until V is pressed. Enemy plates shipped ON here from 0167
// until 1804 — `VPlateMode::default()` carries that history.
same(crate::vplates::CVAR_ENEMIES, "0"),
same(crate::vplates::CVAR_FRIENDS, "0"),
// World detail (0992) — the ENVIRONMENT_DETAIL slider's 0..2, over the clutter-density knob.
// 0 is the client's bare `frillDensity` baseline (×1 = 16 visits), each step +1×, so 0/1/2 are
// the 16/32/48 `SetWorldDetail` itself writes.
//
// **Not a 1.12 CVar** (corrected 1804 — this row used to call it "1.12's video-panel var").
// `WorldDetail` does not exist as a string in `WoW.exe` (scanned; positive control
// `frillDensity` present), and `OptionsFrame.lua:27`'s `func = "WorldDetail"` is a *function
// name suffix*: the slider calls the engine's `GetWorldDetail`/`SetWorldDetail`. So the
// spelling is the API's — the `autoLootDefault` posture.
//
// **"1" IS the reference's own setting, and getting there took two goes.** `SetWorldDetail
// 0x488dd0` writes **two** CVars per stop: `frillDensity` {16,32,48} and `SmallCull`
// {0.07,0.04,0.01}. The registered pair is `frillDensity` **16** (stop 0) with `SmallCull`
// **0.04** (stop **1**) — the reference boots an inconsistent pair — and `GetWorldDetail`
// reads only `SmallCull`. So its own slider **reads Medium at boot**, which is this row.
// Mid-1804 this was filed as a deviation against "0" on two true facts that are not the
// answer: `OptionsFrame.lua:430`'s Defaults ladder yields 0, and `frillDensity` registers 16.
// A partly-verified mechanism is not the mechanism (wow-re
// `cvar/scratch/registered-defaults-census.md`, the §5 round 1804 dispatched).
//
// What IS still divergent is the grass, and that is a *mapping* difference rather than a
// 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. **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
// `{cvarName, defaultValue}` pairs at `0x8629e0` skipping the NULL/empty names, then one
// unrolled call for the death range (wow-re `object-layer/scratch/combat-log-chat-law.md`
// §5.2). They read as the CVar record's **float** (`+0x24`), unlike the periodic gate below,
// which reads the int.
//
// **They are why a damage meter's range slider does something.** `BigWigs/Plugins/Range.lua`
// and `DPSMate/DPSMate_DataBuilder.lua` both read and write all eight; unregistered, every
// `SetCVar` here wrote nothing and every `GetCVar` answered nil. The reader was already built
// — `ui_chat::combat::in_range` has run this exact table since 1571, off the compiled-in
// defaults, with `UnitClass::range_cvar` parked under `#[cfg(test)]` waiting for this row.
//
// Classes 0 and 1 — you and your pet — have NO CVar in the reference's table (a NULL name and
// the `100000.0` sentinel), so there is nothing to register for them and nothing to miss.
same("CombatLogRangeParty", "50"),
same("CombatLogRangePartyPet", "50"),
same("CombatLogRangeFriendlyPlayers", "50"),
same("CombatLogRangeFriendlyPlayersPets", "50"),
same("CombatLogRangeHostilePlayers", "50"),
same("CombatLogRangeHostilePlayersPets", "50"),
same("CombatLogRangeCreature", "30"),
// The one range CVar OUTSIDE that table (`0x626d5f`, default string `"60"` at `0x862e14`) —
// and the only formatter with a range of its own. `0x62c160` reads it first and falls back to
// the per-class getter only when the *lookup* fails, which a registered client never sees.
same(crate::ui_chat::combat::DEATH_LOG_RANGE_CVAR, "60"),
// ── The floating-combat-text gates, and the periodic one ────────────────────────────────────
//
// `CombatDamage` (`0x6032df`, record `[0xc4d944]`) is the MASTER: its only two readers are the
// localized-WORD emitter `0x607140` and the `"%d"` NUMBER emitter `0x6128b0`, and both branch
// targets are epilogues — so at "0" nothing floats over any unit from any source, words
// included, despite the CVar's own help text saying "damage numbers". The two `Pet*` rows are
// sub-gates below it, on the owned-by-you branch only; the self sub-case is unconditional.
//
// `PetSpellDamage` has no row in `UIOptionsFrameCheckButtons` — the *Show Pet Melee Damage*
// box writes both (`UIOptionsFrame_Save` l.334-336) — which is why 2077's census, which reads
// that table, could not see it while it saw its two siblings. Our own Pet Damage row carries
// the same partner write (2180); all three are on the Combat page, under `CombatDamage`.
same("CombatDamage", "1"),
same("PetMeleeDamage", "1"),
same("PetSpellDamage", "1"),
// `CombatLogPeriodicSpells` (`0x6033b3`, handle deliberately DISCARDED — every use re-looks it
// up by name). Read as the record's INT, unlike the ranges above, which read its float.
same(crate::ui_chat::combat::LOG_PERIODIC_CVAR, "1"),
// ── The three Sound-panel check buttons benilla had the machinery for and no key to ─────────
//
// All three are category-7 (sound) registrations that keep **no** `CVar::Register` handle: the
// reference looks each up by name at the point of use. Each already had its reader here.
//
// `SoundListenerAtCharacter` (`0x457890`, "lock listener at character"): both of its branches
// were already written in `update_audio_listener` — the at-character seat and the at-camera
// one — with the camera path reachable only as a no-character fallback. This is the selector
// they were missing.
same("SoundListenerAtCharacter", "1"),
// `EmoteSounds` (`0x4573b9`): the received text-emote voice kit, and only that.
same("EmoteSounds", "1"),
// `SoundZoneMusicNoDelay` (`0x4578b3`): `next_track_time`'s own comment named it as "the
// immediate path, a \"0\" CVar we don't expose". Now exposed, at the reference's `"0"`.
same("SoundZoneMusicNoDelay", "0"),
// `assistAttack` (`0x48fc50`, record `[0xb4d8f8]`) — `/assist`'s opt-in second leg: select the
// basis unit's target AND open the swing on it. Three references image-wide, two of them the
// shared assist tails; `CanAssist 0x6066f0` is verified NOT on the path. The `"0"` default is
// the one wow-re had to correct against itself — its first pass read `"3"` off the *next*
// registration's default (`minimapZoom`), the `mov ds:` adjacency trap — so it is worth saying
// plainly here: stock `/assist` selects and does not swing.
same("assistAttack", "0"),
// ── Mouse-look, per axis: the two CVars whose absence RAISED in the stock window ────────────
//
// `cameraYawMoveSpeed` is `UIOptionsFrameSliders` row 3, MOUSE_LOOK_SPEED (90…270 step 10) —
// and it is what made stock `UIOptionsFrame_Load()` die: `slider:SetValue(GetCVar(value.cvar))`
// is a shape-A binding (`0x790980`) that raises on a nil in the reference too, so the whole
// window's `_Load` (and `_SetDefaults`, through `GetCVarDefault`) stopped at slider 3.
// `cameraPitchMoveSpeed` has no row of its own: `UIOptionsFrame_Save` writes it as
// `sliderValue / 2` beside the yaw one (l.352-356), which is exactly this 180/90 pair — and
// the Controls page's Mouse Look Speed row carries that partner write (2180), so dragging it
// keeps the two axes in the ratio the registrar ships them at.
//
// **Both defaults are the reference's, and the shipped feel does not change** — the two facts
// are compatible only because the unit divergence is carried in `camera::LOOK_YAW_PER_SPEED`
// instead of in these numbers. The reference integrates OS-accelerated `WM_MOUSEMOVE` pixels
// (it imports no DirectInput at all) while we integrate winit's raw device delta, so its
// `deg per pixel` is not our `deg per unit` and the factor between them is a per-machine
// pointer setting. Anchoring the scale there and keeping the defaults here is what lets 1804
// hold honestly rather than by picking a number that merely looks right.
//
// The validator is `0x50c000` → `0x50b330`, range [0.1, 360], and it **rejects rather than
// clamps** — `apply_to_knobs` does the same below, which is why these two do not use the
// clamping shape every other numeric row uses.
same("cameraYawMoveSpeed", "180"),
same("cameraPitchMoveSpeed", "90"),
// Mouse Sensitivity (1140): 1.12's own MOUSE_SENSITIVITY slider (`UIOptionsFrameSliders` row
// 1, 0.5..1.5 step 0.05), a MULTIPLIER over the camera's own per-pixel rate — which was a
// frozen constant until this row. Default "1" is the shipped feel exactly, welded to
// `LookConfig::default()`.
//
// **The spelling is FrameXML's, not the binary's**, and that is deliberate: `WoW.exe` holds
// `mouseSpeed` (capital S, register site `0x402c7b`) while `UIOptionsFrame.lua`'s slider
// writes `cvar = "mousespeed"`. The reference reconciles them by looking CVars up
// case-insensitively (`SStrCmpI`, wow-re `cvar/cvar.md`), and so do we (`apply_to_knobs`
// lowercases first), so both spellings answer. We take the one the interface uses, because
// that is the one an addon will type.
//
// **The VALUE agrees and the MECHANISM does not** (wow-re
// `cvar/scratch/registered-defaults-census.md` §, 1804). The reference's default is not a
// literal at all: it is `sprintf("%1.1f", SPI_GETMOUSESPEED × 0.1)`, which is `"1.0"` on a
// stock Windows host — so "1" is the right number. But its record has **zero readers**: the
// slider drives the *operating system's* pointer speed through `SPI_SETMOUSESPEED` (clamped
// [0.1, 2.0]), not an in-engine gain. benilla will not reach out and repoint the OS mouse, so
// ours is a multiplier over our own per-pixel rate — the same dial, the same range, the same
// resting value, a different thing underneath. Recorded here rather than filed as a deviation
// because the default is the question this table answers, and the default matches.
same("mousespeed", "1"),
// Max Camera Distance (1140): 1.12's `cameraDistanceMaxFactor` (its MAX_FOLLOW_DIST slider,
// 1..2 step 0.1) over `cameraDistanceMax`'s 15 yd base. **"1", the reference's registrar
// value** (wow-re `ui/scratch/follow-camera.md`: "cameraDistanceMax 15.0,
// cameraDistanceMaxFactor 1.0") — so the shipped ceiling is 15 yd, not the 30 this row
// registered from 1140 until 1804. `ZoomLimit`'s doc carries why that changed; the slider
// still reaches 2.
same("cameraDistanceMaxFactor", "1"),
// Camera Following Style (1493, re-pinned by 1502): 1.12's `cameraSmoothStyle` — the
// auto-return that swings the camera back behind the character. Registered "1" = Smart, which
// is BOTH the reference's registrar default (byte-verified: the argument is loaded from
// `[0x84f4f4]` -> "1" at the `0x50ba92` register site) and the director's call; benilla behaved
// as Never unconditionally until this row. The enum is the ENGINE's — 0 Never, 1 Smart,
// 2 Always — NOT the 1/2/3 the reference's own dropdown writes; see `FollowStyle`.
same("cameraSmoothStyle", "1"),
// Its sibling selector (1502), also registered "1": the reference reads THIS style instead
// whenever the state mask contains Track or Fear — the externally-driven states — indexing the
// same matrices. No row on any 1.12 panel, here or there; the reader is the host.
same("cameraSmoothTrackingStyle", "1"),
// The auto-follow's rate (1502), °/s — 1.12's own AUTO_FOLLOW_SPEED slider
// (`UIOptionsFrameSliders`, 90..270 by 10), registered at the binary's "180.0" (`[0xbe1070]`).
// It sets the transition's DURATION (`|dyaw| / rate * factor`), so it is an average rate, not a
// slew. Its slider is the Controls page's Auto-Follow Speed row (2180), greyed while the
// following style is Never — and it writes only this one, where the reference also writes
// `cameraPitchSmoothSpeed` at a quarter of it: that name is deliberately unregistered here,
// because `FollowRig` has a single rate and a key with no reader is 1134 §4's pretence.
same("cameraYawSmoothSpeed", "180"),
// **The four 1.12 camera-option toggles** (decision 2149) — the `UIOptionsFrame` checkboxes
// FOLLOW_TERRAIN / HEAD_BOB / SMART_PIVOT / WATER_COLLISION, all four of which sat on the
// unbacked-CVar census with a byte-level spec and no feature until now. Defaults are the
// registrar's own (`re/cvar/cvar-register-sites.tsv`), and two of them are **"1"** — which is
// why building them was not cosmetic: benilla was the divergence on those, not the reference.
//
// `cameraPivot` `[0xbe10a4]` "1" (`0x50bda3`) — smart pivot. Mechanism: wow-re
// `ui/scratch/camera-cvar-gates.md` §3 (gate `0x510690`, routing `0x50fee0`, release
// `0x5107f0`); ours is `player::camera_dynamics::SmartPivot`.
same("cameraPivot", "1"),
// Its two drag-shape thresholds, both read by that routing (`0x50fff5`/`0x510004`) and both
// in RADIANS of camera rotation — the deltas they are compared against are already scaled by
// `camera<Yaw|Pitch>MoveSpeed · π/180`, so unlike the sensitivity itself these two transfer
// to benilla's raw-device units exactly (see `camera::LOOK_YAW_PER_SPEED`'s note).
same("cameraPivotDXMax", "0.05"),
same("cameraPivotDYMin", "0"),
// The rate the pitch bias eases back on once the pivot lets go, deg/s (`[0xbe0fc8]`,
// `0x512a50`'s `duration = |Δ| / (rate · π/180)`). No panel row here or there — the reader is
// the host, exactly like `cameraSmoothTrackingStyle` above it.
same("cameraTargetSmoothSpeed", "90"),
// **`cameraWaterCollision`** `[0xbe1088]` "1" (`0x50bd63`, default string `0x82e748`) — one of
// the two that ship ON, and this row is its THIRD life. It is **one CVar with two consumers**,
// and this tree has now shipped each of them alone and broken the camera both times: 2149 the
// pivot corridor without the trace (a 19/18 yd step reached continuously), 2170 the trace
// without the corridor (the boom straddling a plane the pivot sits 11 mm under — 2173 §1).
// Both halves are here now, and the row exists to say they may never again be separated.
//
// `0x50e5ec` produces one register. Its `0xf0000` nibble rides the trace mask to all three of
// `0x50e570`'s queries, reaching `0x69cc13` through four direct calls; and `0x50e629` tests
// the SAME register to admit the floor/cap block that lifts the sweep origin to
// `surface + 2/9`. Readers: the camera boom's collision filter
// (`benilla_world::collision::camera_filter`) and `player::camera_water`.
same("cameraWaterCollision", "1"),
// `cameraTerrainTilt` `[0xbe0fd4]` **"0"** (`0x50bcfd`) — Follow Terrain, and the one of the
// four that ships OFF, so building it changed nothing until a player ticks the box. Mechanism:
// wow-re `camera-cvar-kernels.md` §2 (the ahead-probe and the five-step staircase) and
// `camera-smooth-style.md` §9 (the arm); ours is `player::camera_dynamics::TerrainTilt`.
same("cameraTerrainTilt", "0"),
// The ground channel's rate, deg/s (`[0xbe0fc0]`) and the duration bound its `Factor` scales
// (`[0xbe1050]`/`[0xbe1054]`, seconds). The floor always binds — `20° / 7.5°/s` is 2.67 s
// against a 3 s minimum — which is why a followed terrain leans rather than tracks.
same("cameraGroundSmoothSpeed", "7.5"),
same("cameraTerrainTiltTimeMin", "3"),
same("cameraTerrainTiltTimeMax", "10"),
// `cameraBobbing` `[0xbe10c0]` **"0"** (`0x50b76d`) — head bob, the fourth of the four and the
// second that ships OFF. Mechanism: wow-re `camera-cvar-kernels.md` §4 and
// `camera-cvar-gates.md` §2; ours is `player::camera_dynamics::HeadBob`.
same("cameraBobbing", "0"),
// Its four numeric siblings. The two amplitudes are in the CVar's own units — the kernel
// scales both by 1/36 (`[0x7ff9d0]`) to reach yards. `cameraBobbingSmoothSpeed` is the odd one
// and its name is the trap: it is **not** a bob rate, it is the DECAY rate, and its single
// image-wide read is in the disarm `0x51113a`, where `|largest component| / speed` becomes the
// ramp's duration (~0.069 s at these defaults).
same("cameraBobbingLRAmplitude", "2"),
same("cameraBobbingUDAmplitude", "2"),
same("cameraBobbingFrequency", "0.8"),
same("cameraBobbingSmoothSpeed", "0.8"),
// Status Text (1140): 1.12's `statusBarText`, the "always show value / max on a status bar"
// switch. **No host knob** — its consumer is Lua (TextStatusBar.xml, decision 1082, which was
// written waiting for this key and reads it on every repaint). Default "0": the reference's
// out-of-box look is hover-only numerals.
//
// **Byte-read since 1804** — register site `0x48fc34`, default string `"0"`, record
// `[0xb4d904]`, and a whole-image census finds that record has **no engine reader at all**:
// this CVar is FrameXML's alone, which is exactly the shape this row was built for (wow-re
// `cvar/scratch/registered-defaults-census.md`). It used to concede "behaviour-derived, not
// byte-read"; that hedge is retired.
same("statusBarText", "0"),
// Enhanced Tooltips (B230): 1.12's `UberTooltips`, the *Enhanced Tooltips* checkbox
// (`UIOptionsFrame.lua:15`, `USE_UBERTOOLTIPS`). **No host knob** — its consumers are Lua, and
// there are three: PetActionBar.xml forks the whole tooltip on it (a token's own text with the
// binding appended, vs the engine's pet-spell channel), the stock action and shapeshift buttons fork
// their anchor. Registered "1" — byte-read, not behaviour-derived: WoW.exe `0x48fdd9`, default
// string `0x82e748`, with the sibling rows `BlockTrades`→"0" and `UnitNameRenderMode`→"2"
// confirming the layout. Those three Lua sites each carried the reference's fork in prose and
// then collapsed it to this default, on the stated premise that benilla shipped no CVar state
// for anything to move. That premise expired with 0954, and this row is what un-collapses them.
same("UberTooltips", "1"),
// The two chat-bubble switches (1139): 1.12's own registrar CVars over the bubble gate,
// which held them as `const bool` from 0598 until this window had a page for them. Both are
// the binary's own (registrar `0x603280` — wow-re `object-layer/scratch/chat-bubble.md`:
// `ChatBubbles` "1", `ChatBubblesParty` "0"); the party half shipped ON from 0598 to 1804 on
// the director's `/p` ask, and is a click away on the Chat page.
same("ChatBubbles", "1"),
same("ChatBubblesParty", "0"),
// **The two text filters** (2077) — 1.12's own pair, and both are real features rather than
// vestigial switches, which is what the wow-re §5 round behind `text-filter-law.md` settled.
// Registered `"1"` each, byte-read: `0x402e68` (`profanityFilter`, name `0x82e7f4`, callback
// `0x403570`) and `0x402e8e` (`spamFilter`, name `0x82e7d4`, callback `0x4035b0`), both pushing
// the shared `"1"` literal `0x82e748`, both category 4.
//
// `profanityFilter` masks matched spans of `ChatProfanity.dbc` in place, and it gates INSIDE
// the shared masker (`0x4a1a66`), so all thirteen of its call sites are covered by the one
// switch — the 14 social chat types, mail, the guild MOTD/info/rank names, item text and the
// send path. `spamFilter` is a predicate over `SpamMessages.dbc` at the chat chokepoint that
// **drops** a matching line silently. The knob for both is
// [`crate::text_filter::TextFilterSwitches`]; the engine is `crate::text_filter`.
same("profanityFilter", "1"),
same("spamFilter", "1"),
// **The loading-screen tip of the day** (2077) — 1.12's own pair, both registered lazily by
// `CGlueMgr::EnterWorld` on its way to the config flush (`0x46b633` `gameTip` `"0"`,
// `0x46b658` `showGameTips` `"1"`, both category 5, neither with a callback or a help string;
// wow-re `system/loadingscreen/scratch/game-tip-of-the-day.md`).
//
// `gameTip` is not a preference — it is the **cursor**, and it holds the NEXT row rather than
// the one on screen, which is why the reference's own `Config.wtf` reads `SET gameTip "34"`
// while showing row 33. It is registered here because that is how it persists: the file is
// composed from the VM's live table, so the host's advance writes through it. `crate::game_tip`
// is the law.
same("gameTip", "0"),
same("showGameTips", "1"),
// *Detailed Loot Information* (1589, the Chat page) — 1.12's `showLootSpam`, whose subject is
// group LOOT ROLLS (its own tooltip: "Uncheck this to hide individual loot roll messages and
// only show the winner"). Registered `"1"`, **byte-read**: wow-re's census of `0xb4e2bc`
// (`lootroll-chat-and-lifecycle.md` §4) has the register site at `0x48fd1c`, name `0x8430a0`,
// default string `0x82e748` = "1", **category** 5 — and exactly four references to the global,
// one writer and three readers, all in the roll-line composers. The knob is
// [`crate::ui_loot::LootConfig::show_loot_spam`], welded to that default below.
same("showLootSpam", "1"),
// *Guild Member Alert* (1589, the Chat page) — 1.12's `guildMemberNotify`, whose registered
// help string says what it does: "Receive notification when guild members log on/off".
//
// Registered **`"0"`** — this is one of the few rows that ships a feature OFF, and it is
// byte-read rather than chosen: the register site `0x5e24c7` pushes default `0x82e570` = "0"
// (§5, wow-re `system/object-layer/scratch/guild-signon-cvar-gate.md`). A stock 1.12 client is
// silent when a guildmate logs in, and a whole-image census of the record global `0xc4d3c4`
// finds exactly two readers, both inside `SMSG_GUILD_EVENT`'s handler. The knob is
// [`crate::ui_guild::GuildMemberNotify`]; the other three conjuncts of the line's display
// condition live on `ui_guild::apply::event`.
same("guildMemberNotify", "0"),
// The minimap's two zoom indices (1131). Byte-verified 1.12 CVars, both registered `"3"`
// (wow-re, at the `RegisterCVar 0x63db90` argument slot). No options row drives these — the
// +/- buttons on the minimap do, through `Minimap:SetZoom`, exactly as in the reference, where
// `set_zoom` writes the live index and `CVar::Set`s the CVar in one breath. The knob is
// [`crate::minimap::MinimapZoom`], the widget's live index is seeded from it at UI load.
same("minimapZoom", "3"),
same("minimapInsideZoom", "3"),
// The addon version gate (decision 1292): 1.12's own `checkAddonVersion`, the *Load out of
// date AddOns* checkbox INVERTED. Registrar default "1" = check enforced = box unticked —
// byte-verified (wow-re `addon-version-gate.md` §1.1: the key appears in Config.wtf exactly
// while force-load is on and vanishes when it is turned off, `SaveConfig 0x63d980`'s
// 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
// `present_mode` follows.
//
// Default "1" is BEHAVIOUR-derived, not byte-read: 1.12's registrar value for this var is not
// pinned in wow-re, and "1" is what benilla actually ships — the primary window is born at
// `PresentMode::default()` (Fifo), and a test in `video.rs` welds the two together.
//
// Two knowing departures from the reference row, both stated on [`crate::video`]: its
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
same("gxVSync", "1"),
// **Display mode** (decisions 1627, 1650) — 1.12's own `gxWindow`, worn since 1650 as modern
// Classic's two-entry *Display Mode* dropdown rather than 1.12's *Windowed Mode* checkbox: the
// two states 1627 settled on ARE that client's two (its own `Graphics.lua` builds the list from
// `VIDEO_OPTIONS_WINDOWED_FULLSCREEN` and `VIDEO_OPTIONS_WINDOWED`, and nothing else), and a
// checkbox could only name one of them. The knob is [`crate::video::VideoConfig::display`],
// which the window's `mode` follows.
//
// Default **"0" = not windowed**, which is the reference's own default and every shipped
// game's — but "0" does NOT mean what it means in 1.12. The reference mode-sets the display;
// we raise a **borderless** fullscreen window, and ship no exclusive mode at all.
// [`crate::video`] carries the three-platform argument for why that is the whole of it (short
// version: Wayland cannot do exclusive, X11's XRandR path cannot restore the desktop after a
// crash, macOS has no such mode, and WoW itself dropped exclusive fullscreen in 8.0.1).
//
// Departs from the reference row's `gxRestart = 1` exactly like `gxVSync` above: ours applies
// on the click.
// Byte-read `"0"` at register site `0x63a889` — **for enUS**. Three defaults in this binary
// are locale-conditional and this is one: `gxWindow` and `gxMaximize` register `"1"` on zhCN,
// `AutoInteract` `"1"` on koKR (wow-re `cvar/scratch/registered-defaults-census.md`, which
// caught its own instrument publishing a single arm mid-round). benilla is enUS-only, so `"0"`
// is the answer here; the note exists so the next reader does not take a locale-conditional
// default for an unconditional one.
same("gxWindow", "0"),
// The **windowed** size, `gxResolution` — 1.12's own CVar name, narrowed to half its job.
// There it is the display mode *and* the backbuffer; here it is only what "windowed" means,
// because fullscreen is the monitor's own size and we expose no mode list to pick from (the
// deviation decision 1092 already records for `GxAspect`, unchanged by 1627).
//
// A **string** CVar, like it is in the reference — the one row [`apply_to_knobs`] has to match
// ahead of its numeric parse. Default is the 1600×900 that was the client's only size before
// 1627, so a windowed run is bit-for-bit where it was.
deviates(
"gxResolution",
"1600x900",
"640x480",
"1627: narrowed to the WINDOWED size only — fullscreen is the monitor's own and we expose \
no mode list, and 640x480 is not a window anyone would ship a client at",
),
// The body panes' half-rate render (decision 1444) — **benilla's own CVar**, no 1.12
// counterpart: the reference draws its doll inside the main pass (no second view exists to
// rate-limit), while our RTT booths (1069) re-run the render graph per pane per frame. "1" =
// the doll renders at half the frame rate while its pane is open; the knob is
// [`crate::portrait::PaneRate`], and the default mirrors it (welded below).
//
// **Default ON (half-rate) — restored by 1607.** 1444 shipped it on; 1559 turned it off for
// a smoother doll (a look-call); the 08-25 weak-GPU perf reports (B329) measured the cost —
// ~1.6 ms at 1600×900, 7.6 ms at 4K, per frame while a body pane is open — and the director
// retested the 30 fps doll as fine. Full-rate is one `SetCVar("boothHalfRate", 0)` away.
ours(
"boothHalfRate",
"1",
"1444/1607: benilla's own — the reference draws its doll inside the main pass and has no \
second view to rate-limit",
),
// The select screen's memory of who you last entered the world as (decision 1622) — 1.12's
// own `lastCharacterIndex`, help string "Last character selected". **No host knob**: the live
// value is the character screen's own state ([`crate::char_select::Roster::pending_index`]),
// which this row only mirrors — the `statusBarText` posture, and why the arm in
// [`apply_to_knobs`] is empty.
//
// Registered **"0"**, byte-read rather than chosen: `CVar::Register` at `0x402d93` pushes
// default string `0x82e570` = "0", category 4, and caches the CVar* at `[0x882674]`. The value
// is a **0-based** row (the engine's selection cell `[0x83856c]` under `"%d"`), so "0" is the
// FIRST character and not a "no memory" sentinel — which is exactly why a stock `Config.wtf`
// has no such line until you have played somebody other than your first character
// (`SaveConfig 0x63d980` skips values equal to their default; `compose_file` does the same).
// Multisample antialiasing — 1.12's own `gxMultisample`, registered at `0x63a950` with help
// "multisample antialiasing" and flags `3` = registered | **latched**. The knob is
// [`benilla_world::view::MsaaSetting`], read once at the world camera's spawn; its doc carries
// the full derivation.
//
// **Default "1" — off — and BYTE-DERIVED, unusually indirectly.** The reference registers no
// literal here: the default string is `snprintf("%d")`'d at runtime from field 21 of the
// `VideoHardware.dbc` row `DetectHardware` (`0x641260`) matched the GPU to. Across the shipped
// 193-row table that field only ever holds 1 (144 rows) or 2 (49 rows), and the three rows the
// fallback match can reach all hold 1 — so on any GPU the 2004-era table does not list, which
// is every machine this client runs on now, the registered string is "1". A 1 is genuinely no
// multisampling on both of its backends, not a one-sample mode. (wow-re §5 cross-check,
// 2026-08-26, `system/console/scratch/gxmultisample-default.md`; decision 1629.)
//
// Latched means a change is PENDING until the next launch — the reference's own callback
// echoes "set pending gxRestart" — so this row persists and `GetCVar` answers it, while the
// camera keeps what it was born with. `$WOW_MSAA` overrides it session-only, below.
same("gxMultisample", "1"),
// The multisample triple's other two thirds. The reference's Video dropdown formats all three
// into one row (`MULTISAMPLING_FORMAT_STRING` = "%d-bit color %d-bit depth %dx multisample")
// and `GetCurrentMultisampleFormat 0x48c580` looks up all three by name to find which row is
// selected — so without these registered that lookup can never match and the dropdown would
// sit on entry 1 forever.
//
// **They describe, they do not steer.** benilla does not offer a colour or depth format to
// choose: every format `benilla_world::view::MsaaFormats` publishes carries the same pair,
// derived from the swapchain format and `Depth32Float`. `SetMultisampleFormat` writes them
// from the chosen entry exactly like `0x48c640` does, which is a no-op in value and the right
// shape to keep. The defaults here are the literals that pair matches on every target we ship;
// if a target ever disagrees the dropdown's own row wins, because it is written from the live
// enumeration.
deviates(
"gxColorBits",
"32",
"16",
"1643: these describe, they do not steer — the pair is our swapchain's own, and every \
format `MsaaFormats` publishes carries it",
),
deviates(
"gxDepthBits",
"32",
"16",
"1643: as `gxColorBits` — the depth half of the same descriptive pair",
),
// **The texture filter policy** — 1.12's own `trilinear` and `anisotropic`, over
// `benilla_assets::TexFilterSetting`. The defaults are the reference's registered strings, and
// benilla had neither CVar: it hardcoded trilinear + aniso 8 at every sampler it built, which
// is mode 5 — the *top* of what these two can ask for — shipped as the thing you get before
// asking. `tex_filter.rs` carries the derivation and the cost.
//
// Latched, exactly like `gxMultisample` above and for a harder reason: a sampler is baked into
// the `Image` at load and lives in the uploaded texture, so a live change would mean rebuilding
// every texture in the world. The reference's own UI says "enabled upon restart".
// `$WOW_TRILINEAR` / `$WOW_ANISO` override session-only, below.
// **`trilinear` registers "1", not the registrar's "0"** (decision 1645, correcting 1642).
// The reference's `CVar::Register` string is `"0"`, but `hwDetect` — registered `"1"` — runs
// `DetectHardware 0x641260` at boot and `CVar::Set`s sixteen video CVars from the matched
// `VideoHardware.dbc` row before the first frame, then self-clears. Every GPU this client runs
// on is unlisted in a 2004 table, so the row is the fallback scan's, whose reachable set is
// exactly rows 168/169/170 — and `trilinear` is 1 on 169 and 170, at both CPU tiers, with no
// CPU bias term. Measured as well as derived: the reference's own `WoW/Logs/gx.log` reads
// `VID: 106b` → `DID: 2` → `videoID: 170`.
//
// This is the same shape as `gxMultisample` above, which also registers the value the hardware
// table yields rather than a literal the registrar never emits on a modern machine.
overridden(
"trilinear",
"1",
"0",
"1645: `hwDetect` sets it from `VideoHardware.dbc` field 9 before the first frame, and \
that field is 1 on both fallback rows an unlisted modern GPU can reach — measured on the \
reference's own `Logs/gx.log` (`videoID: 170`)",
),
// `anisotropic` registers `"1"` — off — and here the registrar's string IS the answer: it is
// **not** one of `hwDetect`'s sixteen (scan of `[0x639a60, 0x639b80)`: sixteen record-pointer
// reads, `0xc7f2e4` absent), so nothing overwrites it on any path.
same("anisotropic", "1"),
// **Weather Intensity** — the video panel's slider 9 (`OptionsFrame.lua:29`,
// `func = "weatherDensity"`, a real CVar name rather than an engine binding), and the nearest
// of 2177 §10's named-not-done: benilla has had the feature since 0310 and only the switch was
// missing. The reader is `benilla_world::weather::WeatherState::weather_density`, which scales
// the rain/snow/mist spawn rate through the reference's own `0x67b870` quality table
// {0.1, 0.33, 0.66, 1.0}. Rendering only — it never touches the wire grade, the two ramp
// channels, or the storm/fog blend, so no server-visible behaviour rides it.
//
// The reference registers **`"2"`** at `0x67b806` (flags 0, callback `0x67b870`, name string
// `0x8685ac`) — wow-re `cvar/scratch/graphics-cost-cvar-census.md` §4, whose §10 table also
// lists this row among the twelve the reference install's `Config.wtf` moves off its default.
deviates(
"weatherDensity",
"3",
"2",
"2181: every precipitation rate in `benilla-world`'s own precipitation module was \
derived and graded against the reference install's own apitrace captures, and that \
install runs \
`SET weatherDensity \"3\"` (K = 1.0) — so 3 is the value a benilla-vs-reference \
side-by-side is correct at, and the registered 2 would thin every rate to 0.66 against \
the only client we compare with. The slider is how a player takes it back down",
),
// **Brightness** (decision 2182) — the reference's `gamma`, registered at `0x402d70` with
// name `0x82e924` `"Gamma"`, default string `0x82e92c` **`"1.0"`** and flags **0** (not
// latched, so its change callback `0x4034d0` applies on the write).
//
// There the callback builds `ramp[i] = __ftol(pow(i · 1/255, gamma) · 65535)` (`0x591680`) and
// hands the 3×256 words to `GDI32!SetDeviceGammaRamp` — and **skips the upload windowed**
// (`byte[dev+0x20b]` = `CGxFormat +0x07` = `gxWindow`), which is every mode benilla has. So the
// reader here is not a ramp upload: it is [`crate::ui_gamma::DisplayGamma`], the same curve
// applied to the same values one stage later, inside the pass that already owns the composited
// image's single decode. wow-re `ffxeffects/scratch/whole-frame-grade-verdict.md` §(a) for the
// curve and `ui/scratch/video-options-verbs.md` §3 for the verbs.
//
// 1.0 is the identity ramp — load-bearing rather than tidy: at the default this client's
// output is what it was before the setting existed, so no visual golden moves.
//
// **Written `"1.000000"` rather than `"1.0"` or `"1"`, and that is not cosmetic.** Every value
// this key ever receives comes through `SetGamma`, whose `SStrPrintf(buf, 0x10, "%f", …)` is
// six decimals — so the row that Restore Defaults produces is `"1.000000"`, and a default
// string in any other spelling would make it compare *moved* and write a `config.toml` line
// holding the default value. The slider rows dodge this with their own trailing-zero strip
// (`OptionsSlider_OnValueChanged`); a row whose store is an engine verb cannot, because the
// verb owns the formatting. So the table speaks the verb's spelling instead, and
// [`sync_cvars`] seeds it the same way. `Same` is still exact: the test parse-compares, and
// the reference registers this value as `"1.0"` (`0x82e92c`).
same("gamma", "1.000000"),
// **Render scale** (decision 1639) — benilla's own CVar, no 1.12 counterpart, in the
// `boothHalfRate` / `SoundOutputLimiter` mould: the reference has no such dial because it has
// no second buffer to hang one on. The world renders into the composite lane's off-screen image
// at `window × this` while the UI stays at native resolution; the knob is
// [`crate::world_backdrop::RenderScale`], clamped to its `RENDER_SCALE_RANGE`.
//
// The era's nearest equivalent is `gxResolution`, which drops the interface along with the
// world and, in fullscreen, mode-sets the display — the thing 1627 deliberately stopped doing.
//
// Default "1" is off, and that is load-bearing rather than cautious: at 1.0 the lane reproduces
// its pre-1639 numbers bit-for-bit, so no visual golden in the tree moves. `$WOW_RENDER_SCALE`
// overrides it session-only, below.
ours(
"renderScale",
"1",
"1639: benilla's own — the reference has no off-screen buffer to hang a resolution dial \
on; its nearest equivalent, `gxResolution`, drops the interface with the world",
),
// **The FPS journal** (decision 2008) — benilla's own, and the one instrument that ships:
// `/console fpsJournal 1` appends a per-second row of position, frame cost and the GPU's
// per-pass split to `benilla-config/Diagnostics/fps-journal.csv` in any build, which is how
// a player on hardware we do not own measures for us. The knob is
// [`crate::perf::FpsJournalSetting`]. Off by default; persisted like every row, so a
// reporter who turns it on keeps it on until they turn it off — the file is theirs to
// attach and theirs to delete.
ours(
"fpsJournal",
"0",
"2008: benilla's own — 1.12 has no player-side perf log; its nearest thing is the \
Ctrl+R framerate label, a number with no file behind it",
),
same(crate::char_select::CVAR_LAST_CHARACTER, "0"),
];
/// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in
/// the reference too; consumers parse and clamp at their edge). BTreeMap so the file is stably
/// sorted on every save.
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct LocalConfig {
#[serde(default)]
cvars: BTreeMap<String, String>,
}
/// The persistence state: what the file said, which keys the environment overrides this
/// session, and the dirty/debounce pair.
#[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, session-owned keys keep their stored value).
file: BTreeMap<String, 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
/// `config.toml` out of that same table.
registered: crate::ui_script::VmMemo<bool>,
/// A change since the last save; `last_change` drives the one-quiet-second debounce.
dirty: bool,
last_change: Option<Instant>,
}
impl CvarPersist {
/// 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
/// in its dropdowns' `OnLoad`, and a `/reloadui` builds a fresh VM and loads the whole
/// interface before [`sync_cvars`]'s `Update` claim gets a turn. The ORDER at both call sites
/// is this first, `register_cvars` second (1291) — reversed, a reload resets every knobless
/// CVar to its factory value.
pub(crate) fn saved_base(&self) -> impl Iterator<Item = (String, String)> + '_ {
self.file
.iter()
.filter(|(k, _)| !self.session_owned.contains(&k.to_ascii_lowercase()))
.map(|(k, v)| (k.clone(), v.clone()))
}
/// One CVar as `config.toml` holds it — matched case-insensitively, so a hand-edited
/// spelling still answers.
///
/// Read from the persist state rather than from the VM's table for the callers that want a
/// value **before, or outside, a registered table**: the addon load walk runs while the VM's
/// CVar table does not exist yet (registration is a per-VM `Update` seed, 1291), and the
/// select screen wants its remembered row the moment a roster lands, from a system that has
/// no business holding the VM (1622). The 1291 fold keeps this current across VM
/// replacements, so it is the value the reference's live read would see.
pub(crate) fn stored(&self, name: &str) -> Option<&str> {
self.file
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
/// A persist state that already holds one stored value — a launch whose `config.toml` said
/// so, without a file. `#[cfg(test)]` and `pub(crate)` because [`Self::file`] is private:
/// `char_select`'s restore test drives the real [`apply_roster_policy`] over a real remembered
/// row rather than a copy of its logic (the `Roster::with_pending_pick` posture).
#[cfg(test)]
pub(crate) fn with_stored(name: &str, value: &str) -> Self {
Self {
file: BTreeMap::from([(name.to_string(), value.to_string())]),
..Self::default()
}
}
/// The persisted `checkAddonVersion` (decision 1292) — what the addon load walk gates on.
/// Absent = the registrar default: check ON.
pub(crate) fn addon_version_check(&self) -> bool {
self.stored("checkAddonVersion").is_none_or(|v| v != "0")
}
}
/// How long a dirty config sits before the save fires — long enough to coalesce a slider drag,
/// short enough that a crash loses one gesture, not a session ("write-on-change, debounced").
const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
/// The startup fold of `config.toml` into the knob resources ([`load_config`]).
///
/// A set rather than a bare system because one knob is **read once and never again**: the world
/// camera takes its `Msaa` at spawn (decision 1629, the reference's latched `gxMultisample`), so
/// `setup_player` must not be able to run before the file has been folded in. Every other knob is
/// live-read and does not care.
///
/// This removes a **race, not an observed bug**. Measured: with the constraint deleted, a
/// `gxMultisample = "4"` in `config.toml` still reached the camera — and it did so despite
/// `PlayerPlugin` being added *before* `CvarPlugin` (`lib.rs`), i.e. the order that happened to
/// hold was the executor's choice out of an unconstrained graph, not insertion order and not
/// anything we could point at. The failure it prevents is silent (the player's setting is simply a
/// launch late) and would surface as a bug report nobody could reproduce.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct CvarLoad;
pub(crate) struct CvarPlugin;
impl Plugin for CvarPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CvarPersist>()
.add_systems(
Startup,
(load_config, publish_filter_policy)
.chain()
.in_set(CvarLoad),
)
.add_systems(Update, sync_cvars);
// **The flush is on the exit edge, not beside its feed** (decision 1528). It used to be
// `(sync_cvars, save_config).chain()` in `Update`, which made the "or the app exiting"
// half of its own gate dead on the exit a player actually causes: the close button's
// `AppExit` is not written until `PostUpdate`, so the last second of slider drags went
// with the process. `Last` still runs after `sync_cvars` — schedule order does what the
// `.chain()` did — and now also after every announcement.
crate::shutdown::on_app_exit(app, save_config.into_configs());
}
}
/// The knob resources as a **SystemParam** — the one census, fetched once, shared by all three
/// entry points ([`load_config`], [`sync_cvars`], [`fold_dying_vm_cvars`]).
///
/// It exists because the census had grown past Bevy's **16-param ceiling**: with fifteen knobs,
/// `sync_cvars` (script + persist + knobs) and the fold's `SystemState` both stopped compiling the
/// moment the plate toggles landed. Re-typing the list at every call site was already the shape
/// that made a new knob a four-place edit; bundling it makes a new knob one field here, one field
/// on [`Knobs`], and one arm in [`apply_to_knobs`], and the ceiling stops being reachable.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct KnobParams<'w> {
sound: ResMut<'w, SoundConfig>,
scale: ResMut<'w, UiScaleCvar>,
view: ResMut<'w, ViewDistance>,
msaa: ResMut<'w, MsaaSetting>,
msaa_formats: Res<'w, benilla_world::view::MsaaFormats>,
look: ResMut<'w, LookConfig>,
click: ResMut<'w, ClickConfig>,
loot: ResMut<'w, LootConfig>,
names: ResMut<'w, NameConfig>,
plates: ResMut<'w, VPlateMode>,
clutter: ResMut<'w, ClutterConfig>,
weather: ResMut<'w, benilla_world::weather::WeatherState>,
display_gamma: ResMut<'w, crate::ui_gamma::DisplayGamma>,
minimap: ResMut<'w, MinimapZoom>,
bubbles: ResMut<'w, BubbleConfig>,
zoom: ResMut<'w, ZoomLimit>,
follow: ResMut<'w, FollowConfig>,
camera_opts: ResMut<'w, crate::player::camera_dynamics::CameraOptions>,
video: ResMut<'w, VideoConfig>,
render_scale: ResMut<'w, RenderScale>,
tex_filter: ResMut<'w, benilla_assets::TexFilterSetting>,
pane_rate: ResMut<'w, PaneRate>,
guild_notify: ResMut<'w, crate::ui_guild::GuildMemberNotify>,
text_filter: ResMut<'w, crate::text_filter::TextFilterSwitches>,
game_tip: ResMut<'w, crate::game_tip::GameTipSetting>,
block_trades: ResMut<'w, crate::ui_trade::BlockTrades>,
auto_self_cast: ResMut<'w, crate::ui_action::AutoSelfCast>,
realmlist: ResMut<'w, crate::realmlist::Realmlist>,
fps_journal: ResMut<'w, crate::perf::FpsJournalSetting>,
assist_attack: ResMut<'w, crate::target::AssistAttack>,
combat_ranges: ResMut<'w, crate::ui_chat::combat::CombatLogRanges>,
damage_text: ResMut<'w, crate::combat_text::DamageTextGates>,
log_periodic: ResMut<'w, crate::ui_chat::combat::LogPeriodicSpells>,
}
impl KnobParams<'_> {
/// Borrow the set as [`Knobs`] for a write.
///
/// **Deref-muts every resource, so call it only when a change is actually being applied**
/// (0992's change-detection trap: the clutter re-scatter watches `is_changed::<ClutterConfig>`,
/// and a set built on every frame — or before the change queue is known to be non-empty —
/// re-scattered the world on every MasterVolume drag tick). Reading a field off `self`
/// directly, as the session seed does, goes through `Deref` and flags nothing.
fn knobs(&mut self) -> Knobs<'_> {
Knobs {
sound: &mut self.sound,
scale: &mut self.scale,
view: &mut self.view,
msaa: &mut self.msaa,
msaa_formats: &self.msaa_formats,
look: &mut self.look,
click: &mut self.click,
loot: &mut self.loot,
names: &mut self.names,
plates: &mut self.plates,
clutter: &mut self.clutter,
weather: &mut self.weather,
display_gamma: &mut self.display_gamma,
minimap: &mut self.minimap,
bubbles: &mut self.bubbles,
zoom: &mut self.zoom,
follow: &mut self.follow,
camera_opts: &mut self.camera_opts,
video: &mut self.video,
render_scale: &mut self.render_scale,
tex_filter: &mut self.tex_filter,
pane_rate: &mut self.pane_rate,
guild_notify: &mut self.guild_notify,
text_filter: &mut self.text_filter,
game_tip: &mut self.game_tip,
block_trades: &mut self.block_trades,
auto_self_cast: &mut self.auto_self_cast,
realmlist: &mut self.realmlist,
fps_journal: &mut self.fps_journal,
assist_attack: &mut self.assist_attack,
combat_ranges: &mut self.combat_ranges,
damage_text: &mut self.damage_text,
log_periodic: &mut self.log_periodic,
}
}
}
/// The knob resources one CVar write can land on, bundled so [`apply_to_knobs`] and its two
/// callers grow together (a new knob is one field + one arm).
struct Knobs<'a> {
sound: &'a mut SoundConfig,
scale: &'a mut UiScaleCvar,
view: &'a mut ViewDistance,
msaa: &'a mut MsaaSetting,
/// What the device actually offers — the ceiling `gxMultisample` is clamped to (1643).
msaa_formats: &'a benilla_world::view::MsaaFormats,
look: &'a mut LookConfig,
click: &'a mut ClickConfig,
loot: &'a mut LootConfig,
names: &'a mut NameConfig,
plates: &'a mut VPlateMode,
clutter: &'a mut ClutterConfig,
/// The weather driver's own state — `weatherDensity` writes ONE byte of it
/// ([`benilla_world::weather::WeatherState::weather_density`]), the particle-density
/// step; every other field on it is the wire's, not a setting's (2181).
weather: &'a mut benilla_world::weather::WeatherState,
/// The display-brightness ramp the UI lane's decode applies (2182).
display_gamma: &'a mut crate::ui_gamma::DisplayGamma,
minimap: &'a mut MinimapZoom,
bubbles: &'a mut BubbleConfig,
zoom: &'a mut ZoomLimit,
follow: &'a mut FollowConfig,
camera_opts: &'a mut crate::player::camera_dynamics::CameraOptions,
video: &'a mut VideoConfig,
render_scale: &'a mut RenderScale,
tex_filter: &'a mut benilla_assets::TexFilterSetting,
pane_rate: &'a mut PaneRate,
guild_notify: &'a mut crate::ui_guild::GuildMemberNotify,
text_filter: &'a mut crate::text_filter::TextFilterSwitches,
game_tip: &'a mut crate::game_tip::GameTipSetting,
block_trades: &'a mut crate::ui_trade::BlockTrades,
auto_self_cast: &'a mut crate::ui_action::AutoSelfCast,
realmlist: &'a mut crate::realmlist::Realmlist,
fps_journal: &'a mut crate::perf::FpsJournalSetting,
assist_attack: &'a mut crate::target::AssistAttack,
combat_ranges: &'a mut crate::ui_chat::combat::CombatLogRanges,
damage_text: &'a mut crate::combat_text::DamageTextGates,
log_periodic: &'a mut crate::ui_chat::combat::LogPeriodicSpells,
}
/// **The string-valued rows**, matched ahead of the numeric parse every other row goes through —
/// which would reject them as bad values. `gxResolution` was the first (decision 1627) and its
/// comment named this as the shape a second one would join rather than a second special case
/// somewhere else; `realmList` (1667) is the second, `realmName` the third. Every arm shares the
/// numeric miss's posture below: known key, bad value — consumed, with a warn, and the resource
/// keeps its truth.
///
/// **Split out of [`apply_to_knobs`] so the table can be held to it.** The claim
/// "a string row without an arm here is a CVar the client will never honour" was written beside
/// [`the_string_valued_cvars_are_the_realm_and_the_windowed_size`] and then not enforced:
/// `realmName` shipped with no arm, so every launch after the first connect warned
/// `cvar realmName: unparseable value 'VMaNGOS' ignored` on the way past the numeric parse. As a
/// separate `bool` this is something a test can call for every non-numeric row in the table, which
/// is what [`every_string_valued_row_is_claimed_before_the_numeric_parse`] now does.
fn apply_string_valued(key: &str, name: &str, value: &str, knobs: &mut Knobs) -> bool {
if !is_string_valued(key) {
return false;
}
match key {
"gxresolution" => match crate::video::parse_resolution(value) {
Some(size) => knobs.video.windowed = size,
None => warn!("cvar {name}: unparseable value '{value}' ignored"),
},
"realmlist" => match crate::realmlist::normalize(value) {
Some(address) => knobs.realmlist.set(&address),
None => warn!("cvar {name}: unusable realmlist '{value}' ignored"),
},
// No host knob, and none wanted: the live realm name is written from the session
// (`ui_script::addons::load_third_party`), and the persisted one reaches `GetCVar` through
// `set_cvar_saved_base` without passing here at all. Claimed anyway — the `statusBarText`
// 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
}
/// Which keys [`apply_string_valued`] claims — lowercased, and split out from the arms so a test
/// can hold the TABLE to it without building a `Knobs`. The claim it makes possible: every
/// 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, "gxapi" | "gxresolution" | "realmlist" | "realmname")
}
/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this
/// build knows (the caller decides whether that warns or rides through).
fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
let key = name.to_ascii_lowercase();
if apply_string_valued(&key, name, value, knobs) {
return true;
}
let Ok(v) = value.parse::<f32>() else {
warn!("cvar {name}: unparseable value '{value}' ignored");
return true; // known key, bad value — consumed, resource keeps its truth
};
match key.as_str() {
"mastervolume" => knobs.sound.master = v.clamp(0.0, 1.0),
"soundvolume" => knobs.sound.sfx = v.clamp(0.0, 1.0),
"musicvolume" => knobs.sound.music = v.clamp(0.0, 1.0),
"ambiencevolume" => knobs.sound.ambience = v.clamp(0.0, 1.0),
// The enables are 0/1 flags; the client's own parse is int + `!= 0`.
"mastersoundeffects" => knobs.sound.enabled = v != 0.0,
"enablemusic" => knobs.sound.music_enabled = v != 0.0,
"enableambience" => knobs.sound.ambience_enabled = v != 0.0,
"enableerrorspeech" => knobs.sound.error_speech = v != 0.0,
"sound_enablesoundwhengameisinbg" => knobs.sound.background_sound = v != 0.0,
// The client's own parse for this one is literally `!= 0` too (`0x4574d0`: `setne al`).
"soundreverb" => knobs.sound.reverb = v != 0.0,
"soundoutputlimiter" => knobs.sound.limiter = v != 0.0,
"soundlisteneratcharacter" => knobs.sound.listener_at_character = v != 0.0,
"emotesounds" => knobs.sound.emote_sounds = v != 0.0,
"soundzonemusicnodelay" => knobs.sound.zone_music_no_delay = v != 0.0,
"uiscale" => knobs.scale.0 = v.clamp(0.5, 1.5),
"farclip" => knobs.view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()),
// The reference REFUSES an out-of-range write here rather than clamping (`0x688d90` echoes
// "NearClip must be in range 0.01 - 0.33" and returns 0). We clamp, which is this table's
// standing posture for every range — the consumer clamps at its own edge.
"nearclip" => knobs.view.set_nearclip(v),
"deselectonclick" => knobs.click.deselect_on_click = v != 0.0,
"autoselfcast" => knobs.auto_self_cast.0 = v != 0.0,
"assistattack" => knobs.assist_attack.0 = v != 0.0,
// The sixteen camera-view CVars have no knob to apply to: `CameraViews` is their writer,
// not their reader (it seeds itself from the persisted file at startup, and `SaveView`
// writes back). They are claimed here so the table's own "not a knob this build knows"
// warning stays meaningful — an unclaimed name would look like a typo every session.
_ if crate::player::camera_view::is_view_cvar(name) => {}
"mouseinvertpitch" => knobs.look.invert_pitch = v != 0.0,
"cameradistancemaxfactor" => knobs.zoom.set_factor(v),
// The three stops are 1 Smart / 2 Always / 3 Never; anything else reads as the registrar
// default rather than as a dead camera (`FollowStyle::from_cvar`).
"camerasmoothstyle" => knobs.follow.style = FollowStyle::from_cvar(v),
// Its sibling selector — the one the reference swaps in for the externally-driven states.
"camerasmoothtrackingstyle" => knobs.follow.tracking_style = FollowStyle::from_cvar(v),
// The auto-follow rate, clamped to 1.12's own AUTO_FOLLOW_SPEED slider range.
"camerayawsmoothspeed" => {
knobs.follow.yaw_speed =
v.clamp(*FOLLOW_SPEED_RANGE.start(), *FOLLOW_SPEED_RANGE.end());
}
// The 1.12 slider's own range; an off-grid hand-edit rides between stops, like the others.
"mousespeed" => {
knobs.look.sensitivity = v.clamp(*MOUSE_SPEED_RANGE.start(), *MOUSE_SPEED_RANGE.end());
}
// The reference's `0x50b330` validator REJECTS an out-of-range value rather than clamping
// it: it prints `Value out of range (%f - %f)` and `CVar::Set` never stores, so the old
// value stands. That is a different posture from every clamping row above, and it is the
// faithful one — a script writing 1e9 gets a refusal, not a silently pinned camera.
"camerayawmovespeed" | "camerapitchmovespeed" => {
if !CAMERA_SPEED_RANGE.contains(&v) {
warn!(
"cvar {name}: value out of range ({} - {}) — ignored",
CAMERA_SPEED_RANGE.start(),
CAMERA_SPEED_RANGE.end()
);
return true;
}
if key == "camerayawmovespeed" {
knobs.look.yaw_speed = v;
} else {
knobs.look.pitch_speed = v;
}
}
"combatdamage" => knobs.damage_text.combat_damage = v != 0.0,
"petmeleedamage" => knobs.damage_text.pet_melee = v != 0.0,
"petspelldamage" => knobs.damage_text.pet_spell = v != 0.0,
"combatlogperiodicspells" => knobs.log_periodic.0 = v != 0.0,
// The combat log's eight display ranges (yards, the CVar's float field). One arm for all
// of them: `CombatLogRanges::set` walks the class table through `UnitClass::range_cvar`,
// so the seven names live in exactly one place and this arm cannot drift from them.
_ if knobs.combat_ranges.set(name, v) => {}
"autolootdefault" => knobs.loot.auto_loot = v != 0.0,
"unitnameplayer" => knobs.names.player = v != 0.0,
"unitnamenpc" => knobs.names.npc = v != 0.0,
"unitnameown" => knobs.names.own = v != 0.0,
"unitnameplayerguild" => knobs.names.player_guild = v != 0.0,
// The camera options (2149). The three numeric ones take the value straight: the
// reference's own validator on them is `0x50b330`'s range REFUSAL, which lives in
// `benilla_ui`'s `SetCVar` path, not here.
"camerapivot" => knobs.camera_opts.pivot = v != 0.0,
"camerawatercollision" => knobs.camera_opts.water_collision = v != 0.0,
"camerapivotdxmax" => knobs.camera_opts.pivot_dx_max = v,
"camerapivotdymin" => knobs.camera_opts.pivot_dy_min = v,
"cameratargetsmoothspeed" => knobs.camera_opts.target_smooth_speed = v,
"cameraterraintilt" => knobs.camera_opts.terrain_tilt = v != 0.0,
"cameragroundsmoothspeed" => knobs.camera_opts.ground_smooth_speed = v,
"cameraterraintilttimemin" => knobs.camera_opts.tilt_time_min = v,
"cameraterraintilttimemax" => knobs.camera_opts.tilt_time_max = v,
"camerabobbing" => knobs.camera_opts.bobbing = v != 0.0,
"camerabobbinglramplitude" => knobs.camera_opts.bob_lr_amplitude = v,
"camerabobbingudamplitude" => knobs.camera_opts.bob_ud_amplitude = v,
"camerabobbingfrequency" => knobs.camera_opts.bob_frequency = v,
"camerabobbingsmoothspeed" => knobs.camera_opts.bob_smooth_speed = v,
// The two V-plate toggles — the bitmask's two bits, flags like every other checkbox.
// Lowercased here like every arm; `VPlateMode`'s consts carry the registered spelling.
"nameplateshowenemies" => knobs.plates.enemies = v != 0.0,
"nameplateshowfriends" => knobs.plates.friends = v != 0.0,
// Two CVars with no HOST knob, because their consumers are Lua (1140, B230). Known — so
// the caller dirties the config and the value persists — with nothing to apply this side.
"statusbartext" | "ubertooltips" => {}
// The two bubble switches (1139) — flags, like every other pair here.
"showgametips" => knobs.game_tip.show = v != 0.0,
// The cursor, not a preference — a hand-edited or downgraded value lands here verbatim and
// `game_tip::raise` clamps it, which is the reference's own tolerance (`0x46b682`).
"gametip" => knobs.game_tip.next = v as i64,
"profanityfilter" => knobs.text_filter.profanity = v != 0.0,
"spamfilter" => knobs.text_filter.spam = v != 0.0,
"chatbubbles" => knobs.bubbles.all = v != 0.0,
"chatbubblesparty" => knobs.bubbles.party = v != 0.0,
// The loot-roll detail switch (1589) — a flag over the roll-line composer's two shapes.
"showlootspam" => knobs.loot.show_loot_spam = v != 0.0,
// Guild Member Alert (1589) — conjunct 2 of the sign-on/sign-off line's condition.
"guildmembernotify" => knobs.guild_notify.0 = v != 0.0,
"blocktrades" => knobs.block_trades.0 = v != 0.0,
// 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),
// Weather Intensity, the panel's 0..3 step 1 (2181). The reference's callback is
// `0x67b870`, a jump table (`0x67b8e8`) mapping 0/1/2/3 onto the quality cells
// {0.1, 0.33, 0.66, 1.0} in `[0x8680ec]` (wow-re
// `cvar/scratch/graphics-cost-cvar-census.md` §4). What that table does with an
// off-grid int is NOT carved, so the clamp here is this table's own standing
// posture rather than a fidelity claim — and it costs nothing either way, because
// `WeatherState::density_gain` already `.min(3)`s its own index.
"weatherdensity" => knobs.weather.weather_density = v.trunc().clamp(0.0, 3.0) as u8,
// Brightness (2182). The clamp is OURS and the reference has none — the reason it
// costs one is on [`crate::ui_gamma::GAMMA_RANGE`], and nothing a player can reach
// from the panel meets it.
"gamma" => {
knobs.display_gamma.0 = v.clamp(
*crate::ui_gamma::GAMMA_RANGE.start(),
*crate::ui_gamma::GAMMA_RANGE.end(),
)
}
// 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.
"minimapzoom" => knobs.minimap.outdoor = zoom_index(v),
"minimapinsidezoom" => knobs.minimap.inside = zoom_index(v),
// The addon version gate (1292): no host knob — the load walk reads the persisted value
// and the gate reads the live table — but a KNOWN key, so a toggle dirties the config
// and persists (the statusBarText posture).
"checkaddonversion" => {}
// The remembered character row (1622) — same posture again: the live value is the select
// screen's own, which writes this key rather than reading it back. Known, so entering the
// world dirties the config and the memory survives to the next launch.
"lastcharacterindex" => {}
// Vertical Sync — a flag like every other checkbox here. `video::apply_present_mode`
// watches the value and pushes it to the window; nothing else reads it.
"gxvsync" => knobs.video.vsync = v != 0.0,
// Display mode (1627) — a flag like every other checkbox here, and the reference's own
// polarity: `1` is WINDOWED (the row is "Windowed Mode"). `video::apply_window_mode`
// watches the value and pushes it to the window; nothing else reads it.
"gxwindow" => knobs.video.display = crate::video::display_from_flag(v),
// The body panes' half-rate render (1444) — a flag like every other checkbox here.
"boothhalfrate" => knobs.pane_rate.half = v != 0.0,
// Render scale (1639). Clamped at the knob's edge like every other numeric row; the
// backdrop re-sizes on the next frame and the world camera's target factor follows it
// in the same pass, which is what keeps the pick rays where they were.
"renderscale" => {
knobs.render_scale.0 = v.clamp(*RENDER_SCALE_RANGE.start(), *RENDER_SCALE_RANGE.end());
}
// The FPS journal switch (2008): a flag, the client's int-parse + `!= 0`. The journal
// system reads the knob every frame, so the file opens on the next second and closes
// the second it is turned off.
"fpsjournal" => knobs.fps_journal.0 = v != 0.0,
// Multisampling (1629) — the reference's own `atoi`-then-clamp `[1, 16]` at `0x63b250`.
// Writing the knob live is faithful, not a bug: the CVar holds the PENDING value (latched),
// and nothing reads this resource after the world camera's spawn.
"gxmultisample" => {
// TWO ceilings, and the second one was missing until 1643. The reference's own
// `atoi`-then-clamp `[1, 16]` comes first; then the DEVICE's, because a count this
// GPU does not offer is not a setting that degrades — it is a wgpu validation error
// that kills the render thread on frame one ("Sample count 8 is not supported by
// format Rgba16Float on this device", 2026-08-26).
//
// `MsaaSupportPlugin::finish` already clamped, but it runs once, before the first
// update — so it saw `MsaaSetting::default()` and never the value `load_config` was
// about to fold in from `config.toml`. A config written on a machine that offers 8x
// and opened on one that stops at 4 therefore reached the camera untouched. Clamping
// at the WRITE covers every writer there is: the file, a Lua `SetCVar`, the dropdown,
// and the Defaults button.
let asked = (v as u32).clamp(*MSAA_RANGE.start(), *MSAA_RANGE.end());
let granted = knobs.msaa_formats.clamp(asked);
if granted != asked {
// At `warn`, the same posture as the seed clamp: the player asked for something
// and did not get it, and this is the only place that fact exists.
warn!(
"cvar {name}: this GPU does not offer {asked}x multisampling — using {granted}x"
);
}
knobs.msaa.samples = granted;
}
// The filter policy's two halves. Both write the PENDING value — latched, like
// `gxMultisample`: the process policy is published once at the end of `load_config` and
// nothing reads this resource afterwards. `anisotropic` takes the reference's own
// parse-then-clamp `[1, 16]` (`0x689110`); `trilinear` is a flag like every other.
"trilinear" => knobs.tex_filter.trilinear = v != 0.0,
"anisotropic" => {
knobs.tex_filter.aniso = (v as u32).clamp(
*benilla_assets::ANISO_RANGE.start(),
*benilla_assets::ANISO_RANGE.end(),
)
}
_ => return false,
}
true
}
/// Freeze the texture filter policy for the process, and say what it resolved to.
///
/// **A separate system, chained after [`load_config`], deliberately.** `load_config` returns early
/// on an absent or malformed file, and the policy has to be published on every one of those paths:
/// the sampler lanes are an async `AssetLoader` and a set of ordinary systems, none of which can
/// read a resource the others own, so a run that never published would be reading
/// [`benilla_assets::tex_filter`]'s fallback while a player's `config.toml` said otherwise.
///
/// The log line is not decoration — it is the same reasoning as `video::log_display_session`
/// (1627). Every filtering report this client will get comes from a machine nobody here can run,
/// and "which mode was that run actually in" must be readable off the log a player pastes rather
/// than reasoned about.
fn publish_filter_policy(filter: Res<benilla_assets::TexFilterSetting>) {
benilla_assets::publish_tex_filter(*filter);
let mode = filter.mode();
let name = match mode {
3 => "bilinear + nearest-mip select, aniso off",
4 => "trilinear, aniso off",
_ => "trilinear + aniso",
};
info!(
"texture filter: mode {mode} ({name}) — trilinear={} anisotropic={}",
u8::from(filter.trilinear),
filter.aniso
);
}
/// A stored minimap zoom level → a valid index: truncate to int and clamp into
/// `[0, MINIMAP_ZOOM_LEVELS)`, the client's own `set_zoom` clamp.
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 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.session_owned.insert("uiscale".into());
}
if std::env::var_os("WOW_FARCLIP").is_some() {
persist.session_owned.insert("farclip".into());
}
// 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() {
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.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.session_owned.insert("trilinear".into());
}
if std::env::var_os("WOW_ANISO").is_some() {
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.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.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.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.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) => {
// A malformed file is preserved, not clobbered: nothing loads, but nothing saves
// over it either until a change actually happens — and the warn names the file.
warn!("{msg}");
return;
}
StoredConfig::Table(t) => t,
};
let known: HashSet<String> = REGISTERED
.iter()
.map(|r| r.name.to_ascii_lowercase())
.collect();
for (name, value) in &cvars {
let key = name.to_ascii_lowercase();
if !known.contains(&key) {
warn!("config: unknown cvar '{name}' — preserved, not applied");
continue;
}
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);
}
persist.file = cvars;
}
/// What the one read of `config.toml` found.
enum StoredConfig {
/// No file, no install, or a hermetic capture — every value is its registered default.
Absent,
/// The file's `[cvars]` table.
Table(BTreeMap<String, String>),
/// The file is there but unreadable or malformed. The string is what [`load_config`] warns
/// with — carried rather than logged, because this read happens before the `App` (and so
/// before `LogPlugin`) exists.
Bad(String),
}
/// Read `config.toml`.
///
/// **One parser, two callers at two different times** — [`load_config`] at `Startup`, and the
/// primary window literal in [`crate::run`], which has to know `gxWindow`/`gxResolution` *before*
/// the window exists ([`crate::video::boot_window_mode`] carries why booting windowed and flipping
/// a frame later is not good enough).
///
/// Deliberately **not** cached in a `OnceLock`, though it was written that way first. Three reads
/// of a sub-kilobyte file at process start is not a cost worth a global, and a process-wide cache
/// is actively wrong: every test that lays a config down and then runs `load_config` would be
/// answered from whatever the *first* test in the binary happened to see, and `local_state`'s home
/// law can legitimately move under a run. The thing worth having exactly one of is this function,
/// not its result.
fn stored_config() -> StoredConfig {
let Some(path) = crate::local_state::config_path() else {
return StoredConfig::Absent; // hermetic capture, or no install — session-only state
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return StoredConfig::Absent,
Err(e) => return StoredConfig::Bad(format!("config: cannot read {}: {e}", path.display())),
};
match toml::from_str::<LocalConfig>(&text) {
Ok(cfg) => StoredConfig::Table(cfg.cvars),
Err(e) => StoredConfig::Bad(format!(
"config: {} is malformed ({e}) — running on defaults",
path.display()
)),
}
}
/// One CVar as `config.toml` holds it, matched case-insensitively — **before the `App` exists**
/// (decision 1627).
///
/// Every other consumer wants [`CvarPersist::stored`], which answers from the same values once
/// they are a resource and stays current across a VM replacement (1291). This one exists for the
/// single caller that cannot wait for a resource: the primary window has to be *built* with its
/// display mode already resolved.
pub(crate) fn boot_cvar(name: &str) -> Option<String> {
match stored_config() {
StoredConfig::Table(t) => t
.into_iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v),
StoredConfig::Absent | StoredConfig::Bad(_) => None,
}
}
/// Per frame: seed the VM's table once it exists (registered set + the RESOLVED session values,
/// so `GetCVar` reflects env overrides and the loaded config alike), then drain Lua `SetCVar`
/// changes into the knob resources and mark the config dirty.
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;
};
if persist.registered.claim(&script) {
// Read-only borrows for the seed: field access through `ResMut`'s `Deref` flags nothing,
// which is the half of 0992's change-detection trap this system has to keep.
let KnobParams {
camera_opts,
sound,
scale,
view,
look,
click,
loot,
names,
plates,
clutter,
weather,
display_gamma,
minimap,
bubbles,
zoom,
follow,
video,
render_scale,
pane_rate,
guild_notify,
block_trades,
auto_self_cast,
text_filter,
game_tip,
msaa,
msaa_formats,
tex_filter,
realmlist,
fps_journal,
assist_attack,
combat_ranges,
damage_text,
log_periodic,
} = &params;
// The config file's values go in FIRST (decision 1291): registration — ours below, or an
// addon's `RegisterCVar` later — starts a key at its saved value. This is what carries a
// knobless CVar (`statusBarText`) and an addon-declared one across a VM replacement; the
// knob-derived session rows below still win for every key a host knob backs, and an
// env-overridden key keeps its env value the same way (its knob carries it).
script.set_cvar_saved_base(persist.saved_base().collect::<Vec<_>>());
script.register_cvars(registered_pairs());
// The Video dropdown's menu — what this device actually accepts, enumerated once at
// `finish()` by `view::MsaaSupportPlugin` (decision 1631) and handed over whole. Pushed
// here rather than owned by the VM because the list is a fact about the render adapter,
// which `benilla-ui` has no way to ask and should not grow one.
script.set_multisample_formats(
msaa_formats
.formats
.iter()
.map(
|&(color_bits, depth_bits, samples)| benilla_ui::script::MultisampleFormat {
color_bits,
depth_bits,
samples,
},
)
.collect(),
);
// **What `GetVideoCaps` answers with** (decision 2177) — the seven values the stock video
// window's `OptionsFrame_Load` destructures. Pushed beside the multisample list because it
// is the same kind of fact: what this client's device and presentation path really offer,
// which the VM has no way to ask.
//
// Six of the seven are properties of the client rather than of the adapter, and each is
// true here by construction:
// * shaders — wgpu has no non-programmable path; there is no fixed-function fallback to
// be missing. The reference asked because 2004 hardware could genuinely lack them.
// * trilinear and anisotropy — `benilla_assets::tex_filter` builds every sampler with
// both available; `ANISO_RANGE`'s top is the ceiling the `anisotropic` CVar clamps to
// and is reported raw, because `OptionsFrame.lua:124` matches it against
// `ANISOTROPIC_VALUES = {"1","2","4","8","16"}` with `tonumber` and ignores a value
// that is not one of them.
// * the hardware cursor — `crate::cursor` composites the reference's own
// `Interface\Cursor\*.blp` into an OS cursor on every target (an `NSCursor` on macOS,
// winit's `CursorIcon::Custom` elsewhere).
// * triple buffering — **false, and it is the one that does visible work**. wgpu's
// surface decides its own buffering and benilla exposes no knob, so the reference's own
// `OptionsFrame_Load` hides check button 13 and re-seats button 6 against button 5
// (`OptionsFrame.lua:168-175`). Answering `true` would light a checkbox writing a CVar
// nothing reads — 2115 §2's wrong answer that succeeds.
script.set_video_caps(benilla_ui::script::VideoCaps {
anisotropic: true,
pixel_shaders: true,
vertex_shaders: true,
trilinear: true,
triple_buffering: false,
max_anisotropy: *benilla_assets::ANISO_RANGE.end(),
hardware_cursor: true,
});
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 87] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
("AmbienceVolume", sound.ambience.to_string()),
("MasterSoundEffects", flag(sound.enabled)),
("EnableMusic", flag(sound.music_enabled)),
("EnableAmbience", flag(sound.ambience_enabled)),
("EnableErrorSpeech", flag(sound.error_speech)),
(
"Sound_EnableSoundWhenGameIsInBG",
flag(sound.background_sound),
),
("SoundReverb", flag(sound.reverb)),
("SoundOutputLimiter", flag(sound.limiter)),
(
"SoundListenerAtCharacter",
flag(sound.listener_at_character),
),
("EmoteSounds", flag(sound.emote_sounds)),
("SoundZoneMusicNoDelay", flag(sound.zone_music_no_delay)),
("uiScale", scale.0.to_string()),
("farclip", view.farclip.to_string()),
("nearclip", view.nearclip.to_string()),
("deselectOnClick", flag(click.deselect_on_click)),
("autoSelfCast", flag(auto_self_cast.0)),
("assistAttack", flag(assist_attack.0)),
("mouseInvertPitch", flag(look.invert_pitch)),
("mousespeed", look.sensitivity.to_string()),
("cameraYawMoveSpeed", look.yaw_speed.to_string()),
("cameraPitchMoveSpeed", look.pitch_speed.to_string()),
("cameraDistanceMaxFactor", zoom.factor().to_string()),
("cameraSmoothStyle", follow.style.cvar().to_string()),
(
"cameraSmoothTrackingStyle",
follow.tracking_style.cvar().to_string(),
),
("cameraYawSmoothSpeed", follow.yaw_speed.to_string()),
("cameraPivot", flag(camera_opts.pivot)),
("cameraPivotDXMax", camera_opts.pivot_dx_max.to_string()),
("cameraPivotDYMin", camera_opts.pivot_dy_min.to_string()),
(
"cameraTargetSmoothSpeed",
camera_opts.target_smooth_speed.to_string(),
),
("cameraWaterCollision", flag(camera_opts.water_collision)),
("cameraTerrainTilt", flag(camera_opts.terrain_tilt)),
(
"cameraGroundSmoothSpeed",
camera_opts.ground_smooth_speed.to_string(),
),
(
"cameraTerrainTiltTimeMin",
camera_opts.tilt_time_min.to_string(),
),
(
"cameraTerrainTiltTimeMax",
camera_opts.tilt_time_max.to_string(),
),
("cameraBobbing", flag(camera_opts.bobbing)),
(
"cameraBobbingLRAmplitude",
camera_opts.bob_lr_amplitude.to_string(),
),
(
"cameraBobbingUDAmplitude",
camera_opts.bob_ud_amplitude.to_string(),
),
(
"cameraBobbingFrequency",
camera_opts.bob_frequency.to_string(),
),
(
"cameraBobbingSmoothSpeed",
camera_opts.bob_smooth_speed.to_string(),
),
("autoLootDefault", flag(loot.auto_loot)),
("showLootSpam", flag(loot.show_loot_spam)),
("guildMemberNotify", flag(guild_notify.0)),
("BlockTrades", flag(block_trades.0)),
("UnitNamePlayer", flag(names.player)),
("UnitNameNPC", flag(names.npc)),
("UnitNameOwn", flag(names.own)),
("UnitNamePlayerGuild", flag(names.player_guild)),
(crate::vplates::CVAR_ENEMIES, flag(plates.enemies)),
(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). 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()),
("weatherDensity", weather.weather_density.to_string()),
// Six decimals, matching `SetGamma`'s own `"%f"` — see the row's comment.
("gamma", format!("{:.6}", display_gamma.0)),
("ChatBubbles", flag(bubbles.all)),
("ChatBubblesParty", flag(bubbles.party)),
("profanityFilter", flag(text_filter.profanity)),
("spamFilter", flag(text_filter.spam)),
("showGameTips", flag(game_tip.show)),
("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.
(
"gxWindow",
flag(video.display == crate::video::DisplayMode::Windowed),
),
// The one string-valued row, composed in the reference's spelling.
(
"gxResolution",
format!("{}x{}", video.windowed.x, video.windowed.y),
),
("boothHalfRate", flag(pane_rate.half)),
("renderScale", render_scale.0.to_string()),
("gxMultisample", msaa.samples.to_string()),
("trilinear", flag(tex_filter.trilinear)),
("anisotropic", tex_filter.aniso.to_string()),
("fpsJournal", flag(fps_journal.0)),
// The other string-valued row (1667): what the next logon attempt will actually dial,
// including a `$WOW_HOST` the player never typed.
(
crate::realmlist::CVAR_REALMLIST,
realmlist.address().to_string(),
),
// The combat log's eight display ranges, off the live table — written out one class at
// a time rather than composed in a loop, because this array is the readable census of
// what a session's `GetCVar` answers and a loop would hide eight rows inside one.
(
"CombatLogRangeParty",
combat_ranges.class(CombatClass::Party).to_string(),
),
(
"CombatLogRangePartyPet",
combat_ranges.class(CombatClass::PartyPet).to_string(),
),
(
"CombatLogRangeFriendlyPlayers",
combat_ranges.class(CombatClass::FriendlyPlayer).to_string(),
),
(
"CombatLogRangeFriendlyPlayersPets",
combat_ranges.class(CombatClass::FriendlyPet).to_string(),
),
(
"CombatLogRangeHostilePlayers",
combat_ranges.class(CombatClass::HostilePlayer).to_string(),
),
(
"CombatLogRangeHostilePlayersPets",
combat_ranges.class(CombatClass::HostilePet).to_string(),
),
(
"CombatLogRangeCreature",
combat_ranges.class(CombatClass::Creature).to_string(),
),
(
crate::ui_chat::combat::DEATH_LOG_RANGE_CVAR,
combat_ranges.death().to_string(),
),
("CombatDamage", flag(damage_text.combat_damage)),
("PetMeleeDamage", flag(damage_text.pet_melee)),
("PetSpellDamage", flag(damage_text.pet_spell)),
(
crate::ui_chat::combat::LOG_PERIODIC_CVAR,
flag(log_periodic.0),
),
];
for (name, value) in session {
script.set_cvar_host(name, &value);
}
}
// Take the changes BEFORE touching the knobs: constructing `Knobs` deref-muts every knob
// resource, which trips Bevy change detection even when nothing is written — and the
// clutter re-scatter is downstream of exactly that signal staying honest (0992).
let changes = script.take_cvar_changes();
if changes.is_empty() {
return;
}
let mut knobs = params.knobs();
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
persist.dirty = true;
persist.last_change = Some(Instant::now());
}
}
}
/// **A HOST-side CVar write that persists** — the counterpart to a Lua `SetCVar`, for the one CVar
/// the engine itself owns: `gameTip`, the loading screen's cursor (2077).
///
/// It has to go through the VM's table rather than through the knob alone, because
/// [`save_config`] composes the file from `cvars_snapshot()` — the knob is only ever *seeded* into
/// a VM at claim time, so a host write that stops at the knob is invisible to the file and the
/// cursor resets every launch. Marking dirty here is what arms the debounced save.
pub(crate) fn write_host_cvar(
script: &mut UiScript,
persist: &mut CvarPersist,
name: &str,
value: &str,
) {
script.set_cvar_host(name, value);
persist.dirty = true;
persist.last_change = Some(Instant::now());
}
/// Fold the dying VM's CVar table into the persist state — the session edge's half of decision
/// 1291's bridge (the seed in [`sync_cvars`] is the other). Called from
/// [`crate::ui_script::end_ui_session`] **after** the shutdown events (an addon's
/// `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference that write lands in an
/// engine-side store that survives) and **before** the VM is replaced.
///
/// Two steps, both about the writes the per-frame sync never got to see:
/// 1. drain the dying VM's change queue into the host knobs — a `SetCVar` in the final frame
/// would otherwise be overwritten by the stale knob when the next VM's seed runs;
/// 2. fold the table into `persist.file` with the same compose the saver uses, so the next VM's
/// saved base — and the next save — both start from what the player actually set.
///
/// `dirty` is left alone: if nothing changed, the fold is an identity; if something did, the
/// change that did it already marked the config dirty.
pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
// A world with no persist state has no file to bridge — a test world or a stripped scenario
// that never added the plugin. It is checked up front because the knob set below is fetched
// NON-optionally, and the two facts are one: any world carrying `CvarPersist` carries every
// knob too (the plugin's own `load_config`/`sync_cvars` take them the same way, and would
// have panicked at startup otherwise).
if !world.contains_resource::<CvarPersist>() {
return;
}
let mut state: bevy::ecs::system::SystemState<(
Option<NonSendMut<UiScript>>,
ResMut<CvarPersist>,
KnobParams,
)> = bevy::ecs::system::SystemState::new(world);
let (script, mut persist, mut params) = state.get_mut(world);
let Some(mut script) = script else {
return;
};
let changes = script.take_cvar_changes();
if !changes.is_empty() {
let mut knobs = params.knobs();
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
persist.dirty = true;
persist.last_change = Some(Instant::now());
}
}
}
let snapshot = script.cvars_snapshot();
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.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 — 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>,
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 session_owned.contains(&key) {
continue;
}
// Match any existing entry case-insensitively so a hand-edited spelling doesn't fork.
let existing = out.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned();
if value == default {
if let Some(k) = existing {
out.remove(&k);
}
} else {
out.insert(existing.unwrap_or_else(|| name.clone()), value.clone());
}
}
out
}
/// The file's header comment — where these values come from and where the law lives.
const HEADER: &str = "\
# benilla local config (decision 0954) — CVar values that moved off their defaults.
# Managed by the client; hand edits are read on next launch and preserved on save.
";
/// Dirty + one quiet second (or the app exiting) → rewrite `config.toml` atomically.
fn save_config(
script: Option<NonSendMut<UiScript>>,
mut persist: ResMut<CvarPersist>,
mut exits: MessageReader<AppExit>,
) {
let exiting = exits.read().next().is_some();
if !persist.dirty {
return;
}
let quiet = persist
.last_change
.is_none_or(|t| t.elapsed() >= SAVE_QUIET);
if !(quiet || exiting) {
return;
}
let Some(script) = script else { return };
let Some(path) = crate::local_state::config_path() else {
persist.dirty = false; // hermetic/session-only: nothing to write, stop retrying
return;
};
let snapshot = script.cvars_snapshot();
// **An empty table is not a player who cleared their settings.** The file is composed from the
// VM's live table, so a VM whose table was never registered would compose the player's
// `config.toml` back out *stripped* — a silent, irreversible loss of everything they had set.
// The seed above is what keeps that from happening; this is the floor under it, because the
// failure is one-way and the next regression in that seed must not be able to reach the disk.
if snapshot.is_empty() {
warn!("config: the VM has no registered cvars — refusing to compose the file from nothing");
persist.dirty = false; // nothing to save, and retrying every frame changes nothing
return;
}
let cvars = compose_file(&persist.file, &persist.session_owned, &snapshot);
let body = toml::to_string(&LocalConfig {
cvars: cvars.clone(),
})
.expect("string map serializes");
match crate::local_state::write_atomic(&path, &format!("{HEADER}{body}")) {
Ok(()) => {
persist.file = cvars;
persist.dirty = false;
}
Err(e) => {
warn!("config: cannot write {}: {e}", path.display());
persist.dirty = false; // don't retry every frame into the same error
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_script::DEFAULT_UI_SCALE;
/// **The standard, enforced: a benilla option's default IS the reference's** (decision 1804)
/// — every row's [`Reference`] column stands up.
///
/// This is the half that could not be a convention. Before it, the reference's value for a row
/// lived only in the prose above that row, which meant a default could be *chosen* without
/// anyone establishing what the client it imitates does — and five of them were: NPC and own
/// overhead names, enemy V-plates, party chat bubbles and the camera's max-distance factor all
/// shipped ON or raised while a stock 1.12 client ships them off or low, each one a reasonable
/// call on its own day and none of them visible as a *set* until somebody went looking. A
/// third column, mandatory and typed, is what makes the question unskippable; this test is
/// what makes the answer stay true.
///
/// It checks **both directions**, which is the part that matters over years:
/// - a [`Reference::Same`] row whose default has drifted off the reference's fails — you
/// cannot edit a default and leave the claim behind;
/// - a [`Reference::Deviates`] or [`Reference::Overridden`] row that has quietly come back
/// into agreement *also* fails, because a stale deviation note is worse than none: it hides
/// that we are already faithful and invites the next reader to "restore" a divergence.
///
/// Values are parse-compared where both sides are numeric, so `"1"` and `"1.0"` are one claim.
#[test]
fn defaults_stand_where_the_reference_column_says() {
/// One value against another — numeric when both parse, textual otherwise (`gxResolution`,
/// `realmList`).
fn agrees(ours: &str, theirs: &str) -> bool {
match (ours.parse::<f32>(), theirs.parse::<f32>()) {
(Ok(a), Ok(b)) => a == b,
_ => ours == theirs,
}
}
for row in REGISTERED {
let name = row.name;
match &row.reference {
Reference::Same(value) => assert!(
agrees(row.default, value),
"{name}: the row claims the reference registers {value:?} and we ship the \
same, but our default is {:?}. If the reference really does differ, this is \
a `deviates` row and owes a reason.",
row.default,
),
Reference::Deviates { value, why } => {
assert!(
!agrees(row.default, value),
"{name}: a `deviates` row that no longer deviates — our {:?} IS the \
reference's. Demote it to `same`; a stale deviation hides that we are \
faithful again.",
row.default,
);
assert!(!why.trim().is_empty(), "{name}: a deviation owes a reason");
}
Reference::Overridden { registered, why } => {
assert!(
!agrees(row.default, registered),
"{name}: an `overridden` row whose default is just the registered string \
{registered:?} — that is `same`, and saying otherwise buries a real \
override behind a false one.",
);
assert!(
!why.trim().is_empty(),
"{name}: an override owes its mechanism"
);
}
Reference::Ours(why) => assert!(
!why.trim().is_empty(),
"{name}: a CVar the reference does not have owes the reason it exists",
),
}
}
}
/// **The inventory** — the exact set of options benilla ships at something other than what a
/// stock 1.12 client ships, as one readable list.
///
/// The list is the deliverable, not the assertion: a reviewer (or the director) reads *this*
/// to answer "where do we differ, and is each one still worth it?", and growing it is a
/// deliberate edit rather than a side effect of adding a row. [`Reference::Overridden`] and
/// [`Reference::Ours`] are deliberately **not** here — those rows follow the reference's
/// behaviour, or have no reference behaviour to follow.
///
/// Every name below is argued at its own row; this is the index, and the reason it is sorted
/// is that the table's order is a load order, not a ranking.
#[test]
fn the_options_that_leave_the_reference_are_this_list_and_no_other() {
let mut names: Vec<&str> = REGISTERED
.iter()
.filter(|r| matches!(r.reference, Reference::Deviates { .. }))
.map(|r| r.name)
.collect();
names.sort_unstable();
assert_eq!(
names,
vec![
"SoundReverb",
"autoSelfCast",
"frillDensity",
"gxApi",
"gxColorBits",
"gxDepthBits",
"gxResolution",
"realmList",
"weatherDensity",
],
);
}
/// Every registered default IS the code constant it mirrors — parse-compared so "1" vs
/// "1.0" cannot fail it, welded so neither side can drift alone.
///
/// **The numeric ones**, which was every one of them until `realmName` — the first
/// string-valued CVar in the table, and the reason this now filters rather than unwraps. It is
/// asserted on its own terms in
/// [`the_only_string_valued_cvar_is_the_realm_and_it_defaults_empty`]; a `parse::<f32>()` over
/// the whole table would either panic (it did) or quietly need every future string CVar to be
/// numeric.
#[test]
fn registered_defaults_mirror_the_code_truths() {
let d: BTreeMap<&str, f32> = REGISTERED
.iter()
.filter_map(|r| r.default.parse::<f32>().ok().map(|f| (r.name, f)))
.collect();
let sound = SoundConfig::default();
assert_eq!(d["MasterVolume"], sound.master);
assert_eq!(d["SoundVolume"], sound.sfx);
assert_eq!(d["MusicVolume"], sound.music);
assert_eq!(d["AmbienceVolume"], sound.ambience);
assert_eq!(d["MasterSoundEffects"] != 0.0, sound.enabled);
assert_eq!(d["EnableMusic"] != 0.0, sound.music_enabled);
assert_eq!(d["EnableAmbience"] != 0.0, sound.ambience_enabled);
assert_eq!(d["EnableErrorSpeech"] != 0.0, sound.error_speech);
assert_eq!(
d["Sound_EnableSoundWhenGameIsInBG"] != 0.0,
sound.background_sound
);
assert!(
!sound.background_sound,
"the reference goes quiet in the background and offers no way out (decision 1847)"
);
// Welded like the rest — and deliberately NOT the binary's registrar "1" (1153).
assert_eq!(d["SoundReverb"] != 0.0, sound.reverb);
assert_eq!(d["SoundOutputLimiter"] != 0.0, sound.limiter);
assert!(sound.limiter, "the output limiter ships on (decision 1551)");
assert!(!sound.reverb, "zone reverb ships off (decision 1153)");
assert_eq!(d["uiScale"], DEFAULT_UI_SCALE);
// ViewDistance::default() reads $WOW_FARCLIP; the registered default mirrors the
// env-less 350 literal (view.rs doc: "Default 350" — the reference's own, 1624).
assert_eq!(d["farclip"], 350.0);
// `nearclip` welds to the const the off-world spawners use, so the viewer, the depth probe
// and the player's camera cannot open on three different near planes again (2163).
assert_eq!(d["nearclip"], benilla_world::view::NEARCLIP_DEFAULT);
assert_eq!(
d["nearclip"],
ViewDistance::default().nearclip,
"the registered default and the resource's own must be one number"
);
// Same shape as farclip: `MsaaSetting::default()` reads $WOW_MSAA, so the registered
// default mirrors the env-less literal — 1, the reference's own (1629).
assert_eq!(d["gxMultisample"], 1.0);
// The Controls trio (0961) welds to its knob Defaults the same way.
assert_eq!(
d["deselectOnClick"] != 0.0,
ClickConfig::default().deselect_on_click
);
assert_eq!(
d["mouseInvertPitch"] != 0.0,
LookConfig::default().invert_pitch
);
assert_eq!(d["mousespeed"], LookConfig::default().sensitivity);
assert_eq!(d["cameraDistanceMaxFactor"], ZoomLimit::default().factor());
// The follow trio (1493/1502) welds to FollowConfig's own defaults — and all three ARE
// the binary's registrar values, byte-verified: "1"/"1"/"180.0". The one corner of this
// arc that agrees with the reference outright.
let follow = FollowConfig::default();
assert_eq!(
d["cameraSmoothStyle"],
follow.style.cvar().parse::<f32>().unwrap()
);
assert_eq!(
d["cameraSmoothTrackingStyle"],
follow.tracking_style.cvar().parse::<f32>().unwrap()
);
assert_eq!(d["cameraYawSmoothSpeed"], follow.yaw_speed);
assert_eq!(FollowStyle::default(), FollowStyle::Smart);
assert_eq!(d["autoLootDefault"] != 0.0, LootConfig::default().auto_loot);
// The roll-detail switch (1589) welds to the same knob's default — and that default IS
// the binary's registered "1", so this row agrees with the reference on both sides.
assert_eq!(
d["showLootSpam"] != 0.0,
LootConfig::default().show_loot_spam
);
// …and the one row that ships a feature OFF, byte-read at `0x5e24c7` (§5).
assert_eq!(
d["guildMemberNotify"] != 0.0,
crate::ui_guild::GuildMemberNotify::default().0
);
assert_eq!(d["guildMemberNotify"], 0.0, "the binary registers \"0\"");
// Block Trades (1764) welds the same way, and its "0" is behaviour: the refusal leg only
// fires when the CVar is SET, so an unset value must read as "trades allowed".
assert_eq!(
d["BlockTrades"] != 0.0,
crate::ui_trade::BlockTrades::default().0
);
assert_eq!(d["BlockTrades"], 0.0, "an unset BlockTrades allows trades");
// The name trio (0992) welds to NameConfig's defaults the same way — and all three are
// the binary's own registrar values now (1804), not two director pins over one.
let names = NameConfig::default();
assert_eq!(d["UnitNamePlayer"] != 0.0, names.player);
assert_eq!(d["UnitNameNPC"] != 0.0, names.npc);
assert_eq!(d["UnitNameOwn"] != 0.0, names.own);
assert_eq!(d["UnitNamePlayerGuild"] != 0.0, names.player_guild);
assert!(
names.player && !names.npc && !names.own && names.player_guild,
"the binary registers UnitNamePlayer \"1\", NPC \"0\", Own \"0\", \
PlayerGuild \"1\""
);
// The camera options weld to `CameraOptions::default()` the same way (2149) — and the one
// that matters here is the one registered "1": a `cameraPivot` that shipped OFF would be
// benilla diverging from the reference on a feature it now has.
let camera_opts = crate::player::camera_dynamics::CameraOptions::default();
assert_eq!(d["cameraPivot"] != 0.0, camera_opts.pivot);
assert!(camera_opts.pivot, "the binary registers cameraPivot \"1\"");
assert_eq!(
d["cameraWaterCollision"] != 0.0,
camera_opts.water_collision
);
assert!(
camera_opts.pivot && camera_opts.water_collision,
"the binary registers cameraPivot and cameraWaterCollision both \"1\""
);
assert_eq!(d["cameraTerrainTilt"] != 0.0, camera_opts.terrain_tilt);
assert!(
!camera_opts.terrain_tilt,
"the binary registers cameraTerrainTilt \"0\""
);
assert_eq!(
d["cameraGroundSmoothSpeed"],
camera_opts.ground_smooth_speed
);
assert_eq!(d["cameraTerrainTiltTimeMin"], camera_opts.tilt_time_min);
assert_eq!(d["cameraTerrainTiltTimeMax"], camera_opts.tilt_time_max);
assert_eq!(d["cameraBobbing"] != 0.0, camera_opts.bobbing);
assert!(
!camera_opts.bobbing && !camera_opts.terrain_tilt,
"the binary registers cameraBobbing and cameraTerrainTilt both \"0\""
);
assert_eq!(d["cameraBobbingLRAmplitude"], camera_opts.bob_lr_amplitude);
assert_eq!(d["cameraBobbingUDAmplitude"], camera_opts.bob_ud_amplitude);
assert_eq!(d["cameraBobbingFrequency"], camera_opts.bob_frequency);
assert_eq!(d["cameraBobbingSmoothSpeed"], camera_opts.bob_smooth_speed);
assert_eq!(d["cameraPivotDXMax"], camera_opts.pivot_dx_max);
assert_eq!(d["cameraPivotDYMin"], camera_opts.pivot_dy_min);
assert_eq!(
d["cameraTargetSmoothSpeed"],
camera_opts.target_smooth_speed
);
// The V-plate pair welds to VPlateMode's defaults — both OFF, which is the reference's
// own boot state on both of its halves (the `[0xc4da34]` bitmask and FrameXML's
// `NAMEPLATES_ON = nil`). Enemy plates were the 0167 director pin until 1804.
// Weather Intensity (2181): the CVar's default and the weather driver's own must be
// the same rain, or a fresh config writes a row the world does not agree with. This is
// also where the DEVIATION is held honest — the reference registers "2" and the row
// above says why we ship 3; the weld makes sure it is 3 in both places.
assert_eq!(
d["weatherDensity"],
f32::from(benilla_world::weather::WeatherState::default().weather_density)
);
let plates = VPlateMode::default();
assert_eq!(d[crate::vplates::CVAR_ENEMIES] != 0.0, plates.enemies);
assert_eq!(d[crate::vplates::CVAR_FRIENDS] != 0.0, plates.friends);
assert!(
!plates.enemies && !plates.friends,
"a fresh 1.12 client draws no plates until V is pressed"
);
// ClutterConfig::default() reads $WOW_CLUTTER_DENSITY; the registered default mirrors
// the env-less ×2 literal (clutter.rs: "Default ×2 = Medium", 1649) on the panel's 0..2
// 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();
assert_eq!(d["ChatBubbles"] != 0.0, bubbles.all);
assert_eq!(d["ChatBubblesParty"] != 0.0, bubbles.party);
assert!(bubbles.all && !bubbles.party, "the binary's own pair");
// The minimap pair (1131) welds to the widget's own `MINIMAP_DEFAULT_ZOOM`, which is the
// byte-verified registration default `"3"` — one truth, mirrored in three places.
let zoom = MinimapZoom::default();
assert_eq!(d["minimapZoom"], f32::from(zoom.outdoor));
assert_eq!(d["minimapInsideZoom"], f32::from(zoom.inside));
assert_eq!(zoom.outdoor, benilla_ui::widget::MINIMAP_DEFAULT_ZOOM);
// VSync welds to the video knob, which in turn welds to the window literal's boot
// mode (`video::tests`) — so the registered "1" cannot drift from what we ship.
assert_eq!(d["gxVSync"] != 0.0, VideoConfig::default().vsync);
// The pane half-rate (1444) welds to the portrait knob's shipped default.
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
// Render scale (1639) welds to OFF. Not a taste default: the whole tree of visual
// goldens is denominated in a 1:1 backdrop, so a registered value other than 1 would
// silently re-render every one of them through a resample.
assert_eq!(d["renderScale"], 1.0);
}
#[test]
fn apply_parses_clamps_and_reports_unknowns() {
let mut sound = SoundConfig::default();
let mut scale = UiScaleCvar(0.9);
let mut view = ViewDistance {
farclip: 350.0,
nearclip: benilla_world::view::NEARCLIP_DEFAULT,
};
let mut look = LookConfig::default();
let mut click = ClickConfig::default();
let mut loot = LootConfig::default();
let mut names = NameConfig::default();
let mut plates = VPlateMode::default();
let mut assist_attack = crate::target::AssistAttack::default();
let mut combat_ranges = crate::ui_chat::combat::CombatLogRanges::default();
let mut damage_text = crate::combat_text::DamageTextGates::default();
let mut log_periodic = crate::ui_chat::combat::LogPeriodicSpells::default();
// Literal fields, not Default: ClutterConfig::default() reads the env A/B vars.
let mut clutter = ClutterConfig {
density: 3.0,
scale: 1.0,
alpha_ref: 0.5,
fade_far: 70.0,
};
let mut weather = benilla_world::weather::WeatherState::default();
let mut display_gamma = crate::ui_gamma::DisplayGamma::default();
let mut minimap = MinimapZoom::default();
let mut bubbles = BubbleConfig::default();
let mut zoom = ZoomLimit::default();
let mut follow = FollowConfig::default();
let mut video = VideoConfig::default();
let mut pane_rate = PaneRate::default();
let mut guild_notify = crate::ui_guild::GuildMemberNotify::default();
let mut block_trades = crate::ui_trade::BlockTrades::default();
// Literal, not Default: MsaaSetting::default() reads $WOW_MSAA.
let mut msaa = MsaaSetting { samples: 1 };
// What an Apple GPU answers for the trio we render into (Rgba16Float / Depth32Float /
// the swapchain). 8 and 16 are NOT in it — which is the whole point below.
let msaa_formats = benilla_world::view::MsaaFormats {
formats: vec![(32, 32, 1), (32, 32, 2), (32, 32, 4)],
};
// Literal for the same reason: RenderScale::default() reads $WOW_RENDER_SCALE.
let mut render_scale = RenderScale(1.0);
// Literal for the same reason again (1642): TexFilterSetting::default() reads
// $WOW_TRILINEAR / $WOW_ANISO. These are what ships (1645).
let mut tex_filter = benilla_assets::TexFilterSetting {
trilinear: true,
aniso: 1,
};
// Literal for the same reason once more (1667): Realmlist::default() reads $WOW_HOST, and
// a shell that happens to export it must not decide what this test asserts against.
let mut realmlist =
crate::realmlist::Realmlist::unpinned(crate::realmlist::DEFAULT_REALMLIST);
let mut auto_self_cast = crate::ui_action::AutoSelfCast::default();
let mut fps_journal = crate::perf::FpsJournalSetting::default();
let mut text_filter = crate::text_filter::TextFilterSwitches::default();
let mut game_tip = crate::game_tip::GameTipSetting::default();
let mut camera_opts = crate::player::camera_dynamics::CameraOptions::default();
let mut knobs = Knobs {
camera_opts: &mut camera_opts,
sound: &mut sound,
auto_self_cast: &mut auto_self_cast,
text_filter: &mut text_filter,
game_tip: &mut game_tip,
scale: &mut scale,
view: &mut view,
look: &mut look,
click: &mut click,
loot: &mut loot,
names: &mut names,
plates: &mut plates,
clutter: &mut clutter,
weather: &mut weather,
display_gamma: &mut display_gamma,
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
follow: &mut follow,
video: &mut video,
render_scale: &mut render_scale,
pane_rate: &mut pane_rate,
guild_notify: &mut guild_notify,
block_trades: &mut block_trades,
msaa: &mut msaa,
tex_filter: &mut tex_filter,
msaa_formats: &msaa_formats,
realmlist: &mut realmlist,
fps_journal: &mut fps_journal,
assist_attack: &mut assist_attack,
combat_ranges: &mut combat_ranges,
damage_text: &mut damage_text,
log_periodic: &mut log_periodic,
};
assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs));
assert_eq!(knobs.sound.music, 0.7);
// The second string-valued row (1667): it must reach the knob rather than being rejected
// by the numeric parse every other row goes through, and a value that is not an address
// must be consumed (known key) while leaving the knob's truth alone.
assert!(apply_to_knobs(
"realmList",
"logon.example.org:3724",
&mut knobs
));
assert_eq!(knobs.realmlist.address(), "logon.example.org:3724");
assert!(apply_to_knobs(
"realmlist",
r#"SET realmlist "elsewhere.example.org""#,
&mut knobs
));
assert_eq!(knobs.realmlist.address(), "elsewhere.example.org");
assert!(apply_to_knobs("realmList", "not an address", &mut knobs));
assert_eq!(
knobs.realmlist.address(),
"elsewhere.example.org",
"a known key with a bad value is consumed, and the resource keeps its truth",
);
// Clamps are the knob's own: volume to [0,1], farclip to FARCLIP_RANGE.
assert!(apply_to_knobs("mastervolume", "7", &mut knobs));
assert_eq!(knobs.sound.master, 1.0);
assert!(apply_to_knobs("farclip", "50", &mut knobs));
assert_eq!(knobs.view.farclip, *FARCLIP_RANGE.start());
// `nearclip` clamps to the reference's own callback bounds `[0.01, 0.33]` (`0x688d90`),
// both ends. pfUI's extended stops write 0.06..0.30, so its whole range passes untouched.
assert!(apply_to_knobs("nearclip", "0.001", &mut knobs));
assert_eq!(
knobs.view.nearclip, 0.01,
"[0x8029d0], the callback's low bound"
);
assert!(apply_to_knobs("nearclip", "9", &mut knobs));
assert_eq!(knobs.view.nearclip, 0.33, "[0x808300], its high bound");
assert!(apply_to_knobs("nearclip", "0.3", &mut knobs));
assert_eq!(knobs.view.nearclip, 0.3);
// Multisampling clamps to the reference's own [1, 16] and takes an int the way its `atoi`
// does — the value reaching the camera is a sample COUNT, where 1 is none (1629).
assert!(apply_to_knobs("gxMultisample", "4", &mut knobs));
assert_eq!(knobs.msaa.samples, 4);
// The filter policy's two rows: `anisotropic` takes the reference's own [1, 16] clamp,
// `trilinear` is a flag. Both write the pending value; the process policy is already
// published by the time either can be typed (1642).
assert!(apply_to_knobs("anisotropic", "99", &mut knobs));
assert_eq!(knobs.tex_filter.aniso, *benilla_assets::ANISO_RANGE.end());
assert!(apply_to_knobs("anisotropic", "0", &mut knobs));
assert_eq!(knobs.tex_filter.aniso, *benilla_assets::ANISO_RANGE.start());
// Both directions: the knob starts at what ships (on), so only the flip to 0 proves the
// arm does anything.
assert!(apply_to_knobs("trilinear", "0", &mut knobs));
assert!(!knobs.tex_filter.trilinear);
assert!(apply_to_knobs("trilinear", "1", &mut knobs));
assert!(knobs.tex_filter.trilinear);
// **The DEVICE's ceiling, not the reference's** (decision 1643). 99 clamps to the
// reference's 16 and then to the 4 this GPU offers — before 1643 it stopped at 16 and the
// camera was handed a sample count wgpu refuses, killing the render thread on frame one.
assert!(apply_to_knobs("gxmultisample", "99", &mut knobs));
assert_eq!(knobs.msaa.samples, 4);
// The realistic route in: a config written where 8x exists, opened where it does not.
assert!(apply_to_knobs("gxMultisample", "8", &mut knobs));
assert_eq!(
knobs.msaa.samples, 4,
"a device that stops at 4x must never be handed an 8"
);
// A count the device DOES offer is untouched.
assert!(apply_to_knobs("gxmultisample", "2", &mut knobs));
assert_eq!(knobs.msaa.samples, 2);
assert!(apply_to_knobs("gxmultisample", "0", &mut knobs));
assert_eq!(knobs.msaa.samples, *MSAA_RANGE.start());
// Render scale takes a fraction and clamps to its own range at both ends (1639).
assert!(apply_to_knobs("renderScale", "0.75", &mut knobs));
assert_eq!(knobs.render_scale.0, 0.75);
assert!(apply_to_knobs("renderscale", "9", &mut knobs));
assert_eq!(knobs.render_scale.0, *RENDER_SCALE_RANGE.end());
assert!(apply_to_knobs("renderscale", "0", &mut knobs));
assert_eq!(knobs.render_scale.0, *RENDER_SCALE_RANGE.start());
// The FPS journal switch (2008): a flag, case-insensitive, off as shipped.
assert!(!knobs.fps_journal.0);
assert!(apply_to_knobs("fpsJournal", "1", &mut knobs));
assert!(knobs.fps_journal.0);
assert!(apply_to_knobs("fpsjournal", "0", &mut knobs));
assert!(!knobs.fps_journal.0);
// Enable flags: any nonzero is on, zero is off (the client's int-parse + != 0).
assert!(apply_to_knobs("EnableMusic", "0", &mut knobs));
assert!(!knobs.sound.music_enabled);
assert!(apply_to_knobs("mastersoundeffects", "1", &mut knobs));
assert!(knobs.sound.enabled);
// The Controls trio lands on its knobs (case-insensitive like everything else).
assert!(apply_to_knobs("deselectonclick", "0", &mut knobs));
assert!(!knobs.click.deselect_on_click);
assert!(apply_to_knobs("MouseInvertPitch", "1", &mut knobs));
assert!(knobs.look.invert_pitch);
// The sensitivity multiplier clamps to the 1.12 slider's range at the knob.
assert!(apply_to_knobs("mousespeed", "1.4", &mut knobs));
assert_eq!(knobs.look.sensitivity, 1.4);
assert!(apply_to_knobs("mousespeed", "9", &mut knobs));
assert_eq!(knobs.look.sensitivity, 1.5);
// The following style lands as the ENGINE's enum (0 Never / 1 Smart / 2 Always), and the
// "3" the reference's own dropdown writes for Never still means Never.
assert!(apply_to_knobs("cameraSmoothStyle", "0", &mut knobs));
assert_eq!(knobs.follow.style, FollowStyle::Never);
assert!(apply_to_knobs("camerasmoothstyle", "2", &mut knobs));
assert_eq!(knobs.follow.style, FollowStyle::Always);
assert!(apply_to_knobs("cameraSmoothStyle", "3", &mut knobs));
assert_eq!(knobs.follow.style, FollowStyle::Never);
assert!(apply_to_knobs("cameraSmoothStyle", "1", &mut knobs));
assert_eq!(knobs.follow.style, FollowStyle::Smart);
// Its two siblings land on the same knob — the tracking selector, and the rate, which
// clamps to 1.12's AUTO_FOLLOW_SPEED slider range.
assert!(apply_to_knobs("cameraSmoothTrackingStyle", "2", &mut knobs));
assert_eq!(knobs.follow.tracking_style, FollowStyle::Always);
assert_eq!(knobs.follow.style, FollowStyle::Smart, "and only that one");
assert!(apply_to_knobs("cameraYawSmoothSpeed", "270", &mut knobs));
assert_eq!(knobs.follow.yaw_speed, 270.0);
assert!(apply_to_knobs("cameraYawSmoothSpeed", "9000", &mut knobs));
assert_eq!(knobs.follow.yaw_speed, *FOLLOW_SPEED_RANGE.end());
// The max-orbit factor lands as YARDS on the knob (base 15 x factor), clamped to 1..2.
assert!(apply_to_knobs("cameraDistanceMaxFactor", "1", &mut knobs));
assert_eq!(knobs.zoom.max, 15.0);
assert!(apply_to_knobs("cameradistancemaxfactor", "5", &mut knobs));
assert_eq!(knobs.zoom.max, 30.0);
assert!(apply_to_knobs("autoLootDefault", "1", &mut knobs));
assert!(knobs.loot.auto_loot);
assert!(apply_to_knobs("showLootSpam", "0", &mut knobs));
assert!(!knobs.loot.show_loot_spam);
// Guild Member Alert (1589) — the row that ships OFF, so its ON is the interesting write.
assert!(apply_to_knobs("guildMemberNotify", "1", &mut knobs));
assert!(knobs.guild_notify.0);
// Block Trades (1764) — the other row that ships OFF; its ON is what refuses a trade.
assert!(apply_to_knobs("BlockTrades", "1", &mut knobs));
assert!(knobs.block_trades.0);
// The name trio lands on its gates (0992).
assert!(apply_to_knobs("UnitNameNPC", "0", &mut knobs));
assert!(!knobs.names.npc);
assert!(apply_to_knobs("unitnameown", "1", &mut knobs));
assert!(knobs.names.own);
// …and the plate pair on the two bits of the bitmask, either casing.
assert!(apply_to_knobs(
crate::vplates::CVAR_ENEMIES,
"0",
&mut knobs
));
assert!(!knobs.plates.enemies);
assert!(apply_to_knobs("nameplateshowfriends", "1", &mut knobs));
assert!(knobs.plates.friends);
// The bubble pair lands on the spawn gate's own knob (1139).
assert!(apply_to_knobs("ChatBubbles", "0", &mut knobs));
assert!(!knobs.bubbles.all);
assert!(apply_to_knobs("chatbubblesparty", "0", &mut knobs));
assert!(!knobs.bubbles.party);
// WorldDetail: panel 0/1/2 → density ×1/×2/×3, clamped to the 1.12 slider's range.
assert!(apply_to_knobs("WorldDetail", "0", &mut knobs));
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);
// Weather Intensity (2181): the panel's four stops land whole, an off-grid value
// truncates toward zero the way every int-valued row here does, and both ends clamp.
for (wrote, want) in [
("0", 0u8),
("1", 1),
("2", 2),
("3", 3),
("2.9", 2),
("9", 3),
("-4", 0),
] {
assert!(apply_to_knobs("weatherDensity", wrote, &mut knobs));
assert_eq!(
knobs.weather.weather_density, want,
"weatherDensity {wrote}"
);
}
// Brightness (2182): the CVar's own unit is the ramp exponent, NOT the slider's offset —
// `SetGamma` does the `1 - v` on the way in, so what arrives here is already `gamma`.
// Both ends of the stock slider land whole, and the consumer's clamp holds the values the
// reference accepts without one (`SetGamma(5)` writes -4 there).
for (wrote, want) in [("1.000000", 1.0), ("0.500000", 0.5), ("1.500000", 1.5)] {
assert!(apply_to_knobs("gamma", wrote, &mut knobs));
assert_eq!(knobs.display_gamma.0, want, "gamma {wrote}");
}
assert!(apply_to_knobs("gamma", "-4.000000", &mut knobs));
assert_eq!(
knobs.display_gamma.0,
*crate::ui_gamma::GAMMA_RANGE.start(),
"a negative exponent clamps at the consumer, where it cannot blank the screen"
);
assert!(apply_to_knobs("gamma", "99", &mut knobs));
assert_eq!(knobs.display_gamma.0, *crate::ui_gamma::GAMMA_RANGE.end());
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");
}
// **The engine verbs' half of the same weld** (2163). `SetWorldDetail`/`GetWorldDetail` live
// in `benilla-ui`, which cannot see this table, so the two CVar names and the stop table it
// writes are consts there — and if either name stopped being registered, or the reference's
// {16, 32, 48} stopped being `frillDensity`'s unit times the stop, the verbs would write
// into nothing and only this assertion would say so.
assert!(REGISTERED
.iter()
.any(|r| r.name == benilla_ui::script::CVAR_WORLD_DETAIL));
assert!(REGISTERED
.iter()
.any(|r| r.name == benilla_ui::script::CVAR_FRILL_DENSITY));
// …and the display-gamma pair's (2182), for exactly the same reason: `GetGamma` answers
// `1 - <this CVar>` and `SetGamma` writes `1 - v` into it, both from a crate that cannot
// see this table, so an unregistered name would make the getter answer a constant 0 and
// the setter write into nothing.
assert!(REGISTERED
.iter()
.any(|r| r.name == benilla_ui::script::CVAR_GAMMA));
// **`RestoreVideoDefaults`' row list, welded the same way** (2177). It lives in
// `benilla-ui` beside the binding that walks it and cannot see this table, so a rename or
// a retirement here would turn one of its rows into a silent skip — the verb would restore
// eleven settings out of twelve and say nothing. This is the only place that can notice.
for key in benilla_ui::script::VIDEO_DEFAULT_CVARS {
assert!(
REGISTERED.iter().any(|r| r.name.eq_ignore_ascii_case(key)),
"{key}: RestoreVideoDefaults would restore it, and nothing registers it"
);
}
for (n, frill) in benilla_ui::script::WORLD_DETAIL_STOPS.iter().enumerate() {
assert_eq!(
*frill,
benilla_formats::FRILL_DENSITY * (n as u32 + 1),
"stop {n}: the reference's own 0x804518 entry must be this knob's unit times the stop"
);
// Either spelling of the stop lands on the same ground cover — which is what lets the
// setter write `frillDensity` and the getter read `WorldDetail` without disagreeing.
assert!(apply_to_knobs("WorldDetail", &n.to_string(), &mut knobs));
let by_stop = knobs.clutter.density;
knobs.clutter.density = 0.5;
assert!(apply_to_knobs(
"frillDensity",
&frill.to_string(),
&mut knobs
));
assert_eq!(
knobs.clutter.density, by_stop,
"stop {n}: the two spellings disagree"
);
assert_eq!(knobs.clutter.frill_density(), *frill as f32);
}
// 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);
assert_eq!(knobs.minimap.inside, 3, "the two indices are independent");
assert!(apply_to_knobs("minimapinsidezoom", "9", &mut knobs));
assert_eq!(knobs.minimap.inside, MINIMAP_ZOOM_LEVELS - 1);
assert!(apply_to_knobs("minimapZoom", "-2", &mut knobs));
assert_eq!(knobs.minimap.outdoor, 0);
// A bad value is consumed (known key) and the resource keeps its truth.
assert!(apply_to_knobs("uiScale", "banana", &mut knobs));
assert_eq!(knobs.scale.0, 0.9);
assert!(!apply_to_knobs("bogus", "1", &mut knobs));
}
#[test]
fn compose_writes_the_diff_and_preserves_what_it_does_not_own() {
let previous: BTreeMap<String, String> = [
("FutureKnob".to_string(), "3".to_string()), // a newer build's key: preserved
("uiScale".to_string(), "0.8".to_string()), // env-overridden this session
("farclip".to_string(), "400".to_string()), // will return to default
]
.into();
let env: HashSet<String> = ["uiscale".to_string()].into();
let snapshot = vec![
// (name, value, default)
("MusicVolume".into(), "0.7".into(), "0.4".into()), // moved: written
("MasterVolume".into(), "1".into(), "1".into()), // default: absent
("uiScale".into(), "1.2".into(), "0.9".into()), // env value: file keeps 0.8
("farclip".into(), "350".into(), "350".into()), // back to default: removed
];
let out = compose_file(&previous, &env, &snapshot);
assert_eq!(out.get("MusicVolume").map(String::as_str), Some("0.7"));
assert!(!out.contains_key("MasterVolume"));
assert_eq!(out.get("uiScale").map(String::as_str), Some("0.8"));
assert!(!out.contains_key("farclip"));
assert_eq!(out.get("FutureKnob").map(String::as_str), Some("3"));
}
/// End to end on a real App: a pre-written `config.toml` loads into the knobs at Startup, a
/// Lua `SetCVar` drains into the knobs and — on the exit flush — lands back in the file as a
/// diff (the moved value present, the untouched ones absent). This is the whole 0954 slice-1
/// loop in one place: file → knobs → VM table → Lua write → knobs → file.
#[test]
fn a_lua_setcvar_lands_in_config_toml_end_to_end() {
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-cvar-e2e-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _u = EnvGuard::unset("WOW_UI_SCALE");
let _f = EnvGuard::unset("WOW_FARCLIP");
let _d = EnvGuard::unset("WOW_CLUTTER_DENSITY");
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
crate::local_state::write_atomic(
&tmp.join("config.toml"),
"[cvars]\nMusicVolume = \"0.1\"\n",
)
.unwrap();
let mut app = cvar_app();
// Startup: the file's MusicVolume reaches the knob; Update: the VM table seeds from it.
app.update();
assert_eq!(app.world().resource::<SoundConfig>().music, 0.1);
assert_eq!(
app.world_mut()
.non_send_resource_mut::<UiScript>()
.cvar("MusicVolume")
.as_deref(),
Some("0.1")
);
// The Lua write (what a settings slider will do) reaches the knob on the next frame…
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run(r#"SetCVar("MusicVolume", 0.75)"#)
.unwrap();
app.update();
assert_eq!(app.world().resource::<SoundConfig>().music, 0.75);
// …and the exit flush writes the diff: the moved value, nothing at its default.
app.world_mut().write_message(AppExit::Success);
app.update();
let text = std::fs::read_to_string(tmp.join("config.toml")).unwrap();
assert!(text.contains("MusicVolume = \"0.75\""), "{text}");
assert!(!text.contains("MasterVolume"), "defaults stay out:\n{text}");
let back: LocalConfig = toml::from_str(&text).unwrap();
assert_eq!(back.cvars.len(), 1, "a diff, not a dump: {text}");
std::fs::remove_dir_all(&tmp).ok();
}
/// A client whose CVar host is real: every knob resource the [`KnobParams`] census wants,
/// [`CvarPlugin`] itself, and a VM for the table to live in. The three end-to-end tests below
/// each stand a whole client up, and the census is one row per knob — copied per test, adding
/// a knob meant editing every copy.
fn cvar_app() -> App {
let mut app = App::new();
app.add_plugins(bevy::MinimalPlugins)
.insert_resource(SoundConfig::default())
.insert_resource(UiScaleCvar(DEFAULT_UI_SCALE))
.insert_resource(ViewDistance {
farclip: 350.0,
nearclip: benilla_world::view::NEARCLIP_DEFAULT,
})
.insert_resource(MsaaSetting { samples: 1 })
// Literal for the same reason (1642): TexFilterSetting::default() reads
// $WOW_TRILINEAR / $WOW_ANISO. These are what ships (1645).
.insert_resource(benilla_assets::TexFilterSetting {
trilinear: true,
aniso: 1,
})
// The device menu the Video dropdown reads. A real-shaped list, not empty: these
// tests exercise `GetCurrentMultisampleFormat`'s lookup, which needs rows to find.
.insert_resource(benilla_world::view::MsaaFormats {
formats: vec![(32, 32, 1), (32, 32, 2), (32, 32, 4)],
})
.init_resource::<LookConfig>()
.init_resource::<crate::player::camera_dynamics::CameraOptions>()
.init_resource::<crate::ui_chat::combat::CombatLogRanges>()
.init_resource::<crate::combat_text::DamageTextGates>()
.init_resource::<crate::ui_chat::combat::LogPeriodicSpells>()
.init_resource::<benilla_world::weather::WeatherState>()
.init_resource::<crate::ui_gamma::DisplayGamma>()
.init_resource::<ClickConfig>()
.init_resource::<crate::target::AssistAttack>()
.init_resource::<LootConfig>()
.init_resource::<NameConfig>()
.init_resource::<VPlateMode>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<FollowConfig>()
.init_resource::<VideoConfig>()
// Literal, not Default: RenderScale::default() reads $WOW_RENDER_SCALE.
.insert_resource(RenderScale(1.0))
// Literal for the same reason again (1667): Realmlist::default() reads $WOW_HOST.
.insert_resource(crate::realmlist::Realmlist::unpinned(
crate::realmlist::DEFAULT_REALMLIST,
))
.init_resource::<PaneRate>()
.init_resource::<crate::ui_guild::GuildMemberNotify>()
.init_resource::<crate::ui_trade::BlockTrades>()
.init_resource::<crate::ui_action::AutoSelfCast>()
.init_resource::<crate::perf::FpsJournalSetting>()
.init_resource::<crate::text_filter::TextFilterSwitches>()
.init_resource::<crate::game_tip::GameTipSetting>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app
}
/// **The reported bug, end to end** (decision 1622): "char screen doesn't remember the last
/// logged in char, the ref does". Two launches over one `benilla-config/`, with the real
/// [`CvarPlugin`] and the real [`crate::char_select`] systems in between — entering the world
/// as somebody has to survive the quit and bring the screen back to them.
///
/// The seam this covers and the per-module tests cannot: `set_cvar_engine`'s queued change is
/// only *persisted* if [`apply_to_knobs`] answers `true` for the name. A knobless CVar that
/// falls through to `_ => return false` reaches the VM's table, reads back correctly all
/// session, and is silently dropped at the save — which is this bug again, one layer down.
#[test]
fn entering_the_world_survives_the_quit_and_comes_back_selected() {
use crate::char_select::{ClientState, Roster};
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-lastchar-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _u = EnvGuard::unset("WOW_UI_SCALE");
let _f = EnvGuard::unset("WOW_FARCLIP");
let _d = EnvGuard::unset("WOW_CLUTTER_DENSITY");
let _w = EnvGuard::unset("WOW_CHAR");
let _s = EnvGuard::unset("WOW_CHARSELECT_PICK");
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
let roster = || {
(1..=4)
.map(|g| crate::char_select::test_character(g, &format!("Char{g}")))
.collect::<Vec<_>>()
};
// ── Launch 1: the roster lands, and the player enters the world as the third row. ────
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = cvar_app();
app.add_plugins(bevy::state::app::StatesPlugin);
crate::char_select::add_test_systems(&mut app, tx);
app.update(); // Startup loads the (absent) file; the first Update seeds the VM table
app.world_mut().write_message(crate::net::CharListMessage {
characters: roster(),
realm: None,
});
app.update();
assert_eq!(
app.world().resource::<Roster>().selected(),
Some(0),
"nothing remembered yet, so the first row — the behaviour that was already right",
);
app.world_mut().resource_mut::<Roster>().pending_pick = Some(3); // guid 3 = row 2
app.update();
app.world_mut().write_message(AppExit::Success);
app.update();
let text = std::fs::read_to_string(tmp.join("config.toml")).unwrap();
assert!(
text.contains("lastCharacterIndex = \"2\""),
"entering the world must reach the file, 0-based like Config.wtf:\n{text}"
);
// ── Launch 2: a fresh client over the same folder, and the roster arrives. ───────────
let (tx, _rx2) = crossbeam_channel::unbounded();
let mut app = cvar_app();
app.add_plugins(bevy::state::app::StatesPlugin);
crate::char_select::add_test_systems(&mut app, tx);
app.update();
app.world_mut().write_message(crate::net::CharListMessage {
characters: roster(),
realm: None,
});
app.update();
assert_eq!(
app.world().resource::<Roster>().selected(),
Some(2),
"the second launch must stand the SAME character on the stage — the whole report",
);
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::CharSelect,
);
std::fs::remove_dir_all(&tmp).ok();
}
/// The minimap's zoom rides the same loop, driven from the **engine** rather than a Lua
/// `SetCVar` (decision 1131): the `+`/`-` buttons call `Minimap:SetZoom`, which writes the live
/// index and its CVar together — and that has to reach the knob and the file exactly like a
/// settings row's write does, or the level is forgotten at the next launch.
#[test]
fn a_minimap_setzoom_reaches_the_knob_and_the_file() {
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-mmzoom-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _u = EnvGuard::unset("WOW_UI_SCALE");
let _f = EnvGuard::unset("WOW_FARCLIP");
let _d = EnvGuard::unset("WOW_CLUTTER_DENSITY");
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
// The previous session left the outdoor map zoomed right in.
crate::local_state::write_atomic(
&tmp.join("config.toml"),
"[cvars]\nminimapZoom = \"5\"\n",
)
.unwrap();
let mut app = cvar_app();
app.update();
// Startup restored the knob, and the VM's table answers with it — which is what the UI-load
// seam hands to `set_minimap_zoom` when the widget is born.
assert_eq!(app.world().resource::<MinimapZoom>().outdoor, 5);
assert_eq!(app.world().resource::<MinimapZoom>().inside, 3);
let seed = {
let z = app.world().resource::<MinimapZoom>();
(z.outdoor, z.inside)
};
{
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
assert_eq!(script.cvar("minimapZoom").as_deref(), Some("5"));
// The UI-load seam's own order: the widget is born (at its `MinimapState` default),
// THEN the persisted level is pushed into it. Seeding a widget that does not exist yet
// is a no-op — which is exactly why that call sits after `load_ingame_ui`.
script.run(r#"m = CreateFrame("Minimap", "Mini")"#).unwrap();
script.set_minimap_zoom(seed.0, seed.1);
assert_eq!(script.eval::<u8>("return m:GetZoom()").unwrap(), 5);
// The player zooms out two notches with the minimap's own buttons.
script.run("m:SetZoom(m:GetZoom() - 2)").unwrap();
}
app.update();
assert_eq!(app.world().resource::<MinimapZoom>().outdoor, 3);
app.world_mut().write_message(AppExit::Success);
app.update();
let text = std::fs::read_to_string(tmp.join("config.toml")).unwrap();
assert!(
!text.contains("minimapZoom"),
"back at the registered default 3, so it leaves the diff entirely:\n{text}"
);
// …and one more notch out is a real diff again.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("m:SetZoom(1)")
.unwrap();
app.update();
app.world_mut().write_message(AppExit::Success);
app.update();
let text = std::fs::read_to_string(tmp.join("config.toml")).unwrap();
assert!(text.contains("minimapZoom = \"1\""), "{text}");
assert_eq!(app.world().resource::<MinimapZoom>().outdoor, 1);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn the_toml_round_trips() {
let cfg = LocalConfig {
cvars: [("MusicVolume".to_string(), "0.7".to_string())].into(),
};
let text = format!("{HEADER}{}", toml::to_string(&cfg).unwrap());
let back: LocalConfig = toml::from_str(&text).unwrap();
assert_eq!(back.cvars, cfg.cvars);
// The header survives as comments; a hand edit with comments parses too.
let hand = "# my note\n[cvars]\nFarclip = \"500\"\n";
let parsed: LocalConfig = toml::from_str(hand).unwrap();
assert_eq!(parsed.cvars.get("Farclip").map(String::as_str), Some("500"));
}
/// **The table's string-valued CVars, named — and each one's default asserted on its own
/// terms.**
///
/// Pinned as a closed list so a new string CVar has to come here and think about the numeric
/// test above rather than silently widening it. That is exactly what happened at 1627, when
/// this test still said "the ONE": `gxResolution` arrived and the list grew by one, on purpose.
///
/// **`realmName` defaults EMPTY** — empty rather than a guess: the value is written from the
/// session's real realm by `set_realm_name`, so the default only ever describes a client that
/// has not connected. wow-re records `"Last realm connected to"` beside the registration, but
/// that reads like the CVar's HELP text rather than its value, and nothing here needs it
/// resolved — `""` is what `Ace/AceState.lua:27`'s `ace.trim(GetCVar("realmName"))` handles
/// cleanly, and inventing a realm name would be worse than admitting we have none yet.
///
/// **`gxResolution` defaults to the pre-1627 window** (decision 1627), and **`realmList` to
/// `localhost`** (1667). These are the rows [`apply_to_knobs`] matches ahead of its numeric
/// parse, so each default is asserted through the same parser the live value goes through — a
/// spelling this table accepts but [`crate::video::parse_resolution`] or
/// [`crate::realmlist::normalize`] rejects would otherwise ship as a silent fall back.
///
/// The list itself is the load-bearing half: a new string-valued row that forgets its arm in
/// `apply_to_knobs` is a CVar the player can set and the client will never honour, and this is
/// what makes adding one impossible to do quietly.
#[test]
fn the_string_valued_cvars_are_the_realm_and_the_windowed_size() {
let mut strings: Vec<&str> = REGISTERED
.iter()
.filter(|r| r.default.parse::<f32>().is_err())
.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!["gxApi", "gxResolution", "realmList", "realmName"]
);
let default_of = |name: &str| {
REGISTERED
.iter()
.find(|r| r.name == name)
.map(|r| r.default)
.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)
);
// Same posture for the third row (1667): a default this table accepts but
// `realmlist::normalize` rejects would ship as a client that silently cannot dial.
assert_eq!(
crate::realmlist::normalize(default_of(crate::realmlist::CVAR_REALMLIST)).as_deref(),
Some(crate::realmlist::DEFAULT_REALMLIST),
);
}
/// **The claim the test above only asserted in prose, now enforced.** Its doc says a string
/// row that forgets its arm in [`apply_to_knobs`] "is a CVar the player can set and the client
/// will never honour, and this is what makes adding one impossible to do quietly" — and then
/// `realmName` was added and did exactly that. It reached the numeric parse, which can only
/// reject it, so every launch after the first connect logged
/// `cvar realmName: unparseable value 'VMaNGOS' ignored`.
///
/// It was the mild half of the failure — the persisted value still reaches `GetCVar` through
/// `set_cvar_saved_base`, so nothing was actually lost, and the warn was libel rather than
/// news. A string row that DID own a knob would have been silently dropped. Both directions
/// are pinned: a new non-numeric row that skips [`is_string_valued`] fails here, and a key
/// named there that stops being a registered string row fails here too.
#[test]
fn every_string_valued_row_is_claimed_before_the_numeric_parse() {
for r in REGISTERED {
let key = r.name.to_ascii_lowercase();
assert_eq!(
r.default.parse::<f32>().is_err(),
is_string_valued(&key),
"{}: a row's default parsing as a number and `is_string_valued` must agree — \
a string row that misses the guard falls to the numeric parse, which only \
rejects it",
r.name,
);
}
}
}