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:
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user