diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 4d008f1..e2d0c55 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -272,8 +272,16 @@ public sealed class PlayerMovementController
}
else if (input.Backward)
{
- forwardCmd = MotionCommand.WalkBackward;
- forwardCmdSpeed = 1.0f;
+ forwardCmd = MotionCommand.WalkBackward;
+ // K-fix3 (2026-04-26): backward also honors Run. Without
+ // this, holding X with Run=true (default) still produced
+ // walk-tier backward speed because forwardCmdSpeed was
+ // hardcoded to 1.0. Now scale by runRate the same way
+ // RunForward does.
+ if (input.Run && _weenie.InqRunRate(out float runRateBack))
+ forwardCmdSpeed = runRateBack;
+ else
+ forwardCmdSpeed = 1.0f;
}
else
{
@@ -307,16 +315,30 @@ public sealed class PlayerMovementController
float localY = 0f;
float localX = 0f;
+ // K-fix3 (2026-04-26): unified run-multiplier for backward
+ // + strafe. Forward already scales correctly because it uses
+ // stateVel.Y (which the motion state machine fed runRate
+ // into via DoMotion). Backward + strafe bypass the state
+ // machine and hardcoded speed; previously they capped at
+ // walk speed regardless of Run, which made the ~2.4×
+ // forward-vs-back/strafe ratio feel wrong. Now both scale
+ // with the same runRate the forward branch uses.
+ float runMul = 1.0f;
+ if (input.Run && _weenie.InqRunRate(out float vrr))
+ runMul = vrr;
+
if (input.Forward)
localY = stateVel.Y;
else if (input.Backward)
- localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f);
+ localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * runMul);
- // Full-speed strafe to match retail sidestep pace.
+ // Strafe scales with the same runMul so sidestep matches
+ // the forward pace at run speed (retail uses speed=1.0 for
+ // SideStep + the same hold-key-driven run/walk multiplier).
if (input.StrafeRight)
- localX = MotionInterpreter.SidestepAnimSpeed;
+ localX = MotionInterpreter.SidestepAnimSpeed * runMul;
else if (input.StrafeLeft)
- localX = -MotionInterpreter.SidestepAnimSpeed;
+ localX = -MotionInterpreter.SidestepAnimSpeed * runMul;
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 77e5524..ca4dcab 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -885,14 +885,7 @@ public sealed class GameWindow : IDisposable
// auto-entry if the user opts out of player mode before
// it fires, so the chase camera doesn't snap on top of
// the fly camera mid-inspection.
- _debugVm.ToggleFlyMode = () =>
- {
- // K.2: manual fly toggle pre-empts the auto-entry
- // trigger (user's choice wins). Cancel is no-op when
- // not yet armed.
- _playerModeAutoEntry?.Cancel();
- _cameraController?.ToggleFly();
- };
+ _debugVm.ToggleFlyMode = ToggleFlyOrChase;
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
_panelHost.Register(_debugPanel);
@@ -4348,10 +4341,7 @@ public sealed class GameWindow : IDisposable
string flyLabel = _cameraController.IsFlyMode
? "Exit Free-Fly Mode" : "Enter Free-Fly Mode";
if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F"))
- {
- _playerModeAutoEntry?.Cancel();
- _cameraController.ToggleFly();
- }
+ ToggleFlyOrChase();
}
ImGuiNET.ImGui.EndMenu();
}
@@ -5348,13 +5338,13 @@ public sealed class GameWindow : IDisposable
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode:
- // K-fix2 (2026-04-26): manual fly toggle pre-empts the
- // auto-entry trigger so the chase camera doesn't snap on
- // top of the fly camera mid-inspection. Mirrors the
- // DebugPanel "Toggle Free-Fly Mode" button + Camera menu
- // entry.
- _playerModeAutoEntry?.Cancel();
- _cameraController?.ToggleFly();
+ // K-fix3 (2026-04-26): proper round-trip when player has
+ // an active chase camera. ToggleFly() only swaps
+ // Fly↔Orbit, so a user who flew out of player mode used
+ // to land in Holtburg-orbit on toggle-back. With a chase
+ // camera available, prefer Fly→Chase / Chase→Fly so the
+ // user round-trips back to the same player view.
+ ToggleFlyOrChase();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode:
@@ -5457,6 +5447,38 @@ public sealed class GameWindow : IDisposable
}
}
+ ///
+ /// K-fix3 (2026-04-26): the right "toggle free-fly mode" routine
+ /// when a chase camera is in play.
+ /// only knows Fly↔Orbit and would strand a player-mode user in the
+ /// orbit camera (Holtburg view) when they exit fly. This wrapper
+ /// gives the round-trip the user actually wants:
+ ///
+ /// - Chase → Fly: cancel auto-entry (user's choice wins) and
+ /// switch to fly camera while keeping _playerMode = true +
+ /// the chase camera alive so we can return.
+ /// - Fly → Chase: when _playerMode is still true and the
+ /// chase camera survived, re-enter chase via
+ /// .
+ /// - Otherwise (no chase available): the original Fly↔Orbit
+ /// toggle for offline / pre-login flows.
+ ///
+ ///
+ private void ToggleFlyOrChase()
+ {
+ if (_cameraController is null) return;
+ _playerModeAutoEntry?.Cancel();
+
+ if (_cameraController.IsFlyMode
+ && _playerMode
+ && _chaseCamera is not null)
+ {
+ _cameraController.EnterChaseMode(_chaseCamera);
+ return;
+ }
+ _cameraController.ToggleFly();
+ }
+
///
/// K.2: shared "construct controller + chase camera + enter chase
/// mode" body extracted from the on-enter branch of
diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs
index f8d5ff8..590b9a9 100644
--- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs
+++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs
@@ -142,8 +142,18 @@ public sealed class InputDispatcher
/// True iff the given chord's primary key is currently down on
/// the appropriate device AND the keyboard's current modifier mask
- /// equals the chord's required modifier mask exactly. Modifiers must
- /// match precisely — Ctrl+A held does NOT count Shift+Ctrl+A as held.
+ /// matches the chord's required modifier mask. Match semantics:
+ ///
+ /// - If is ,
+ /// any Shift state is allowed — Shift is the retail walk-modifier and
+ /// must coexist with movement chords (W held + Shift pressed = run
+ /// gracefully drops to walk, not "stop"). Other modifiers
+ /// (Ctrl, Alt, Win) still mismatch strictly so Ctrl+W stays a
+ /// distinct chord.
+ /// - If includes any non-Shift
+ /// modifier (or includes Shift explicitly), the match is exact —
+ /// Ctrl+A held does NOT count Shift+Ctrl+A as held.
+ ///
private bool IsChordHeld(KeyChord chord)
{
if (chord.Device == 0)
@@ -160,7 +170,14 @@ public sealed class InputDispatcher
// Unknown device — never held.
return false;
}
- return _keyboard.CurrentModifiers == chord.Modifiers;
+ var current = _keyboard.CurrentModifiers;
+ if (chord.Modifiers == ModifierMask.None)
+ {
+ // K-fix3 (2026-04-26): permissive Shift handling for bare-key
+ // chords. See the XML doc above for rationale.
+ current &= ~ModifierMask.Shift;
+ }
+ return current == chord.Modifiers;
}
/// Inverse of : decode a chord
diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs
index 4ebedca..d5003bb 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs
@@ -106,6 +106,47 @@ public class InputDispatcherIsActionHeldTests
Assert.False(dispatcher.IsActionHeld(InputAction.None));
}
+ [Fact]
+ public void IsActionHeld_None_chord_remains_held_when_user_adds_Shift()
+ {
+ // K-fix3 (2026-04-26): Shift is the walk modifier in retail AC.
+ // The user holding W (default = run) and then pressing Shift
+ // should drop them to walk speed, NOT stop forward motion. Prior
+ // to this fix, IsChordHeld required CurrentModifiers to match
+ // chord.Modifiers EXACTLY — so (W, None) failed to match while
+ // CurrentModifiers=Shift, and the player stopped on Shift-press.
+ // Now: when chord requires no modifiers, Shift is allowed to
+ // coexist (other modifiers — Ctrl, Alt, Win — still mismatch).
+ var (dispatcher, kb, _, bindings) = Build();
+ bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
+
+ kb.EmitKeyDown(Key.W, ModifierMask.None);
+ Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
+
+ // User now holds Shift while still holding W. CurrentModifiers
+ // becomes Shift; W is still physically down.
+ kb.CurrentModifiers = ModifierMask.Shift;
+ Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
+ }
+
+ [Fact]
+ public void IsActionHeld_None_chord_does_not_fire_when_user_adds_Ctrl()
+ {
+ // Counterpart to the Shift test above: Ctrl is NOT a movement
+ // modifier, so Ctrl+W should be a different chord. Without an
+ // explicit (W, Ctrl) binding the action stays inactive — that's
+ // what makes Ctrl+F* / Ctrl+1-9 / etc. distinct from the bare
+ // F* / 1-9 chords.
+ var (dispatcher, kb, _, bindings) = Build();
+ bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
+
+ kb.EmitKeyDown(Key.W, ModifierMask.None);
+ Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
+
+ kb.CurrentModifiers = ModifierMask.Ctrl;
+ Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
+ }
+
[Fact]
public void IsActionHeld_does_not_check_WantCaptureMouse()
{