mouse speed, zoom ceiling and bar numerals stop being constants

- Mouse Sensitivity and Max Camera Distance are real settings now, not frozen constants
- Status Bar Text is wired through: value/max numerals on the player and pet frames' bars, live the moment the switch flips
- Shift+W walks — a modified press falls back once to the bare binding, the reference's own two-probe lookup
- the steep-face response is horizontal only, ending the extra slide when jumping nearly straight at a slope

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-08 17:14:26 +07:00
co-authored by Claude Fable 5
parent 9cb943a0b0
commit 8cc4ffa188
13 changed files with 1009 additions and 139 deletions
+77 -4
View File
@@ -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 @@
<Scripts><OnLoad>OptionsRow_OnLoad(self, "mouseInvertPitch", "Invert Mouse",
"OPTION_TOOLTIP_INVERT_MOUSE")</OnLoad></Scripts>
</Frame>
<!-- Mouse Sensitivity (decision 1140). Last because 1.12
puts its four sliders in their own box BELOW the
checkbox columns (UIOptionsFrameSliders), and this is
the only one of the four whose knob we have: the other
three drive auto-follow yaw speed, mouse-look yaw speed
and the max-follow-distance factor, none of which our
camera carries yet. Range and step are the reference's
own (0.5 .. 1.5 by 0.05); the percent readout is the
era's default and reads a multiplier exactly right. -->
<Frame name="$parentRowMouseSpeed" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowInvertMouse" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowInvertMouse" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "mousespeed", "Mouse Sensitivity",
"OPTION_TOOLTIP_MOUSE_SENSITIVITY")
OptionsSliderRow_Setup(self, 0.5, 1.5, 0.05)
</OnLoad></Scripts>
</Frame>
<!-- Max Camera Distance (decision 1140), the second of
1.12's four sliders whose knob we have. The stored
value is the FACTOR over `cameraDistanceMax`'s 15 yd
base — the reference's own arrangement, and the reason
the readout is raw yards here rather than a percent:
"22" means the wheel stops at 22 yards, which is the
thing being chosen. Our default is the factor fully
raised (30 yd), where 1.12 ships 1.0. -->
<Frame name="$parentRowMaxCameraDistance" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "cameraDistanceMaxFactor",
"Max Camera Distance", "OPTION_TOOLTIP_MAX_FOLLOW_DIST")
OptionsSliderRow_Setup(self, 1.0, 2.0, 0.1, "yards")
</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
<!-- The Audio page. Row seats transcribe the era settings list
@@ -2276,11 +2331,29 @@
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowInstantQuestText" inherits="OptionsCheckboxRowTemplate">
<!-- Status Bar Text (decision 1140). First because it is
first in the reference's file: its own Display column
runs CheckButton12 -> **2** -> 21, and 2 is declared
ahead of the 42 chain below it. The one row in this
window carrying a `cvarEvent`: TextStatusBar.xml has
had a CVAR_UPDATE watcher since 1082 and nothing to
wake it. -->
<Frame name="$parentRowStatusText" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "statusBarText",
"Status Bar Text", "OPTION_TOOLTIP_STATUS_BAR")
self.cvarEvent = "STATUS_BAR_TEXT"
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowInstantQuestText" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowStatusText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowStatusText" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "QUEST_FADING_DISABLE",
"Instant Quest Text", "OPTION_TOOLTIP_SHOW_QUEST_FADING")
+16 -6
View File
@@ -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.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
function TextStatusBar_Initialize()
this:RegisterEvent("CVAR_UPDATE")
this.lockShow = 0
-- `bar` is optional and falls back to `this`, the same shape TextStatusBar_UpdateTextString
-- below already has (and for the same reason): the ref calls this from a bar's own OnLoad,
-- where `this` is the bar, but our unit frames wire their children from ONE shared
-- BenillaUnitFrame_OnLoad that holds each bar as a value, not as `this` (decision 1143).
function TextStatusBar_Initialize(bar)
if not bar then
bar = this
end
bar:RegisterEvent("CVAR_UPDATE")
bar.lockShow = 0
end
function SetTextStatusBarText(bar, text)
@@ -144,6 +144,24 @@
local textureFrameLevel = getglobal(tf):GetFrameLevel()
frame.healthBar:SetFrameLevel(textureFrameLevel - 1)
frame.powerBar:SetFrameLevel(textureFrameLevel - 1)
-- The on-bar numerals (TextStatusBar.xml, decision 1082; the switch that shows them
-- is 1140's Status Bar Text). Wiring is unconditional — a frame with no text regions
-- gets a no-op, since SetTextStatusBarText drops a nil text and UpdateTextString skips
-- a bar with no TextString — so the target frame is one FontString pair away from
-- having them the day its hover lands. `textLockable` is the REFERENCE's own split
-- and the only conditional here: 1.12 sets it on the player's two bars, the pet's two
-- and the XP bar, and on nothing else, which is exactly why Status Bar Text pins your
-- own numbers and your pet's but never the target's (decision 1143).
if TextStatusBar_Initialize then -- engine tests load file subsets
TextStatusBar_Initialize(frame.healthBar)
TextStatusBar_Initialize(frame.powerBar)
SetTextStatusBarText(frame.healthBar, getglobal(tf .. "HealthBarText"))
SetTextStatusBarText(frame.powerBar, getglobal(tf .. "PowerBarText"))
if unit == "player" then
frame.healthBar.textLockable = 1
frame.powerBar.textLockable = 1
end
end
frame.nameText = getglobal(tf .. "NameText")
frame.levelText = getglobal(tf .. "LevelText")
frame.deadText = getglobal(tf .. "DeadText")
@@ -917,6 +935,10 @@
</Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="1.0" b="0.0"/>
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
</Scripts>
</StatusBar>
<!-- Power bar 119×12 TOPLEFT (106,-52), UI-StatusBar; color set at runtime (ref l.316-341). -->
<StatusBar name="$parentPowerBar">
@@ -926,6 +948,10 @@
</Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="0.0" b="1.0"/>
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
</Scripts>
</StatusBar>
<!-- The ring art + text, over the bars (ref TargetFrameTextureFrame). 232×100 fills the
parent; it draws over the bar edges because OnLoad pushes the bars one frame level
@@ -968,6 +994,31 @@
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-63" y="-16"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- The on-bar numerals, "value / max" (decision 1143). The reference's
own seats are CENTER (50,4) and (50,-8) (ref-PlayerFrame.xml l.79-96);
ours sit **1 px lower** — the director's call on the shipped result,
2026-08-08, and the deviation is recorded rather than the literals
(decision 1145). Why a digit string reads high at the ref's own offset:
a FontString centres on its LINE BOX, ascent + descent, and digits
occupy only the ascent — so the visible mass sits above the box centre
and a numerals-only string looks lifted inside a 12 px bar. The nudge
is optical, and it belongs on the seat rather than on the centring,
which every other string in the UI shares.
The LAYER is the reference's: BACKGROUND beside the name and level, not
the OVERLAY the state badges sit in, for the reason spelled out above.
They draw over the bar FILL because the whole TextureFrame outranks the
bars by a frame level. Empty until Status Bar Text is on or (later) the
bar is hovered — TextStatusBar.xml owns that decision, not this file. -->
<FontString name="$parentHealthBarText" inherits="TextStatusBarText">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="50" y="3"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="$parentPowerBarText" inherits="TextStatusBarText">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="50" y="-9"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
<Layer level="ARTWORK">
<!-- The PvP flag icon, 64×64 TOPLEFT (18,-20) (ref PlayerPVPIcon,
@@ -1172,6 +1223,10 @@
</Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="1.0" b="0.0"/>
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
</Scripts>
</StatusBar>
<!-- Power bar 119×12 TOPRIGHT (-106,-52), UI-StatusBar; color set at runtime (ref l.274-292). -->
<StatusBar name="$parentPowerBar">
@@ -1181,6 +1236,10 @@
</Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="0.0" b="1.0"/>
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
</Scripts>
</StatusBar>
<!-- The ring art + text, over the bars (ref TargetFrameTextureFrame); it draws over the
bar edges because OnLoad pushes the bars one frame level BELOW it (the ref's
@@ -1387,6 +1446,15 @@
local level = BenillaPetFrameTextureFrame:GetFrameLevel()
frame.healthBar:SetFrameLevel(level - 1)
frame.manaBar:SetFrameLevel(level - 1)
-- The pet's numerals, textLockable like the ref's (PetFrame.xml l.139/164; 1143).
if TextStatusBar_Initialize then -- engine tests load file subsets
TextStatusBar_Initialize(frame.healthBar)
TextStatusBar_Initialize(frame.manaBar)
SetTextStatusBarText(frame.healthBar, BenillaPetFrameTextureFrameHealthBarText)
SetTextStatusBarText(frame.manaBar, BenillaPetFrameTextureFrameManaBarText)
frame.healthBar.textLockable = 1
frame.manaBar.textLockable = 1
end
frame.attackModeCounter = 0
frame.attackModeSign = -1
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
@@ -1653,12 +1721,20 @@
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="47" y="-22"/></Offset></Anchor></Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0" g="1.0" b="0"/>
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
</Scripts>
</StatusBar>
<StatusBar name="$parentManaBar">
<Size><AbsDimension x="70" y="8"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="47" y="-29"/></Offset></Anchor></Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0" g="0" b="1.0"/>
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
</Scripts>
</StatusBar>
<Frame name="$parentTextureFrame">
<Size><AbsDimension x="128" y="53"/></Size>
@@ -1693,6 +1769,21 @@
<FontString name="$parentName" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="50" y="33"/></Offset></Anchor></Anchors>
</FontString>
<!-- The pet's numerals (decision 1143), same font as the ref's
(PetFrame.xml l.87-104). ONE divergence, and it is why these anchor to
the bars instead of carrying the ref's literal offsets: the ref's are
CENTER-from-TOPLEFT (82,-27)/(82,-38) against ITS small-frame bar
seats, and ours sit at (47,-22)/(47,-29) — the same x=82 centre, a
different pitch. Anchoring each string to its own bar lands it right by
construction and cannot drift if the bars move again. The -1 is the
same optical nudge the player's pair carries (1145), so the two frames
agree about where a number sits in its bar. -->
<FontString name="$parentHealthBarText" inherits="TextStatusBarText">
<Anchors><Anchor point="CENTER" relativeTo="BenillaPetFrameHealthBar" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentManaBarText" inherits="TextStatusBarText">
<Anchors><Anchor point="CENTER" relativeTo="BenillaPetFrameManaBar" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
+110 -12
View File
@@ -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<Chord, Cmd>,
seen_generation: Option<u64>,
}
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<Cmd> {
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
+45 -7
View File
@@ -1,13 +1,19 @@
//! The chord codec (decision 0997): Bevy's physical input ↔ the reference's canonical binding
//! strings — `[ALT-][CTRL-][SHIFT-]<TOKEN>`, 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<Chord> {
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
+51 -4
View File
@@ -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<ClutterConfig>,
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
) {
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<ClutterConfig>,
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
) {
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::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
@@ -683,6 +729,7 @@ mod tests {
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app.update();
+12 -6
View File
@@ -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<KeyCode>, key: KeyCode) -> bool {
dev_plane(keys) && keys.just_pressed(key)
}
+69 -5
View File
@@ -141,6 +141,7 @@ impl Plugin for PlayerPlugin {
follow::plugin(app);
camera_saved::plugin(app);
app.init_resource::<camera::LookConfig>();
app.init_resource::<camera::ZoomLimit>();
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<ButtonInput<MouseButton>>,
// 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<AccumulatedMouseMotion>, Res<camera::LookConfig>),
// 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<AccumulatedMouseMotion>,
Res<camera::LookConfig>,
Res<camera::ZoomLimit>,
),
// 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);
+110 -11
View File
@@ -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<f32> = 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<f32> = 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<WorldRightPress>,
left_click: &mut Option<PressGesture>,
right_click: &mut Option<PressGesture>,
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.
+107 -74
View File
@@ -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<Vec3> {
if n.y < GROUND_COS || v.dot(n) >= 0.0 {
@@ -837,53 +837,61 @@ fn foot_cone_ride(n: Vec3, v: Vec3) -> Option<Vec3> {
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<Vec3> {
/// - **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<Vec3> {
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);
}
}
@@ -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::<String>("return GetCVar(\"statusBarText\")")
.unwrap(),
"0"
);
assert!(!s
.eval::<bool>("return BenillaExpBarText:IsShown()")
.unwrap());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(!s
.eval::<bool>("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::<bool>("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::<bool>("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::<bool>(
"return math.abs(OptionsFrameContainerBodyControlsRowMouseSpeedControlSlider:GetValue() \
- 1.25) < 0.0001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"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::<String>("return GetCVar(\"mousespeed\")").unwrap(),
"1"
);
assert_eq!(
s.eval::<String>(
"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::<String>(
"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::<String>(
"return OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
)
.unwrap(),
"15 yd",
"vanilla's own out-of-box ceiling"
);
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(
s.eval::<String>("return GetCVar(\"cameraDistanceMaxFactor\")")
.unwrap(),
"2"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -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::<String>(&format!("return tostring(({name}:GetText()) or \"\")"))
.unwrap()
};
let shown = |s: &UiScript, name: &str| -> bool {
s.eval::<bool>(&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::<bool>(
"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());
}
+62 -2
View File
@@ -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::<Model>().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::<f64>("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::<String>("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::<f64>("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();