diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 9f2d38b8..2fc4abe1 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -607,7 +607,10 @@
-- benilla knob behind them (Enable Sound=Sound_EnableAllSound, Dialog, output device,
-- channels…) do not ship — the 0950 honest-tree rule applied to settings.
OPTIONS_PAGE_ROWS = {
- Controls = { "RowStickyTarget", "RowAutoLoot", "RowInvertMouse" },
+ Controls = {
+ "RowStickyTarget", "RowAutoLoot", "RowInvertMouse", "RowMouseSpeed",
+ "RowMaxCameraDistance",
+ },
Audio = {
"RowEnableAll", "RowMaster", "RowSound", "RowMusic", "RowAmbience",
"RowEnableMusic", "RowEnableAmbience",
@@ -618,7 +621,8 @@
-- (1136; Buff Durations 1139), in the reference's own on-screen order — its Basic
-- Options anchor chain, Display block (CheckButton42 → 39 → 66) before Help (29).
Interface = {
- "RowInstantQuestText", "RowBuffDurations", "RowAutoQuestWatch", "RowNewbieTips",
+ "RowStatusText", "RowInstantQuestText", "RowBuffDurations", "RowAutoQuestWatch",
+ "RowNewbieTips",
},
-- The two chat-bubble switches (decision 1139), in the reference's relative order —
-- its Advanced Options chat chain runs CheckButton37 → 46 (Loot Spam, no knob of ours)
@@ -683,13 +687,19 @@
-- HIDE_PARTY_INTERFACE → RaidOptionsFrame_UpdatePartyFrames, the combat-text family →
-- CombatText_UpdateDisplayedMessages), moved from its batched Okay to our live write
-- because this window commits on the click (0961's grammar), not on a Save button.
+ -- `cvarEvent` is a CVar row's counterpart to a global row's `applyFunc`, and it is the
+ -- reference's own third SetCVar argument (decision 1140): the token the engine hands back
+ -- as `CVAR_UPDATE`'s arg1. 1.12 passes its CheckButtons table KEY there
+ -- (`SetCVar(value.cvar, value.value, index)`), so a consumer that wants to react the
+ -- INSTANT the switch moves — rather than on its next repaint — has something to listen
+ -- for. nil on a row nobody listens to, which is most of them: the host polls its knob.
function OptionsRow_Set(row, value)
if row.uvar then
setglobal(row.uvar, value)
if row.applyFunc then row.applyFunc() end
return
end
- SetCVar(row.cvar, value)
+ SetCVar(row.cvar, value, row.cvarEvent)
end
-- The registered default — GetCVarDefault's counterpart for a global. `udefault` is the
@@ -834,6 +844,12 @@
if row.fmt == "raw" then
return string.format("%d", math.floor(value + 0.5))
end
+ -- A row whose stored value is a FACTOR but whose meaning is a distance: show the
+ -- distance (decision 1140 — cameraDistanceMaxFactor over the 15 yd base). The number
+ -- the player is choosing is "how far out the wheel stops", not "times 1.4".
+ if row.fmt == "yards" then
+ return string.format("%d yd", math.floor(value * 15 + 0.5))
+ end
return string.format("%d%%", math.floor(value * 100 + 0.5))
end
@@ -1870,6 +1886,45 @@
OptionsRow_OnLoad(self, "mouseInvertPitch", "Invert Mouse",
"OPTION_TOOLTIP_INVERT_MOUSE")
+
+
+
+
+
+
+
+ OptionsRow_OnLoad(self, "mousespeed", "Mouse Sensitivity",
+ "OPTION_TOOLTIP_MOUSE_SENSITIVITY")
+ OptionsSliderRow_Setup(self, 0.5, 1.5, 0.05)
+
+
+
+
+
+
+
+
+
+ OptionsRow_OnLoad(self, "cameraDistanceMaxFactor",
+ "Max Camera Distance", "OPTION_TOOLTIP_MAX_FOLLOW_DIST")
+ OptionsSliderRow_Setup(self, 1.0, 2.0, 0.1, "yards")
+
+
+
+
+ OptionsRow_OnLoad(self, "statusBarText",
+ "Status Bar Text", "OPTION_TOOLTIP_STATUS_BAR")
+ self.cvarEvent = "STATUS_BAR_TEXT"
+
+
+
+
+
+
+
OptionsGlobalRow_OnLoad(self, "QUEST_FADING_DISABLE",
"Instant Quest Text", "OPTION_TOOLTIP_SHOW_QUEST_FADING")
diff --git a/crates/benilla-app/assets/ui/TextStatusBar.xml b/crates/benilla-app/assets/ui/TextStatusBar.xml
index 155ca8fb..c89dea8f 100644
--- a/crates/benilla-app/assets/ui/TextStatusBar.xml
+++ b/crates/benilla-app/assets/ui/TextStatusBar.xml
@@ -7,15 +7,25 @@
`statusBarText` cvar is "1" ("Status Text" in the options; our GetCVar returns nil for a cvar
the engine doesn't carry, which reads as OFF — the 1.12 default).
- First (and so far only) customer: the XP bar (ActionBar.xml). The unit-frame health/mana
- numerals are the ref's other users of this file — UnitFrames.xml omits them deliberately (its
- own header note) — so this is the seam they inherit when their slice lands.
+ Customers: the XP bar (ActionBar.xml) and, since decision 1143, the player and pet frames'
+ health/power bars (UnitFrames.xml). `textLockable` is what the statusBarText cvar pins, and
+ which bars carry it is the REFERENCE's own split, not an omission of ours: 1.12 sets it on the
+ player's two bars, the pet's two and the XP bar, and on nothing else. The target frame's bars
+ are deliberately not lockable there — its numerals are hover-only — so ours have the machinery
+ wired and no text regions yet; the hover half needs mouse-enabled bars we do not have.
-->
@@ -926,6 +948,10 @@
+
+ if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end
+ if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end
+
+
+
+
+
+
+
+
+
+
+
@@ -1181,6 +1236,10 @@
+
+ if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end
+ if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end
+
+
+
+
+
+
+
diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs
index 26333c17..31a1181d 100644
--- a/crates/benilla-app/src/bindings.rs
+++ b/crates/benilla-app/src/bindings.rs
@@ -9,8 +9,10 @@
//!
//! Dispatch (the [`latch_and_dispatch`] system, ordered inside [`crate::ui_script::UiInput`]
//! right after the UI key feed):
-//! - a press matches a chord only on the **exact** modifier set (0585, engine-wide now; Super
-//! held matches nothing);
+//! - a press probes its **exact** chord and then, only on a miss, **once more** with its leftmost
+//! modifier dropped ([`Chord::fallback`], decision 1142) — which is why `Shift`+`W` walks while
+//! `SHIFT-W` is bound to nothing, and why a bound `SHIFT-TAB` still beats bare `TAB` (the exact
+//! probe is always first). Super held matches nothing;
//! - [`Kind::Held`] commands **latch** on the matching press and unlatch on the *base key's*
//! release — the reference's `runOnUp` movement law, which is why tapping Shift mid-run does
//! not stop you, and why a chat box taking focus stops movement (latches clear on the capture
@@ -48,14 +50,43 @@ use chord::{BindKey, Chord};
pub(crate) use commands::cmd;
use commands::{Cmd, Kind, SPECS};
-/// The derived chord→command map (exact-modifier law) — rebuilt whenever the engine table's
-/// generation moves.
+/// The derived chord→command map — rebuilt whenever the engine table's generation moves. Probed
+/// through [`BindingDispatch::resolve`], never directly: the lookup is two probes, not one.
#[derive(Resource, Default)]
struct BindingDispatch {
map: std::collections::HashMap,
seen_generation: Option,
}
+impl BindingDispatch {
+ /// Resolve a press to its command — the reference's lookup (`CBindings::ExecuteBinding`
+ /// `0x4b7990`, decision 1142): the exact chord, then **one** retry with the leftmost modifier
+ /// dropped ([`Chord::fallback`]). The exact probe always runs first, so a bound specific chord
+ /// always beats the general one.
+ ///
+ /// `dev_plane` suppresses the **retry only**, and only on the keyboard path. `Ctrl`+`Shift` is
+ /// ours ([`crate::debug_panel::DEV_CHORD`]), and 0870 picked it on the argument that the plane
+ /// was empty — an argument that rested on the exact-match law 1142 corrects. It is
+ /// *nearly* empty anyway under the real law, because the single strip drops `CTRL` and leaves
+ /// `SHIFT-`*key*, never reaching the bare letter — but "nearly" is not a plane: `SHIFT-P` is a
+ /// live 1.12 default (`TOGGLECHARACTER3`, 1057), so `Ctrl`+`Shift`+`P` would open the pet
+ /// paper doll under the perf HUD. That is 0585's original bug, and this is the same fix it
+ /// made, now correctly scoped: an *exact* `CTRL-SHIFT-` binding still dispatches, so the
+ /// reference's own two (`CTRL-SHIFT-TAB`, `CTRL-SHIFT-PAGEDOWN`) and anything a player binds
+ /// there are untouched. Only the plane's fallback shadow is ours. Mouse and wheel are not
+ /// suppressed — every dev instrument is a letter, so a modified click is nobody's but the
+ /// game's.
+ fn resolve(&self, chord: Chord, dev_plane: bool) -> Option {
+ if let Some(&cmd) = self.map.get(&chord) {
+ return Some(cmd);
+ }
+ if dev_plane {
+ return None;
+ }
+ self.map.get(&chord.fallback()?).copied()
+ }
+}
+
/// This frame's binding activity — what the engine-side consumers read instead of raw keys.
#[derive(Resource, Default)]
pub(crate) struct BindingsState {
@@ -302,6 +333,10 @@ fn latch_and_dispatch(
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight);
let sup = keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight);
+ // Exactly the dev overlays' plane (`debug_panel::dev_plane`, minus the Super arm the `sup`
+ // gate below already covers) — it costs the keyboard its fallback probe. See
+ // [`BindingDispatch::resolve`].
+ let dev_plane = ctrl && shift && !alt;
let mut script = script;
let armed = script.as_ref().is_some_and(|s| s.bind_capture_armed());
@@ -383,7 +418,7 @@ fn latch_and_dispatch(
shift,
key: BindKey::Key(key),
};
- if let Some(&cmd) = dispatch.map.get(&chord) {
+ if let Some(cmd) = dispatch.resolve(chord, dev_plane) {
press(&mut state, &mut script, run_lua, cmd, BindKey::Key(key));
}
}
@@ -414,7 +449,7 @@ fn latch_and_dispatch(
shift,
key: BindKey::Mouse(b),
};
- if let Some(&cmd) = dispatch.map.get(&chord) {
+ if let Some(cmd) = dispatch.resolve(chord, false) {
press(&mut state, &mut script, run_lua, cmd, BindKey::Mouse(b));
}
}
@@ -445,7 +480,7 @@ fn latch_and_dispatch(
shift,
key,
};
- if let Some(&cmd) = dispatch.map.get(&chord) {
+ if let Some(cmd) = dispatch.resolve(chord, false) {
match &SPECS[cmd.0 as usize].kind {
Kind::Edge(lua) => run_lua(&mut script, lua, SPECS[cmd.0 as usize].name),
Kind::Host => {
@@ -627,9 +662,29 @@ mod tests {
assert!(!state(&app).just_pressed(cmd::MOVE_FORWARD));
}
+ /// **The two-probe lookup, table-wide** (decision 1142): the exact chord, then one retry with
+ /// the leftmost modifier dropped, and never a third. The bug that bought this test was a
+ /// modifier held over a movement key eating the movement — so the movement case leads.
#[test]
- fn the_exact_modifier_law_holds_table_wide() {
- // Bare Z is the sheath toggle; ALT-Z is TOGGLEUI; CTRL-ALT-Z is nobody's.
+ fn a_press_probes_its_chord_then_falls_back_once() {
+ // The reported bug: Shift held, W pressed. `SHIFT-W` is nobody's, so the retry drops
+ // SHIFT and MOVEFORWARD latches — the reference's `strchr` step, `0x4b7990`.
+ let mut app = harness();
+ press_key(&mut app, KeyCode::ShiftLeft);
+ press_key(&mut app, KeyCode::KeyW);
+ app.update();
+ assert!(
+ state(&app).pressed(cmd::MOVE_FORWARD),
+ "SHIFT-W falls back to W — the whole point of 1142"
+ );
+ // And it unlatches on the base key with the modifier still down (the reference replays
+ // the press-time chord at key-up, `0x483bd0`; we latch the resolved command instead —
+ // same observable).
+ release_key(&mut app, KeyCode::KeyW);
+ app.update();
+ assert!(!state(&app).pressed(cmd::MOVE_FORWARD));
+ // Bare Z is the sheath toggle; ALT-Z is TOGGLEUI. The exact probe runs first, so the
+ // specific chord wins outright — a fallback never overrides a real entry.
let mut app = harness();
press_key(&mut app, KeyCode::KeyZ);
app.update();
@@ -644,15 +699,18 @@ mod tests {
"ALT-Z is TOGGLEUI (0870)"
);
assert!(!state(&app).fired(cmd::TOGGLE_SHEATH));
+ // CTRL-ALT-Z still fires nothing — but for the real reason, not 0585's. The single strip
+ // drops the LEFTMOST modifier (`ALT-CTRL-Z` → `CTRL-Z`, unbound) and stops: it never
+ // reaches ALT-Z, and never reaches bare Z. This is the assertion that pins "one retry".
release_key(&mut app, KeyCode::KeyZ);
press_key(&mut app, KeyCode::ControlLeft);
press_key(&mut app, KeyCode::KeyZ);
app.update();
assert!(
!state(&app).fired(cmd::TOGGLE_UI) && !state(&app).fired(cmd::TOGGLE_SHEATH),
- "CTRL-ALT-Z matches neither entry (0585)"
+ "ALT-CTRL-Z probes CTRL-Z and stops — no second strip to ALT-Z or Z"
);
- // TAB vs SHIFT-TAB are two different commands now.
+ // TAB vs SHIFT-TAB: both bound, so both resolve exactly and neither borrows the other.
let mut app = harness();
press_key(&mut app, KeyCode::Tab);
app.update();
@@ -663,7 +721,8 @@ mod tests {
app.update();
assert!(state(&app).fired(cmd::TARGET_PREVIOUS_ENEMY));
assert!(!state(&app).fired(cmd::TARGET_NEAREST_ENEMY));
- // Super/Cmd is never a binding modifier: a super-held press matches nothing.
+ // Super/Cmd is never a binding modifier: a super-held press builds no chord at all, so it
+ // has no fallback either.
let mut app = harness();
press_key(&mut app, KeyCode::SuperLeft);
press_key(&mut app, KeyCode::KeyZ);
@@ -671,6 +730,45 @@ mod tests {
assert!(!state(&app).fired(cmd::TOGGLE_SHEATH));
}
+ /// **The dev plane spends the keyboard's fallback probe, and nothing else** (1142). 0870 chose
+ /// `Ctrl`+`Shift` for the overlays because the plane looked empty under 0585's exact-match
+ /// law; under the real law it is *nearly* empty — the single strip lands on `SHIFT-`*key*, not
+ /// the bare letter — and `SHIFT-P` is exactly the entry that makes "nearly" insufficient.
+ ///
+ /// Asserted on [`BindingDispatch::resolve`] rather than through the event harness because the
+ /// colliding command is `Kind::Edge` — its whole effect is a Lua body, and the harness has no
+ /// VM, so an end-to-end assertion here could only ever be vacuously true.
+ #[test]
+ fn the_dev_plane_keeps_its_letters_without_stealing_bound_chords() {
+ let by_name =
+ |n: &str| Cmd(SPECS.iter().position(|s| s.name == n).expect("registered") as u16);
+ let pet_paper_doll = by_name("TOGGLECHARACTER3"); // SHIFT-P, decision 1057
+ let mut dispatch = BindingDispatch::test_defaults();
+ let plane_p = Chord::parse("CTRL-SHIFT-P").expect("parses");
+ // Ctrl+Shift+P is the perf HUD's. Off the plane it would fall back onto SHIFT-P — which
+ // is 0585's original "one key did two things" bug, reborn one strip further along...
+ assert_eq!(
+ dispatch.resolve(plane_p, false),
+ Some(pet_paper_doll),
+ "without the plane rule the retry does reach SHIFT-P — this is what is being blocked"
+ );
+ // ...so on the plane, the retry is what it costs.
+ assert_eq!(dispatch.resolve(plane_p, true), None);
+ // The suppression is the FALLBACK only — an exact CTRL-SHIFT- entry still resolves. No
+ // shipped default proves it (1.12's own two, CTRL-SHIFT-TAB and CTRL-SHIFT-PAGEDOWN,
+ // belong to commands the honest tree doesn't carry yet), so the entry is made here: this
+ // is the case a player creates the moment they bind anything on the plane.
+ dispatch.map.insert(plane_p, pet_paper_doll);
+ assert_eq!(
+ dispatch.resolve(plane_p, true),
+ Some(pet_paper_doll),
+ "the plane spends the retry, never the exact probe"
+ );
+ // And SHIFT-P itself is still the pet paper doll: the plane costs nothing outside itself.
+ let shift_p = Chord::parse("SHIFT-P").expect("parses");
+ assert_eq!(dispatch.resolve(shift_p, false), Some(pet_paper_doll));
+ }
+
/// **The pet lane routes on the CTRL digits, and the number row is untouched** (B218,
/// decision 1052). The two share their base keys, so the only thing keeping them apart is
/// the exact-modifier law — worth pinning on the pair that actually collides rather than
diff --git a/crates/benilla-app/src/bindings/chord.rs b/crates/benilla-app/src/bindings/chord.rs
index cbace2e8..40f64ef2 100644
--- a/crates/benilla-app/src/bindings/chord.rs
+++ b/crates/benilla-app/src/bindings/chord.rs
@@ -1,13 +1,19 @@
//! The chord codec (decision 0997): Bevy's physical input ↔ the reference's canonical binding
//! strings — `[ALT-][CTRL-][SHIFT-]`, where the token set is 1.12's own (`W`, `SPACE`,
//! `NUMPAD0`, `BUTTON4`, `MOUSEWHEELUP`, the bare punctuation characters). These strings are what
-//! the table stores, the window displays (through the `KEY_*` GlobalStrings), and the files save —
-//! matched by string equality exactly as the client matches them (decision 0585's law).
+//! the table stores, the window displays (through the `KEY_*` GlobalStrings), and the files save.
+//!
+//! A press is matched against them by string equality — and then, on a miss, **once more** with
+//! its leftmost modifier dropped ([`Chord::fallback`], decision 1142; this is the half 0585 got
+//! wrong and 0997 carried).
//!
//! Prefix order is ALT-CTRL-SHIFT, verified from 1.12's own capture Lua (`Blizzard_BindingUI.lua`
-//! prepends SHIFT, then CTRL, then ALT) and its saved cache (`CTRL-SHIFT-PAGEDOWN`). The Super/Cmd
-//! key is **not** a 1.12 binding modifier: a chord never carries it, a super-modified press never
-//! matches (0585's `sup` addition), and a capture with Super held is ignored outright.
+//! prepends SHIFT, then CTRL, then ALT), its saved cache (`CTRL-SHIFT-PAGEDOWN`), and the engine's
+//! own emitter (`0x4b6630` walks the `{bitIndex,name}` table at `0x846bd0` downward). It is not
+//! only cosmetic: that order is what decides *which* modifier the fallback drops.
+//!
+//! The Super/Cmd key is **not** a 1.12 binding modifier: a chord never carries it, a super-modified
+//! press never matches (0585's `sup` addition), and a capture with Super held is ignored outright.
use bevy::input::mouse::MouseButton;
use bevy::prelude::KeyCode;
@@ -21,8 +27,8 @@ pub(crate) enum BindKey {
WheelDown,
}
-/// One parsed binding chord: the exact modifier set + the base input. Equality is the whole
-/// matching law — a press matches iff its held modifiers equal the chord's exactly.
+/// One parsed binding chord: the modifier set + the base input. Equality is how a press is
+/// probed; [`Chord::fallback`] is the second and last probe when that misses.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) struct Chord {
pub alt: bool,
@@ -59,6 +65,38 @@ impl Chord {
key: token_key(rest)?,
})
}
+
+ /// The **one** retry the reference allows after an exact miss — drop the leftmost modifier
+ /// present, in the emitted prefix order ALT → CTRL → SHIFT. `None` once there is none left,
+ /// which is where the lookup ends (decision 1142).
+ ///
+ /// `CBindings::ExecuteBinding` (`0x4b7990`) does this by string surgery: on a miss it calls
+ /// `strchr(chord, '-')` (`0x4b7a2b`/`0x4b7a2d`) and re-probes the text after the **first**
+ /// `'-'` (`0x4b7a49 inc eax`). Both second-probe misses land on `0x4b7b41 xor eax,eax` —
+ /// there is no third probe and no loop, so `ALT-CTRL-Z` reaches `CTRL-Z` and stops without
+ /// ever seeing `ALT-Z` or bare `Z`. Hence `Option`, not an iterator: the chain is two long.
+ ///
+ /// Cutting at the first `'-'` *is* dropping the leftmost prefix, byte for byte — including
+ /// for the one token that contains a `'-'` of its own, the minus key. With a modifier held
+ /// the first `'-'` still terminates that prefix (`SHIFT--` → `-`); bare, the reference's
+ /// retry lands on the empty string and can only miss, which is exactly the `None` here.
+ pub(crate) fn fallback(self) -> Option {
+ if self.alt {
+ Some(Chord { alt: false, ..self })
+ } else if self.ctrl {
+ Some(Chord {
+ ctrl: false,
+ ..self
+ })
+ } else if self.shift {
+ Some(Chord {
+ shift: false,
+ ..self
+ })
+ } else {
+ None
+ }
+ }
}
/// Build the canonical chord string from live modifier state + a base token — the capture arm's
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index fed12829..806ff4bc 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -2,8 +2,9 @@
//! (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 backed by a real engine knob (the
-//! honest-tree rule). Defaults are the code's own truths — the sound quartet is the client's
+//! - **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. Defaults are the code's own truths — the sound quartet is the client's
//! verified CVar registration defaults (wow-re `benilla-pins.md` B10, quoted in
//! [`crate::sound::SoundConfig`]), `uiScale`/`farclip` are benilla's shipped defaults —
//! and a test welds each string to the constant it mirrors so they cannot drift.
@@ -31,7 +32,7 @@ use crate::chat_bubble::BubbleConfig;
use crate::clutter::ClutterConfig;
use crate::minimap::MinimapZoom;
use crate::nameplates::NameConfig;
-use crate::player::camera::LookConfig;
+use crate::player::camera::{LookConfig, ZoomLimit, MOUSE_SPEED_RANGE};
use crate::sound::SoundConfig;
use crate::target::ClickConfig;
use crate::ui_loot::LootConfig;
@@ -75,6 +76,21 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// the clutter-density knob — 0 is the client's bare frillDensity baseline (×1 = 16 visits),
// each step +1×; the "2" default IS ClutterConfig's shipped ×3 (the reference's High).
("WorldDetail", "2"),
+ // Mouse Sensitivity (1140): 1.12's own `mousespeed` slider (UIOptionsFrameSliders, 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().
+ ("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. Registered "2" — the factor fully
+ // raised — because that IS benilla's shipped 30 yd ceiling, a knowing divergence from the
+ // reference's registrar "1" that camera.rs has carried in prose since it was written.
+ ("cameraDistanceMaxFactor", "2"),
+ // 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. That default is BEHAVIOUR-derived, not byte-read —
+ // 1.12's registrar value for this var is not pinned in wow-re yet.
+ ("statusBarText", "0"),
// 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.
// `ChatBubbles` is the reference's registered "1"; `ChatBubblesParty` is ON where the binary
@@ -142,6 +158,7 @@ struct Knobs<'a> {
clutter: &'a mut ClutterConfig,
minimap: &'a mut MinimapZoom,
bubbles: &'a mut BubbleConfig,
+ zoom: &'a mut ZoomLimit,
}
/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this
@@ -164,10 +181,18 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"farclip" => knobs.view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()),
"deselectonclick" => knobs.click.deselect_on_click = v != 0.0,
"mouseinvertpitch" => knobs.look.invert_pitch = v != 0.0,
+ "cameradistancemaxfactor" => knobs.zoom.set_factor(v),
+ // 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());
+ }
"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,
+ // A CVar with no HOST knob, because its consumer is Lua (1140). Known — so the caller
+ // dirties the config and the value persists — with nothing to apply on this side.
+ "statusbartext" => {}
// The two bubble switches (1139) — flags, like every other pair here.
"chatbubbles" => knobs.bubbles.all = v != 0.0,
"chatbubblesparty" => knobs.bubbles.party = v != 0.0,
@@ -207,6 +232,7 @@ fn load_config(
mut clutter: ResMut,
mut minimap: ResMut,
mut bubbles: ResMut,
+ mut zoom: ResMut,
) {
let mut knobs = Knobs {
sound: &mut sound,
@@ -219,6 +245,7 @@ fn load_config(
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
+ zoom: &mut zoom,
};
if std::env::var_os("WOW_UI_SCALE").is_some() {
persist.env_overridden.insert("uiscale".into());
@@ -289,6 +316,7 @@ fn sync_cvars(
mut clutter: ResMut,
mut minimap: ResMut,
mut bubbles: ResMut,
+ mut zoom: ResMut,
) {
let Some(mut script) = script else {
return;
@@ -296,7 +324,7 @@ fn sync_cvars(
if !persist.registered {
script.register_cvars(REGISTERED.iter().copied());
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
- let session: [(&str, String); 20] = [
+ let session: [(&str, String); 22] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -308,6 +336,8 @@ fn sync_cvars(
("farclip", view.farclip.to_string()),
("deselectOnClick", flag(click.deselect_on_click)),
("mouseInvertPitch", flag(look.invert_pitch)),
+ ("mousespeed", look.sensitivity.to_string()),
+ ("cameraDistanceMaxFactor", zoom.factor().to_string()),
("autoLootDefault", flag(loot.auto_loot)),
("UnitNamePlayer", flag(names.player)),
("UnitNameNPC", flag(names.npc)),
@@ -344,6 +374,7 @@ fn sync_cvars(
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
+ zoom: &mut zoom,
};
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
@@ -459,6 +490,8 @@ mod tests {
d["mouseInvertPitch"] != 0.0,
LookConfig::default().invert_pitch
);
+ assert_eq!(d["mousespeed"], LookConfig::default().sensitivity);
+ assert_eq!(d["cameraDistanceMaxFactor"], ZoomLimit::default().factor());
assert_eq!(d["autoLootDefault"] != 0.0, LootConfig::default().auto_loot);
// The name trio (0992) welds to NameConfig's defaults the same way.
let names = NameConfig::default();
@@ -499,6 +532,7 @@ mod tests {
};
let mut minimap = MinimapZoom::default();
let mut bubbles = BubbleConfig::default();
+ let mut zoom = ZoomLimit::default();
let mut knobs = Knobs {
sound: &mut sound,
scale: &mut scale,
@@ -510,6 +544,7 @@ mod tests {
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
+ zoom: &mut zoom,
};
assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs));
assert_eq!(knobs.sound.music, 0.7);
@@ -528,6 +563,16 @@ mod tests {
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 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);
// The name trio lands on its gates (0992).
@@ -616,6 +661,7 @@ mod tests {
.init_resource::()
.init_resource::()
.init_resource::()
+ .init_resource::()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
@@ -683,6 +729,7 @@ mod tests {
.init_resource::()
.init_resource::()
.init_resource::()
+ .init_resource::()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app.update();
diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs
index 3138ddc2..566bb8aa 100644
--- a/crates/benilla-app/src/debug_panel/mod.rs
+++ b/crates/benilla-app/src/debug_panel/mod.rs
@@ -308,15 +308,21 @@ pub(crate) const DEV_CHORD: &str = "Ctrl+Shift";
///
/// **Exactly those two modifiers and no others**, both sides of each. The block is what makes this
/// plane safe to leave ungated below: AltGr+Shift+*key* is `Ctrl`+`Alt`+`Shift`+*key*, and a German
-/// layout typing one of those into chat must not fire an overlay. It is also the same exact-set rule
-/// every binding here obeys — the reference matches a binding by string equality, so `CTRL-SHIFT-P` is
-/// a different entry from `SHIFT-P` and neither answers for the other.
+/// layout typing one of those into chat must not fire an overlay.
+///
+/// **"No letter at all" was half the story, and the missing half cost us a plane's worth of safety**
+/// (decision 1142). The reference does not match bindings by equality alone: an exact miss re-probes
+/// **once** with the leftmost modifier dropped, so `CTRL-SHIFT-`*key* falls through to
+/// `SHIFT-`*key* — never to the bare letter, which is why this plane survived the correction at all,
+/// but far enough that `Ctrl`+`Shift`+`P` would open the pet paper doll (`SHIFT-P`,
+/// `TOGGLECHARACTER3`) under the perf HUD. So the other half of the rule now lives where the law
+/// itself does, in `bindings::BindingDispatch::resolve`: this plane spends the keyboard's
+/// **fallback probe**, and only that — an exact `CTRL-SHIFT-` binding, the reference's two included,
+/// still dispatches normally.
///
/// Deliberately **not** gated on [`crate::ui_script::UiKeyboardCapture`] the way the bare-key toggles
/// were: a chord can't be mistaken for typed text, so the dev overlays stay reachable with the chat bar
-/// open. The other half of the rule lives at the gameplay key feed (`ui_script::input`): a bare-key
-/// binding doesn't fire while *any* modifier is held, so this chord doesn't also trigger the letter's
-/// game action.
+/// open.
pub(crate) fn dev_chord(keys: &ButtonInput, key: KeyCode) -> bool {
dev_plane(keys) && keys.just_pressed(key)
}
diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs
index c86e8b08..ae92fb4d 100644
--- a/crates/benilla-app/src/player.rs
+++ b/crates/benilla-app/src/player.rs
@@ -141,6 +141,7 @@ impl Plugin for PlayerPlugin {
follow::plugin(app);
camera_saved::plugin(app);
app.init_resource::();
+ app.init_resource::();
app.add_systems(Startup, setup::setup_player.after(AssetSet::Open))
// The world camera renders only when the world can be seen (decision 0540): in world,
// or under the opaque loading screen (whose covered render is what compiles the
@@ -277,7 +278,14 @@ fn control(
buttons: Res>,
// Nested into one param to stay within Bevy's 16-element system-param tuple limit. (The
// scroll wheel left this tuple with 0997: zoom reads the CAMERAZOOM bindings now.)
- mouse: (Res, Res),
+ // The pointer's motion this frame + the two knobs that scale what it does with it. Bundled
+ // because a Bevy system takes at most 16 parameters and this one is at the ceiling — the
+ // grouping is the existing `mouse` tuple, widened rather than a seventeenth argument.
+ pointer: (
+ Res,
+ Res,
+ Res,
+ ),
// The net bridge, bundled into one param (16-param limit): the outbound command channel + the
// inbound teleport/worldport messages `apply_net_updates` wrote earlier this frame
// (WorldStage::Net), + the sheath-setter queue (the Z toggle's request — decision 0080).
@@ -394,8 +402,9 @@ fn control(
return;
};
let (mut window, mut cursor_opts) = window.into_inner();
- let mouse_motion = &mouse.0;
- let invert_pitch = mouse.1.invert_pitch;
+ let mouse_motion = &pointer.0;
+ let look_cfg = *pointer.1;
+ let zoom_max = pointer.2.max;
let (move_speed, capsule, cam_probe, pointer_over_ui, inspect, ui_capture, click_consumed) = (
&speed_capsule.0,
&speed_capsule.1 .0,
@@ -482,7 +491,7 @@ fn control(
&mut world_clicks.2,
left_click,
right_click,
- invert_pitch,
+ look_cfg,
time.elapsed_secs(),
);
// A stun freezes the BODY, not the view. The look session has already moved `cam.yaw` (and, on
@@ -511,7 +520,7 @@ fn control(
// `CameraZoomIn(1.0)` argument.
let zoom = binds.amount(crate::bindings::cmd::CAMERA_ZOOM_IN)
- binds.amount(crate::bindings::cmd::CAMERA_ZOOM_OUT);
- apply_zoom_scroll(zoom, dt, &mut rig);
+ apply_zoom_scroll(zoom, dt, &mut rig, zoom_max);
// Free-fly is a dev instrument, so it sits on the dev chord, not a bare `F` (decision 1043).
// A bare `F` is a key the reference lets a player bind — our own store test binds it to JUMP —
@@ -1191,6 +1200,61 @@ fn control(
}
}
let feet = player.pos;
+ // **The ride trace** (`WOW_MOVE_TRACE_TAGS=ride`) — one line per frame while attached, plus
+ // the frame after a detach, because "what happened on the boat" is otherwise unanswerable:
+ // the deck's own motion is in the boat's transform, the rider's in world space, and the
+ // difference between them is the only thing that says whether the carry composed. The
+ // director's report — *stepped off a ledge on a boat and it threw me back across the boat
+ // until I landed* — is a statement about the DECK-relative path, which no other instrument
+ // here records.
+ if crate::dbg_trace::enabled_for("ride") {
+ let boat_pose = player
+ .ride
+ .as_ref()
+ .and_then(|r| transports.get(r.entity).ok())
+ .map(|(t, _)| (t.translation, t.rotation.to_euler(EulerRot::YXZ).0));
+ if let (Some(ride), Some((bpos, byaw))) = (player.ride.as_ref(), boat_pose) {
+ let local = Quat::from_euler(EulerRot::YXZ, byaw, 0.0, 0.0)
+ .inverse()
+ .mul_vec3(feet - bpos);
+ crate::dbg_trace::line(
+ "ride",
+ &format!(
+ "on {:#x} deck({:8.2},{:7.2},{:8.2}) yaw{:+.3} | feet({:8.2},{:7.2},{:8.2}) local({:7.2},{:6.2},{:7.2}) | grounded={} support={} vy={:+6.2}",
+ ride.guid,
+ bpos.x,
+ bpos.y,
+ bpos.z,
+ byaw,
+ feet.x,
+ feet.y,
+ feet.z,
+ local.x,
+ local.y,
+ local.z,
+ grounded as u8,
+ match ground.and_then(owning_transport) {
+ Some(_) => "deck",
+ None if ground.is_some() => "world",
+ None => "NONE",
+ },
+ player.vel_y,
+ ),
+ );
+ } else if !swimming {
+ // Not riding: only worth a line when something under us *is* a transport, i.e. the
+ // frames where an attach should have happened and did not.
+ if let Some((_, _, guid)) = ground.and_then(owning_transport) {
+ crate::dbg_trace::line(
+ "ride",
+ &format!(
+ "OFF but standing on {:#x} at ({:8.2},{:7.2},{:8.2}) grounded={}",
+ guid.0, feet.x, feet.y, feet.z, grounded as u8
+ ),
+ );
+ }
+ }
+ }
if let Some(ride) = player.ride.as_mut() {
if let Ok((boat, _)) = transports.get(ride.entity) {
ride.local_pos = boat.compute_affine().inverse().transform_point3(feet);
diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs
index 069b3de8..1b618cc9 100644
--- a/crates/benilla-app/src/player/camera.rs
+++ b/crates/benilla-app/src/player/camera.rs
@@ -97,8 +97,46 @@ impl PressGesture {
/// *defaults* and the 50 hard cap, not the factor slider's own max.) Our starting zoom is 15 — pulled
/// back a bit further than vanilla's own default for a wider view.
pub(super) const CAM_DIST_MIN: f32 = 0.0;
-pub(super) const CAM_DIST_MAX: f32 = 30.0;
+/// The reference's `cameraDistanceMax` — the BASE the factor multiplies (registrar default 15).
+/// Not exposed: 1.12's panel offers only the factor, so this stays the constant it is there.
+pub(super) const CAM_DIST_BASE_MAX: f32 = 15.0;
+/// `cameraDistanceMaxFactor`'s slider range — 1.12's own (MAX_FOLLOW_DIST: 1 … 2, step 0.1).
+pub(crate) const CAM_DIST_FACTOR_RANGE: std::ops::RangeInclusive = 1.0..=2.0;
+/// The shipped max orbit: the factor slider fully raised, which is where benilla has always sat.
+pub(super) const CAM_DIST_MAX: f32 = CAM_DIST_BASE_MAX * 2.0;
pub(super) const CAM_DIST_DEFAULT: f32 = 15.0;
+
+/// The max-orbit knob (decision 1140) — 1.12's `cameraDistanceMaxFactor` over the base above.
+/// A fourth frozen constant made reachable: [`CAM_DIST_MAX`] was the only zoom ceiling there was.
+///
+/// **Our registered default is the factor at 2.0, the reference's registrar ships 1.0** — a named
+/// divergence, and the one this file has always carried in its own words ("pulled back a bit
+/// further than vanilla's own default for a wider view"). Lowering the slider re-clamps the live
+/// target on the next frame, so the view comes in rather than waiting for the next wheel notch.
+#[derive(Resource)]
+pub(crate) struct ZoomLimit {
+ /// Max orbit distance in yards — `CAM_DIST_BASE_MAX × factor`, hard-capped like the client's 50.
+ pub(crate) max: f32,
+}
+
+impl Default for ZoomLimit {
+ fn default() -> Self {
+ Self { max: CAM_DIST_MAX }
+ }
+}
+
+impl ZoomLimit {
+ /// Set from the CVar's factor, clamped to the reference's slider range first.
+ pub(crate) fn set_factor(&mut self, factor: f32) {
+ let f = factor.clamp(*CAM_DIST_FACTOR_RANGE.start(), *CAM_DIST_FACTOR_RANGE.end());
+ self.max = CAM_DIST_BASE_MAX * f;
+ }
+
+ /// The live factor — what the CVar table and the config file carry.
+ pub(crate) fn factor(&self) -> f32 {
+ self.max / CAM_DIST_BASE_MAX
+ }
+}
/// Yards the wheel moves the target per notch — `CameraZoomIn`/`CameraZoomOut`'s default `amount`
/// (VERIFIED 1.0 in `WoW.exe`).
const CAM_ZOOM_STEP: f32 = 1.0;
@@ -106,16 +144,48 @@ const CAM_ZOOM_STEP: f32 = 1.0;
/// glides the distance toward the wheel target at this *constant velocity* (linear, frame-delta-scaled
/// — `FUN_005112d0` in `WoW.exe`), **not** an exponential ease.
const CAM_MOVE_SPEED: f32 = 8.33;
-/// Mouse-look sensitivity, radians of camera rotation per pixel of mouse motion.
+/// Mouse-look sensitivity at the slider's neutral notch — radians of camera rotation per pixel of
+/// mouse motion. [`LookConfig::sensitivity`] scales it; this is the ×1.0 case, and it is what the
+/// client felt like before there was a slider at all (decision 1140).
const LOOK_SENSITIVITY: f32 = 0.003;
+/// The `mousespeed` slider's range — 1.12's own (UIOptionsFrameSliders' MOUSE_SENSITIVITY row:
+/// 0.5 … 1.5, step 0.05). A multiplier over [`LOOK_SENSITIVITY`], so the registered default 1.0
+/// reproduces the shipped feel exactly.
+pub(crate) const MOUSE_SPEED_RANGE: std::ops::RangeInclusive = 0.5..=1.5;
/// The mouse-look player knobs (decision 0961): `mouseInvertPitch` is 1.12's own Interface
/// Options checkbox (UIOptionsFrame.lua index 1, CVar-backed), settable from the Options
/// window's Controls page through the CVar store (0954). Inverted, moving the mouse up pitches
/// the camera down — the delta.y term flips sign at the one apply site, both drag styles alike.
-#[derive(Resource, Default)]
+///
+/// `sensitivity` is 1.12's `mousespeed` slider (1140), the same story one layer down: the rate was
+/// a frozen constant with no way to reach it. It multiplies [`LOOK_SENSITIVITY`] at BOTH apply
+/// sites — the rotation itself and the click-vs-drag travel budget — because the reference scales
+/// the device delta once, upstream of everything that reads it, and a budget measured in unscaled
+/// pixels would make a click's drag threshold drift as the slider moved.
+#[derive(Resource, Clone, Copy)]
pub(crate) struct LookConfig {
pub(crate) invert_pitch: bool,
+ pub(crate) sensitivity: f32,
+}
+
+impl Default for LookConfig {
+ fn default() -> Self {
+ Self {
+ invert_pitch: false,
+ sensitivity: 1.0,
+ }
+ }
+}
+
+impl LookConfig {
+ /// Radians of camera rotation per pixel of mouse motion, this session. ONE function because
+ /// both readers must agree: the look rotation itself and the click-vs-drag travel budget that
+ /// decides whether a press was a click. Splitting them would let the slider move the drag
+ /// threshold out from under the gesture (decision 1140).
+ pub(super) fn rate(self) -> f32 {
+ LOOK_SENSITIVITY * self.sensitivity
+ }
}
/// Camera pitch clamp (radians) — **VERIFIED ±89.00°** (`WoW.exe` `0x8089d8`/`0x8089dc` =
/// 1.5533430576 rad; the pitch integrate `FUN_00510120`, wow-re `follow-camera`). A single uniform
@@ -299,7 +369,7 @@ pub(super) fn run_look_session(
world_right_press: &mut MessageWriter,
left_click: &mut Option,
right_click: &mut Option,
- invert_pitch: bool,
+ look_cfg: LookConfig,
// Seconds on the app clock — the press predicate's two time gates are measured against it.
now: f32,
) {
@@ -324,8 +394,9 @@ pub(super) fn run_look_session(
// the release decides. The travel is charged from the *input* delta, before the pitch clamp —
// the reference accumulates raw device motion, so a drag pinned at the pitch limit still spends
// its budget.
- let dyaw = (mouse_motion.delta.x * LOOK_SENSITIVITY).abs();
- let dpitch = (mouse_motion.delta.y * LOOK_SENSITIVITY).abs();
+ let rate = look_cfg.rate();
+ let dyaw = (mouse_motion.delta.x * rate).abs();
+ let dpitch = (mouse_motion.delta.y * rate).abs();
for test in [&mut *left_click, &mut *right_click].into_iter().flatten() {
test.yaw_travel += dyaw;
test.pitch_travel += dpitch;
@@ -410,10 +481,14 @@ pub(super) fn run_look_session(
// turns the character (its facing tracks the camera yaw); left-drag leaves the character facing.
if let Some(active) = rig.look {
let delta = mouse_motion.delta;
- cam.yaw -= delta.x * LOOK_SENSITIVITY;
+ cam.yaw -= delta.x * rate;
// `mouseInvertPitch` flips only the pitch axis (the 1.12 checkbox's whole meaning).
- let dy = if invert_pitch { -delta.y } else { delta.y };
- cam.pitch = (cam.pitch - dy * LOOK_SENSITIVITY).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
+ let dy = if look_cfg.invert_pitch {
+ -delta.y
+ } else {
+ delta.y
+ };
+ cam.pitch = (cam.pitch - dy * rate).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
if active == LookButton::Right || both_buttons {
*face_yaw = cam.yaw;
}
@@ -426,11 +501,15 @@ pub(super) fn run_look_session(
/// mirroring the reference camera. `scroll` is this frame's net zoom-in amount (wheel notches in
/// line-equivalents — the binding dispatch normalizes trackpad pixels — or the 1.12 key step of
/// 1.0 per press; positive = closer), so a rebound zoom key feels exactly like a wheel notch.
-pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl) {
+pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl, max: f32) {
if scroll != 0.0 {
rig.target_distance =
- (rig.target_distance - scroll * CAM_ZOOM_STEP).clamp(CAM_DIST_MIN, CAM_DIST_MAX);
+ (rig.target_distance - scroll * CAM_ZOOM_STEP).clamp(CAM_DIST_MIN, max);
}
+ // Re-clamp every frame, not just on a notch: lowering the Max Camera Distance slider has to
+ // pull a camera already sitting past the new ceiling back in, and the glide below then eases
+ // it there at the same yd/s a wheel notch would.
+ rig.target_distance = rig.target_distance.min(max);
// Glide the actual distance toward the wheel target at a constant `cameraDistanceMoveSpeed` yd/s,
// stopping exactly there — the verified vanilla behavior (linear, frame-delta-scaled; not an ease).
let max_step = CAM_MOVE_SPEED * dt;
@@ -900,6 +979,26 @@ mod tests {
// which is the half that makes the reference's gesture possible at all.
}
+ /// The `mousespeed` slider is a MULTIPLIER over the shipped per-pixel rate (decision 1140), and
+ /// the neutral notch has to reproduce the old constant exactly — the whole point of registering
+ /// the default at 1.0 is that nobody's feel changes until they move the slider. Both the look
+ /// rotation and the click-vs-drag travel budget read this one function, so the drag threshold
+ /// scales with the pointer instead of drifting away from it.
+ #[test]
+ fn the_sensitivity_slider_is_a_multiplier_over_the_shipped_rate() {
+ assert_eq!(LookConfig::default().rate(), LOOK_SENSITIVITY);
+ let fast = LookConfig {
+ sensitivity: 1.5,
+ ..Default::default()
+ };
+ assert_eq!(fast.rate(), LOOK_SENSITIVITY * 1.5);
+ let slow = LookConfig {
+ sensitivity: *MOUSE_SPEED_RANGE.start(),
+ ..Default::default()
+ };
+ assert_eq!(slow.rate(), LOOK_SENSITIVITY * 0.5);
+ }
+
/// The second arm: between 200 and 800 ms the travel gate applies, per axis and independently
/// (`0x514ae0`'s `both accums < 8.0` arm). The thresholds are the reference's 2.25° of yaw and
/// 2.0° of pitch — see [`CLICK_HOLD_CEILING`] for why we hold the angle, not the raw literal.
diff --git a/crates/benilla-app/src/player/mover.rs b/crates/benilla-app/src/player/mover.rs
index 86e8707c..b76db3e0 100644
--- a/crates/benilla-app/src/player/mover.rs
+++ b/crates/benilla-app/src/player/mover.rs
@@ -22,10 +22,10 @@
//! foot cone ([`FOOT_CONE_HEIGHT`]) is **ridden** up the cone's 61.6° skirt over the frames the
//! gait needs ([`foot_cone_ride`]) — a kerb takes three at a run; only a rise above the cone is
//! the instant pop, committed whole within the frame. Uncertified, nothing rises at all;
-//! - a steep face never lifts the mover on a *push*: the horizontal held into the face is
-//! stripped before the clip ([`steep_push_stripped`]), so what the face may do is carry the
-//! body's own descent down itself — you rub along trunks and steep banks, never up them,
-//! and a jump at a hillside costs the jump instead of banking its height;
+//! - a steep face's response is **horizontal only** and the descent passes through it untouched
+//! ([`steep_contact_shear`], the reference's own `0x635090`): the body follows the surface
+//! down at full free-fall rate, no contact can manufacture lift at any angle, and a jump at a
+//! hillside costs the jump instead of banking its height;
//! - airborne → gravity carries the arc, with a one-shot nudge to steer a standstill jump;
//! - a fall whose descent stalls (a capsule wedged between steep faces — the
//! tree-pinch funnel) *lands there*: standing, walking control live, instead of hanging in
@@ -562,8 +562,8 @@ pub(crate) fn grounded_step(
return MoveAndSlideHitResponse::Accept;
}
}
- if let Some(pushed) = steep_push_stripped(**hit.normal, *hit.velocity) {
- *hit.velocity = pushed;
+ if let Some(followed) = steep_contact_shear(**hit.normal, *hit.velocity) {
+ *hit.velocity = followed;
}
MoveAndSlideHitResponse::Accept
},
@@ -727,7 +727,7 @@ pub(crate) fn grounded_step(
/// **One airborne step, resolved against the world** — the arc's slide and nothing else. No
/// step-up and no election snap: the arc owns its own height (gravity carries it; the landing is
/// next frame's ground probe to call), so the only thing the world may do here is *stop* it. Steep
-/// faces get [`steep_push_stripped`]'s treatment, exactly as they do on the ground.
+/// faces get [`steep_contact_shear`]'s treatment, exactly as they do on the ground.
///
/// The airborne twin of [`grounded_step`], and shared for the same reason (0059's one controller,
/// every mover): the local controller ([`step`]) calls it for its held/airborne frames, and a
@@ -752,8 +752,8 @@ pub(crate) fn airborne_step(
&MoveAndSlideConfig::default(),
filter,
|hit| {
- if let Some(pushed) = steep_push_stripped(**hit.normal, *hit.velocity) {
- *hit.velocity = pushed;
+ if let Some(followed) = steep_contact_shear(**hit.normal, *hit.velocity) {
+ *hit.velocity = followed;
}
MoveAndSlideHitResponse::Accept
},
@@ -791,7 +791,7 @@ fn is_steep_face(ny: f32) -> bool {
/// with the vertical-lift projection: keep the horizontal velocity exactly, set the vertical so
/// the motion rides along the plane (`v'·n = 0` — the plane's own clip then passes it
/// untouched). Unreal's `bMaintainHorizontalGroundVelocity` is the same standard treatment.
-/// Steep faces stay with [`steep_push_stripped`], and any height the ride manufactures is
+/// Steep faces stay with [`steep_contact_shear`], and any height the ride manufactures is
/// bounded by the end-of-frame snap, which only ever settles onto a walkable floor.
fn walkable_ride_velocity(n: Vec3, v: Vec3) -> Option {
if n.y < GROUND_COS || v.dot(n) >= 0.0 {
@@ -837,53 +837,61 @@ fn foot_cone_ride(n: Vec3, v: Vec3) -> Option {
Some(Vec3::new(v.x, into * STEP_SLOPE_RATIO, v.z))
}
-/// The steep-face law: **a steep (non-walkable, non-overhanging) face may convert the mover's
-/// own descent into a slide down itself; it may never convert the mover's *push* into lift.**
+/// The steep-contact response: **the descent is never touched, and the push-out is horizontal.**
///
-/// Collide-and-slide clips velocity onto each contact plane, and on a tilted plane that clip
-/// manufactures upward motion out of a horizontal push. Split the result and the two halves have
-/// different owners:
+/// VERIFIED against the reference (wow-re `system/collision/scratch/fall-steep-response.md`, the
+/// dispatch this decided): `0x635090` writes **exactly two floats** — an x/y push-out along the
+/// horizontal projection of the contact normal — and `0x635600` adds them to `Δx`/`Δy` only, while
+/// `Δz = dir.z · remaining` passes through **with no addend at all**. The normal's vertical is
+/// annihilated in the response's own denominator (`0x635166 fmul [0x7ffd74]`, and that constant is
+/// `0.0`). So the residual is placed **exactly in the contact plane** (`Δ'·A = 0`) but reaches it by
+/// moving *only horizontally*: the **vertical-shear** projection onto the plane, not the orthogonal
+/// one every collide-and-slide library ships.
///
/// ```text
-/// v'.y = v.y·(1 − n.y²) + into·|n.xz|·n.y
-/// \_____________/ \______________/
-/// geometry's share the push's lift
+/// v' = v − (v·n)/|n_h|² · n_h n_h = (n.x, 0, n.z)
/// ```
///
-/// The first term is the mover's own vertical, shortened because a body sliding down a face of
-/// pitch θ keeps `sin²θ` of its descent — that is the slide, and it must survive untouched. The
-/// second is manufactured out of the horizontal the player is holding into the hill, and is
-/// exactly what walked the capsule up 50–80° trunks and hillsides.
+/// Decomposing an approach as `a` into the face, `b` across it and `c` descending, against a face of
+/// pitch θ:
///
-/// So this **removes the into-face horizontal and leaves the true plane to clip what remains**,
-/// rather than choosing between two planes. A rise carries the body up on its own jump with the
-/// push spent against the face; a fall keeps `sin²θ` of its descent and slides *down* the hill.
+/// | component | what survives |
+/// |---|---|
+/// | downhill | `c · cotθ` — exactly what following the surface requires |
+/// | across | `b` — preserved in full, never scaled |
+/// | vertical | `−c` — **untouched** |
///
-/// The predecessor rule swapped in the face's vertical-wall flatten whenever the clip would
-/// leave the mover moving *upward* (`v'.y > 0`), and kept the true plane otherwise — a test on
-/// the **sign** of a quantity whose problem is its **origin**. The director's Elwynn capture is
-/// what that misses: at 55° a run into the hill cancels 99% of a 4.9 yd/s descent without ever
-/// crossing zero, so the flatten declined, the descent stalled, and the wedge rest (decisions
-/// 0211/0212, which model a vertical-only fall) read the stall as a landing and stood the body up
-/// mid-face. Jump, bank ~1.2 yd, repeat: a climbing ratchet up terrain far too steep to walk.
+/// Three consequences, none of them a tuning choice:
///
-/// Grounded frames are unchanged in outcome — their velocity is horizontal, so stripping the
-/// into-face component leaves motion along the wall line, which is what the flatten produced.
-/// Walkable floors ride ([`walkable_ride_velocity`]) and overhangs (`n.y < 0`) are ceilings;
-/// neither belongs here. Penetration safety is untouched: the sweeps still stop at the real
-/// surface, this only shapes the deflection. (Unreal's `HandleSlopeBoosting` and Godot's
-/// `floor_block_on_wall` are the same treatment with the same sign-test blind spot.)
-fn steep_push_stripped(n: Vec3, v: Vec3) -> Option {
+/// - **No geometry can add upward motion, at any steepness or approach angle.** The predecessor
+/// rules here reconstructed that invariant twice — first by testing the *sign* of the clipped
+/// vertical (0970), then by stripping the into-face push before an orthogonal clip (1135). The
+/// reference gets it structurally, because the vertical is not an output.
+/// - **A fall against a steep face descends at the full free-fall rate**, so along-surface speed is
+/// `V/sinθ` — *faster* than free fall. This is the reference's slippery steep-slope slide. An
+/// orthogonal clip descends at `V·sin²θ` instead: the same path down the face, walked 1.2–1.7×
+/// slower, which is that much longer in contact and so that much further across (the director's
+/// *"we slide across it until we land … further than the ref"*, worst just past 50° — exactly
+/// where players engage slopes).
+/// - **How hard you press into the face leaves no trace in the residual** — the push-out cancels
+/// `a` exactly. It survives only through *where* the sweep stopped.
+///
+/// A **grounded** frame is unchanged by all of this: its velocity is horizontal, `c = 0`, and the
+/// formula collapses to "remove the into-face component" — which is what walking into a bank always
+/// did here. Walkable faces ride ([`walkable_ride_velocity`]) and overhangs are ceilings; neither
+/// reaches this. The reference's degeneracy guards (`|n_h|` under `2⁻²²`) cannot fire for us:
+/// [`is_steep_face`] bounds `|n_h|` below by `sin 50°`.
+fn steep_contact_shear(n: Vec3, v: Vec3) -> Option {
if !is_steep_face(n.y) {
return None;
}
- // Steepness bounds the horizontal part below by sin 50°, so the normalize is safe.
- let h = Vec3::new(n.x, 0.0, n.z).normalize();
- let into = -(v.x * h.x + v.z * h.z);
- if into <= 0.0 {
+ let vn = v.dot(n);
+ // A contact the motion is leaving is not this response's business.
+ if vn >= 0.0 {
return None;
}
- Some(v + h * into)
+ let h = Vec3::new(n.x, 0.0, n.z);
+ Some(v - (vn / h.length_squared()) * h)
}
/// What one atomic step-up attempt decided — the structured form of the `step` trace line.
@@ -1796,7 +1804,7 @@ mod tests {
assert_eq!((ride.x, ride.z), (7.0, 0.0));
assert!(ride.y <= 7.0 * 50.0_f32.to_radians().tan() + 1e-3);
assert!(walkable_ride_velocity(face(50.1), v).is_none());
- assert!(steep_push_stripped(face(50.1), v).is_some());
+ assert!(steep_contact_shear(face(50.1), v).is_some());
}
#[test]
@@ -1804,39 +1812,64 @@ mod tests {
// The grounded case, unchanged in outcome by the strip: a run straight at a 60° face
// keeps nothing pointing into it, and manufactures no vertical of its own.
let v = Vec3::new(7.0, 0.0, 0.0);
- let out = steep_push_stripped(face(60.0), v).expect("must strip");
+ let out = steep_contact_shear(face(60.0), v).expect("must strip");
assert!(out.x.abs() < 1e-6 && out.y == 0.0);
}
#[test]
- fn the_stall_window_is_stripped_not_judged_by_sign() {
- // The window the sign test missed, and the ratchet's engine. Falling at 4.9 yd/s into a
- // 55° face with a run held into it: the true-plane clip leaves the body descending — so
- // the old `v'.y > 0` flatten declined — but at ~1% of what gravity intended, which the
- // wedge rest reads as a landing. After the strip, geometry's share is all that is taken.
+ fn a_fall_against_a_face_keeps_the_whole_of_its_descent() {
+ // The ratchet's engine, and the slide's, in one place. Falling at 4.9 yd/s into a 55° face
+ // with a run held into it, an orthogonal clip leaves the body descending — so 0970's
+ // `v'.y > 0` flatten declined — at ~1% of what gravity intended, which the wedge rest reads
+ // as a landing. The reference never touches the vertical at all.
let (n, v) = (face(55.0), Vec3::new(7.0, -4.9, 0.0));
- let raw = v - v.dot(n) * n;
+ let orthogonal = v - v.dot(n) * n;
assert!(
- raw.y > -0.2,
- "the clip must be the near-cancel it was: {:.3}",
- raw.y
+ orthogonal.y > -0.2,
+ "the orthogonal clip must be the near-cancel it was: {:.3}",
+ orthogonal.y
);
- let stripped = steep_push_stripped(n, v).expect("must strip");
- let slid = stripped - stripped.dot(n) * n;
- let share = slid.y / v.y;
- let sin2 = 55.0_f32.to_radians().sin().powi(2);
+ let out = steep_contact_shear(n, v).expect("a steep contact responds");
assert!(
- (share - sin2).abs() < 1e-3,
- "a stripped fall keeps exactly sin²θ ({sin2:.3}) of its descent: {share:.3}"
+ (out.y - v.y).abs() < 1.0e-4,
+ "the descent passes through untouched: {:.3} vs {:.3}",
+ out.y,
+ v.y
);
+ // …and the horizontal is exactly what following the surface costs: `cotθ` per unit of drop,
+ // downhill. The push held into the face leaves no trace (`a` cancels exactly).
+ let cot = 1.0 / 55.0_f32.to_radians().tan();
+ assert!(
+ (out.x - v.y * cot).abs() < 1.0e-3,
+ "downhill drift must be cot55° = {cot:.3} per unit of drop: {:.3}",
+ out.x
+ );
+ // The residual lies in the plane, so nothing further is clipped off it.
+ assert!(out.dot(n).abs() < 1.0e-4, "Δ'·A = 0: {:.5}", out.dot(n));
}
#[test]
- fn a_real_fall_keeps_its_slide() {
- // Nothing to strip when nothing pushes: a plumb fall against the face, and a fall whose
- // horizontal runs *away* from it, both keep the true plane and slide naturally.
- assert!(steep_push_stripped(face(60.0), Vec3::new(0.0, -10.0, 0.0)).is_none());
- assert!(steep_push_stripped(face(60.0), Vec3::new(-7.0, -20.0, 0.0)).is_none());
+ fn a_plumb_fall_is_carried_down_the_face_at_full_speed() {
+ // A body dropping straight onto a steep face has no push to remove — and is still the
+ // response's business, because following the surface *is* horizontal motion. It keeps every
+ // bit of its 10 yd/s and gains the surface's own drift. (Under 1135's strip this returned
+ // `None`, and the orthogonal clip then slowed the descent instead — the same error seen
+ // from the other side.)
+ let out = steep_contact_shear(face(60.0), Vec3::new(0.0, -10.0, 0.0))
+ .expect("a plumb fall still follows the surface");
+ assert!(
+ (out.y + 10.0).abs() < 1.0e-4,
+ "full free-fall rate: {:.3}",
+ out.y
+ );
+ let cot = 1.0 / 60.0_f32.to_radians().tan();
+ assert!(
+ (out.x + 10.0 * cot).abs() < 1.0e-3,
+ "downhill: {:.3}",
+ out.x
+ );
+ // A contact the motion is leaving is not this response's: no push-out, no drift.
+ assert!(steep_contact_shear(face(60.0), Vec3::new(7.0, 20.0, 0.0)).is_none());
}
#[test]
@@ -1844,7 +1877,7 @@ mod tests {
// A jump rising along the face: the strip spends the push against the hill and the
// mover's own +vy passes through untouched — the face adds nothing to it.
let v = Vec3::new(7.0, 8.0, 0.0);
- let out = steep_push_stripped(face(60.0), v).expect("boost must strip");
+ let out = steep_contact_shear(face(60.0), v).expect("boost must strip");
assert!((out.y - v.y).abs() < 1e-6);
let n = face(60.0);
// …and the true plane no longer opposes what is left, so nothing further is clipped.
@@ -1970,9 +2003,9 @@ mod tests {
.map(|r| r.2)
.fold(f32::MAX, f32::min);
assert!(
- worst > 0.5,
- "a falling frame against the face must keep its descent (sin²55° = 0.67 of it is \
- geometry's share): worst was {worst:.2} of intent"
+ worst > 0.98,
+ "a falling frame against the face keeps ALL of its descent — the reference's response \
+ is horizontal only: worst was {worst:.2} of intent"
);
}
@@ -1980,11 +2013,11 @@ mod tests {
fn walkable_and_overhanging_faces_are_untouched() {
let push = Vec3::new(7.0, 0.0, 0.0);
// Walkable floor: the slide's ordinary uphill walk.
- assert!(steep_push_stripped(face(40.0), push).is_none());
+ assert!(steep_contact_shear(face(40.0), push).is_none());
// Overhang: the ceiling clip stands as-is.
- assert!(steep_push_stripped(Vec3::new(-0.5, -0.7, 0.0).normalize(), push).is_none());
+ assert!(steep_contact_shear(Vec3::new(-0.5, -0.7, 0.0).normalize(), push).is_none());
// A receding face never opposes the motion.
- assert!(steep_push_stripped(face(60.0), -push).is_none());
+ assert!(steep_contact_shear(face(60.0), -push).is_none());
}
#[test]
@@ -1992,7 +2025,7 @@ mod tests {
// A true vertical face manufactures no lift under either rule — the strip removes the
// push, and there is no vertical left for the plane to take.
let v = Vec3::new(7.0, -4.0, 0.0);
- let out = steep_push_stripped(face(90.0), v).expect("must strip");
+ let out = steep_contact_shear(face(90.0), v).expect("must strip");
assert!(out.x.abs() < 1e-6 && (out.y - v.y).abs() < 1e-6);
}
}
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index 85ab58a5..a42c28e5 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -581,7 +581,8 @@ fn load_definers(s: &UiScript, files: &[&str]) {
/// the kit `QuestFrame.xml` inherits from (the same chain `quest_tests`/`questlog_tests` load).
/// `SHOW_BUFF_DURATIONS` arrives with the bar it re-anchors (1139), behind the two files
/// `buff_tests` loads ahead of it — `Cooldown.xml` (every button's child) and `ActionBar.xml`
-/// (`BENILLA_FALLBACK_ICON`), themselves behind `UIParent.xml`.
+/// (`BENILLA_FALLBACK_ICON`), themselves behind `UIParent.xml`. `TextStatusBar.xml` rides in
+/// ahead of them for the Status Bar Text row's consumer — the XP bar's numerals (1140).
fn interface_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
@@ -591,6 +592,7 @@ fn interface_harness() -> UiScript {
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml",
+ "TextStatusBar.xml",
"Cooldown.xml",
"ActionBar.xml",
"ScrollTemplates.xml",
@@ -1649,10 +1651,10 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
- // 16 CVar rows (the Social page's two bubble switches are 1139's) + the Combat page's 14
- // saved-variable rows (1134) + the Interface page's 4 (3 from 1136, Buff Durations 1139) and
- // the Action Bars page's 1 (1136).
- assert_eq!(checked, 35, "every row but one carries a live 1.12 key");
+ // 19 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
+ // and Max Camera Distance 1140's) + the Combat page's 14 saved-variable rows (1134) + the Interface
+ // page's 4 (3 from 1136, Buff Durations 1139) and the Action Bars page's 1 (1136).
+ assert_eq!(checked, 38, "every row but one carries a live 1.12 key");
assert_eq!(
untipped,
vec!["ControlsRowAutoLoot".to_string()],
@@ -1741,9 +1743,10 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
- // 16 of the 17 CVar rows (Social's two are 1139's), plus the Combat page's 14 saved-variable
- // rows (1134), the Interface page's 4 (1136, + Buff Durations 1139) and Action Bars' 1 (1136).
- assert_eq!(raised, 35, "every row but Auto Loot has a 1.12 description");
+ // 19 of the 20 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
+ // Max Camera Distance 1140's), plus the Combat page's 14 saved-variable rows (1134), the Interface page's 4
+ // (1136, + Buff Durations 1139) and Action Bars' 1 (1136).
+ assert_eq!(raised, 38, "every row but Auto Loot has a 1.12 description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -2403,3 +2406,161 @@ fn the_social_page_toggles_the_chat_bubble_cvars() {
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+
+/// **Status Bar Text** (decision 1140) — the row that finally reaches `TextStatusBar.xml`. That
+/// file has been transcribed whole since 1082, with a `CVAR_UPDATE` watcher and a `statusBarText`
+/// read on every repaint, and nothing in the client could move the variable: `GetCVar` answered nil
+/// for a key the host never registered, which reads as off. So the numerals were hover-only, with
+/// no way to pin them.
+///
+/// The row is also the only one in this window carrying a `cvarEvent`, and this is what that buys:
+/// the XP bar's numerals appear on the click, not on the next XP tick. The event is 1.12's own —
+/// `SetCVar(cvar, value, index)`'s third argument, handed back as arg1 — which is why the token
+/// here is the uppercase display name and not the CVar's own spelling.
+#[test]
+fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() {
+ let mut s = interface_harness();
+ // The bar needs a real span before it decides anything about its numerals (its update bails
+ // on valueMax == 0 and hides the strip instead).
+ s.set_player_xp(1000, 10000);
+ s.run("BenillaExpBar_Update(BenillaExpBar)").unwrap();
+ // Shipped default: off, so the numerals only show while hovered.
+ assert_eq!(
+ s.eval::("return GetCVar(\"statusBarText\")")
+ .unwrap(),
+ "0"
+ );
+ assert!(!s
+ .eval::("return BenillaExpBarText:IsShown()")
+ .unwrap());
+
+ s.run("ShowUIPanel(OptionsFrame)").unwrap();
+ s.run("OptionsFrameCategoryListRowInterface:Click()")
+ .unwrap();
+ assert!(!s
+ .eval::("return OptionsFrameContainerBodyInterfaceRowStatusTextCheck:GetChecked()")
+ .unwrap());
+
+ s.run("OptionsFrameContainerBodyInterfaceRowStatusTextCheck:Click()")
+ .unwrap();
+ assert_eq!(
+ s.take_cvar_changes(),
+ vec![("statusBarText".to_string(), "1".to_string())]
+ );
+ // No repaint, no XP tick — only the CVAR_UPDATE the third argument queued.
+ s.tick(0.0);
+ assert!(
+ s.eval::("return BenillaExpBarText:IsShown()")
+ .unwrap(),
+ "the watcher woke on the click, not on the next value change"
+ );
+
+ // And back off again, the same way.
+ s.run("OptionsFrameContainerBodyInterfaceRowStatusTextCheck:Click()")
+ .unwrap();
+ s.tick(0.0);
+ assert!(!s
+ .eval::("return BenillaExpBarText:IsShown()")
+ .unwrap());
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// **Mouse Sensitivity** (decision 1140) — the Controls page's first slider, and the third frozen
+/// constant this arc has unfrozen: the camera's radians-per-pixel rate was a `const` with no way
+/// to reach it. 1.12's own row (`UIOptionsFrameSliders`' MOUSE_SENSITIVITY): 0.5 … 1.5 by 0.05,
+/// a multiplier, so the registered default 1 is the shipped feel and the percent readout reads it
+/// straight. The slider snaps to the reference's step and writes the CVar the host drains onto
+/// `LookConfig::sensitivity`.
+#[test]
+fn the_mouse_sensitivity_slider_snaps_to_the_reference_step() {
+ let mut s = audio_harness();
+ s.set_cvar_host("mousespeed", "1.25");
+ let mut s = harness_on(s);
+ s.run("ShowUIPanel(OptionsFrame)").unwrap();
+ s.run("OptionsFrameCategoryListRowControls:Click()")
+ .unwrap();
+
+ // Read from the table on select, with the era's rounded-percent readout.
+ assert!(s
+ .eval::(
+ "return math.abs(OptionsFrameContainerBodyControlsRowMouseSpeedControlSlider:GetValue() \
+ - 1.25) < 0.0001"
+ )
+ .unwrap());
+ assert_eq!(
+ s.eval::(
+ "return OptionsFrameContainerBodyControlsRowMouseSpeedControlValue:GetText()"
+ )
+ .unwrap(),
+ "125%"
+ );
+
+ // A user move snaps to 0.05 and writes once.
+ s.run("OptionsFrameContainerBodyControlsRowMouseSpeedControlSlider:SetValue(1.42)")
+ .unwrap();
+ assert_eq!(
+ s.take_cvar_changes(),
+ vec![("mousespeed".to_string(), "1.4".to_string())]
+ );
+
+ // Defaults walks it back to the neutral notch — the shipped feel, not the slider's floor.
+ s.run("OptionsFrameContainerDefaults:Click()").unwrap();
+ assert_eq!(
+ s.eval::("return GetCVar(\"mousespeed\")").unwrap(),
+ "1"
+ );
+ assert_eq!(
+ s.eval::(
+ "return OptionsFrameContainerBodyControlsRowMouseSpeedControlValue:GetText()"
+ )
+ .unwrap(),
+ "100%"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// **Max Camera Distance** (decision 1140) — the fourth frozen constant, and the one with a wrinkle
+/// worth pinning: 1.12 stores a FACTOR over `cameraDistanceMax`'s 15 yd base, so the value that
+/// persists is `1.0 … 2.0` while the thing the player is choosing is a distance. The readout shows
+/// the distance; the CVar carries the factor. Our default is the factor fully raised — the 30 yd
+/// ceiling benilla has always had — where the reference's registrar ships 1.0.
+#[test]
+fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
+ let mut s = harness_on(audio_harness());
+ s.run("ShowUIPanel(OptionsFrame)").unwrap();
+ s.run("OptionsFrameCategoryListRowControls:Click()")
+ .unwrap();
+
+ // The shipped ceiling, shown as what it buys.
+ assert_eq!(
+ s.eval::(
+ "return OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
+ )
+ .unwrap(),
+ "30 yd"
+ );
+
+ // Down to the reference's own default: the factor is what persists, the yards are the label.
+ s.run("OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlSlider:SetValue(1.0)")
+ .unwrap();
+ assert_eq!(
+ s.take_cvar_changes(),
+ vec![("cameraDistanceMaxFactor".to_string(), "1".to_string())]
+ );
+ assert_eq!(
+ s.eval::(
+ "return OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
+ )
+ .unwrap(),
+ "15 yd",
+ "vanilla's own out-of-box ceiling"
+ );
+
+ s.run("OptionsFrameContainerDefaults:Click()").unwrap();
+ assert_eq!(
+ s.eval::("return GetCVar(\"cameraDistanceMaxFactor\")")
+ .unwrap(),
+ "2"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs
index 75ee45c2..1ef06709 100644
--- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs
+++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs
@@ -23,6 +23,9 @@ fn load_xml(s: &UiScript, file: &str) {
fn load_unit_frames(s: &UiScript) {
load_xml(s, "Fonts.xml");
load_xml(s, "UIParent.xml");
+ // The bars' numerals machinery (decision 1082), which the manifest loads immediately ahead of
+ // UnitFrames.xml and which every bar's OnLoad wires into since 1143.
+ load_xml(s, "TextStatusBar.xml");
load_xml(s, "GameTooltip.xml");
load_xml(s, "UIDropDownMenu.xml");
load_xml(s, "UnitPopup.xml");
@@ -1445,3 +1448,90 @@ fn the_rest_badge_covers_the_level_number() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+
+/// **The on-bar numerals** (decision 1143) — the half 1140 promised and did not deliver. The Status
+/// Bar Text switch landed with only the XP bar wired to it, so turning it on changed nothing on the
+/// frames people actually watch (director report). Now the player's health and power bars carry
+/// "value / max", and the switch pins them.
+///
+/// Which bars it pins is the REFERENCE's own split, transcribed rather than chosen: 1.12 sets
+/// `textLockable` on the player's two bars, the pet's two and the XP bar, and on nothing else — so
+/// the target frame's numerals are hover-only there and stay blank here. The power bar is whatever
+/// the unit runs on; a rage unit reads "45 / 80" the same as a mana one.
+#[test]
+fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ load_unit_frames(&s);
+
+ let alive = |health: u32, power: u32, power_type: u8| UnitState {
+ exists: true,
+ name: Some("Somebody".into()),
+ health,
+ max_health: 100,
+ level: 12,
+ power_type,
+ power,
+ max_power: 80,
+ dead: false,
+ reaction: 4,
+ ..UnitState::default()
+ };
+ s.set_unit("player", Some(alive(72, 45, 1))); // power_type 1 = RAGE
+ s.set_unit("target", Some(alive(50, 20, 0)));
+ s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
+ s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
+
+ let text = |s: &UiScript, name: &str| -> String {
+ s.eval::(&format!("return tostring(({name}:GetText()) or \"\")"))
+ .unwrap()
+ };
+ let shown = |s: &UiScript, name: &str| -> bool {
+ s.eval::(&format!("return {name}:IsShown()")).unwrap()
+ };
+
+ // Off (the shipped default): the strings carry the numbers, and nothing paints.
+ assert!(!shown(&s, "BenillaPlayerFrameTextureFrameHealthBarText"));
+ assert!(!shown(&s, "BenillaPlayerFrameTextureFramePowerBarText"));
+
+ // On, through the switch's own event — no repaint, no damage taken.
+ s.register_cvars([("statusBarText", "0")]);
+ s.run("SetCVar(\"statusBarText\", \"1\", \"STATUS_BAR_TEXT\")")
+ .unwrap();
+ s.tick(0.0);
+ assert!(
+ shown(&s, "BenillaPlayerFrameTextureFrameHealthBarText"),
+ "your own health numerals pin on"
+ );
+ assert_eq!(
+ text(&s, "BenillaPlayerFrameTextureFrameHealthBarText"),
+ "72 / 100"
+ );
+ assert_eq!(
+ text(&s, "BenillaPlayerFrameTextureFramePowerBarText"),
+ "45 / 80",
+ "the power bar reads its own resource — rage here"
+ );
+
+ // The target has no numerals to pin: 1.12 never makes its bars textLockable, so its numbers
+ // are hover-only there — and our bars have no hover yet, which is why the text regions are not
+ // declared at all rather than declared and permanently dark. The wiring is unconditional though (the
+ // shared OnLoad runs for the target too), so the day a hover lands they are one pair of
+ // FontStrings away.
+ assert!(
+ s.eval::(
+ "return BenillaTargetFrameTextureFrameHealthBarText == nil and BenillaTargetFrameHealthBar.lockShow == 0"
+ )
+ .unwrap(),
+ "no target numerals, but the target's bars are wired for them"
+ );
+
+ // A health change repaints through the bar's own OnValueChanged, like the reference's.
+ s.set_unit("player", Some(alive(31, 45, 1)));
+ s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
+ assert_eq!(
+ text(&s, "BenillaPlayerFrameTextureFrameHealthBarText"),
+ "31 / 100"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
diff --git a/crates/benilla-ui/src/script/cvars.rs b/crates/benilla-ui/src/script/cvars.rs
index 42533575..e6ada1ed 100644
--- a/crates/benilla-ui/src/script/cvars.rs
+++ b/crates/benilla-ui/src/script/cvars.rs
@@ -19,7 +19,7 @@
use mlua::{Lua, Value};
-use super::Model;
+use super::{Model, ScriptValue};
/// One registered CVar: the host's spelling (for change events and the config file), the live
/// value, and the registered default (`GetCVarDefault`, and the saver's "only write what moved").
@@ -176,6 +176,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(value) = value_to_string(v) else {
return Err(mlua::Error::runtime("Usage: SetCVar(\"name\", value)"));
};
+ // The THIRD argument is the `CVAR_UPDATE` token, and it is the whole mechanism behind
+ // that event (decision 1140). 1.12's own callers are its two options panels —
+ // `SetCVar(value.cvar, value.value, index)` (UIOptionsFrame.lua l.335/343/345,
+ // OptionsFrame.lua l.192) — where `index` is the CheckButtons table's KEY, an
+ // uppercase display name like "STATUS_BAR_TEXT". The engine passes it straight through
+ // as arg1, which is why `UIOptionsFrame_OnEvent` can look the row up with
+ // `UIOptionsFrameCheckButtons[arg1]`, and why `TextStatusBar_OnEvent` compares against
+ // "STATUS_BAR_TEXT" rather than "statusBarText".
+ //
+ // A caller that omits it fires nothing — the event is opt-in per write, not a
+ // property of the variable. (Which means `ReputationFrame.xml`'s
+ // `arg1 == "statusBarText"` arm is dead code in shipped 1.12: nothing ever passes the
+ // CVar's own name as the token. Transcribed as found, not repaired.)
+ let token = it.next().and_then(value_to_string);
let mut model = lua.app_data_mut::().expect("model app_data");
let key = name.to_ascii_lowercase();
match model.cvars.get_mut(&key) {
@@ -183,7 +197,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if slot.value != value {
slot.value = value.clone();
let reg_name = slot.name.clone();
- model.cvar_changes.push((reg_name, value));
+ model.cvar_changes.push((reg_name, value.clone()));
+ if let Some(token) = token {
+ model.pending_events.push((
+ "CVAR_UPDATE".to_string(),
+ vec![ScriptValue::Str(token), ScriptValue::Str(value)],
+ ));
+ }
}
}
None => warn_unknown(&mut model, &name),
@@ -231,6 +251,46 @@ mod tests {
assert!(s.take_cvar_changes().is_empty());
}
+ /// **`SetCVar`'s third argument is the `CVAR_UPDATE` token** (decision 1140) — the whole
+ /// mechanism behind that event. 1.12's options panels pass their CheckButtons table KEY
+ /// (`SetCVar(value.cvar, value.value, index)`), an uppercase display name, and the engine
+ /// hands it back verbatim as arg1 — which is what lets `UIOptionsFrame_OnEvent` do
+ /// `UIOptionsFrameCheckButtons[arg1]`. Omit it and nothing fires: the event is opt-in per
+ /// write. A write that changes nothing fires nothing either, like the change queue.
+ #[test]
+ fn the_third_argument_is_the_cvar_update_token() {
+ let mut s = script_with_volume();
+ s.run(
+ "SEEN = {} \
+ f = CreateFrame(\"Frame\") \
+ f:RegisterEvent(\"CVAR_UPDATE\") \
+ f:SetScript(\"OnEvent\", function() table.insert(SEEN, arg1 .. \"=\" .. arg2) end)",
+ )
+ .unwrap();
+
+ // No token: the value moves, the change queues, the event does not fire.
+ s.run(r#"SetCVar("MusicVolume", "0.5")"#).unwrap();
+ s.tick(0.0);
+ assert_eq!(s.eval::("return getn(SEEN)").unwrap(), 0.0);
+
+ // With one: arg1 is the token verbatim (the display name, NOT the CVar's own name),
+ // arg2 the new value.
+ s.run(r#"SetCVar("MusicVolume", "0.6", "MUSIC_VOLUME")"#)
+ .unwrap();
+ s.tick(0.0);
+ assert_eq!(
+ s.eval::("return SEEN[1]").unwrap(),
+ "MUSIC_VOLUME=0.6"
+ );
+
+ // A no-op write is silent on both channels.
+ s.run(r#"SetCVar("MusicVolume", "0.6", "MUSIC_VOLUME")"#)
+ .unwrap();
+ s.tick(0.0);
+ assert_eq!(s.eval::("return getn(SEEN)").unwrap(), 1.0);
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+ }
+
#[test]
fn host_writes_do_not_echo_and_snapshots_carry_defaults() {
let mut s = script_with_volume();