diff --git a/src/AcDream.App/Input/CameraPointerInputController.cs b/src/AcDream.App/Input/CameraPointerInputController.cs
index 9104323f..78ddb898 100644
--- a/src/AcDream.App/Input/CameraPointerInputController.cs
+++ b/src/AcDream.App/Input/CameraPointerInputController.cs
@@ -489,8 +489,9 @@ internal sealed class CameraPointerInputController
rawY: dy,
weight: 0.5f,
nowSec: _clock.NowSeconds);
- _chase.Retail.YawOffset -= filteredDx * 0.004f * sensitivity;
- _chase.Retail.AdjustPitch(filteredDy * 0.003f * sensitivity);
+ const float retailMouseScale = 0.0666666701f;
+ _chase.Retail.AdjustYaw(-filteredDx * sensitivity * retailMouseScale);
+ _chase.Retail.AdjustPitch(filteredDy * sensitivity * retailMouseScale);
}
else
{
diff --git a/src/AcDream.App/Input/MouseLookController.cs b/src/AcDream.App/Input/MouseLookController.cs
index d3b28f79..7606ef98 100644
--- a/src/AcDream.App/Input/MouseLookController.cs
+++ b/src/AcDream.App/Input/MouseLookController.cs
@@ -188,7 +188,7 @@ internal sealed class MouseLookController : IMouseLookInputFrameController
: (rawX, rawY);
_state.ApplyDelta(filteredX, _chase.Sensitivity);
if (_chase.Retail is { } retailCamera)
- retailCamera.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity);
+ retailCamera.AdjustPitch(filteredY * 0.0666666701f * _chase.Sensitivity);
else
_chase.Legacy?.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity);
}
diff --git a/src/AcDream.App/Rendering/CameraFrameController.cs b/src/AcDream.App/Rendering/CameraFrameController.cs
index a0cb3202..0150ceb1 100644
--- a/src/AcDream.App/Rendering/CameraFrameController.cs
+++ b/src/AcDream.App/Rendering/CameraFrameController.cs
@@ -82,13 +82,13 @@ internal sealed class CameraFrameController : ICameraFramePhase
if (input.ZoomOut)
retail.AdjustDistance(+adjustment);
if (input.Raise)
- retail.AdjustPitch(+adjustment * 0.02f);
+ retail.AdjustPitch(+adjustment);
if (input.Lower)
- retail.AdjustPitch(-adjustment * 0.02f);
+ retail.AdjustPitch(-adjustment);
if (input.RotateLeft)
- retail.YawOffset += adjustment * 0.02f;
+ retail.AdjustYaw(+adjustment);
if (input.RotateRight)
- retail.YawOffset -= adjustment * 0.02f;
+ retail.AdjustYaw(-adjustment);
}
else
{
diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs
index b227fc60..fb02610b 100644
--- a/src/AcDream.App/Rendering/RetailChaseCamera.cs
+++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs
@@ -34,6 +34,18 @@ public sealed class RetailChaseCamera : ICamera
private const float RetailLookDownBack = 2f;
private const float RetailMapBack = 450f;
private const float RetailFirstPersonForward = 0.18f;
+ private const float RetailLeaveHeadBack = 0.6f;
+ private const float RetailLeaveHeadUp = 0.5f;
+
+ // CameraSet::{Farther,Closer,Raise,Lower,Rotate} constants. The named
+ // retail decomp carries CAMERA_MOUSELOOK_INC at 0x0079BC04 and the
+ // eight-degree offset angle initialised at 0x006EABF0/0x0083D034.
+ internal const float OffsetScalePerAdjustment = 0.200000003f;
+ internal const float OffsetAngleRadians = 8f * MathF.PI / 180f;
+ internal const float MinimumOffsetLength = 0.5f;
+ internal const float MaximumHorizontalComponent = 10f;
+ internal const float MaximumVerticalComponent = 450f;
+ internal const float MinimumVerticalComponent = -1.8f;
// ICamera surface.
public Vector3 Position { get; private set; }
@@ -106,12 +118,6 @@ public sealed class RetailChaseCamera : ICamera
/// Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow.
public float PlayerTranslucency { get; private set; }
- /// Clamp bounds carried over from legacy ChaseCamera.
- public const float DistanceMin = 2f;
- public const float DistanceMax = 40f;
- public const float PitchMin = -0.7f;
- public const float PitchMax = 1.4f;
-
// Retail CameraManager::UpdateCamera convergence-snap thresholds (decomp
// acclient_2013_pseudo_c.txt, 0x00456fcd–0x00457035). SnapEpsilon = 2 ×
// 0.000199999995 m ≈ 0.0004 m — the per-frame translation step below which retail
@@ -311,27 +317,80 @@ public sealed class RetailChaseCamera : ICamera
}
///
- /// Adjust the camera distance (zoom) by a delta, clamped to
- /// ... Mirrors
- /// legacy ChaseCamera.AdjustDistance.
+ /// Apply one retail camera-adjustment unit to the viewer offset.
+ /// CameraSet::Farther/Closer (0x00458890/0x004586D0)
+ /// scale all three offset components multiplicatively by
+ /// 1 + adjustment * 0.2. Positive values move farther and
+ /// negative values move closer. Retail refuses the complete write when
+ /// any component or the 0.5 m near limit would be crossed; it does not
+ /// clamp an invented scalar distance.
///
- public void AdjustDistance(float delta)
+ public void AdjustDistance(float adjustment)
{
ExitLookDownForAdjustment();
- ExitInHeadForAdjustment();
- Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
+ if (!float.IsFinite(adjustment) || adjustment == 0f)
+ return;
+
+ // Farther's exact in-head branch replaces (0,+0.18,0) with
+ // (0,-0.6,+0.5) and returns without applying the ordinary scale.
+ if (_inHead)
+ {
+ _inHead = false;
+ SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp);
+ return;
+ }
+
+ float scale = 1f + adjustment * OffsetScalePerAdjustment;
+ if (!(scale > 0f) || !float.IsFinite(scale))
+ return;
+
+ float candidateDistance = Distance * scale;
+ if (adjustment < 0f && !(candidateDistance > MinimumOffsetLength))
+ return;
+
+ TryWriteViewerOffset(candidateDistance, Pitch, preserveHorizontalSign: false);
}
///
- /// Adjust the camera pitch by a delta (radians), clamped to
- /// ... Mirrors legacy
- /// ChaseCamera.AdjustPitch.
+ /// Apply one retail camera-adjustment unit to the viewer-offset pitch.
+ /// CameraSet::Raise/Lower (0x00457980/0x00457CF0)
+ /// rotate the offset by eight degrees per unit while preserving its
+ /// length. A write that would cross the vertical axis or a component
+ /// limit is refused as a whole.
///
- public void AdjustPitch(float delta)
+ public void AdjustPitch(float adjustment)
{
ExitLookDownForAdjustment();
- ExitInHeadForAdjustment();
- Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
+ if (!float.IsFinite(adjustment) || adjustment == 0f)
+ return;
+
+ if (_inHead)
+ {
+ _inHead = false;
+ SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp);
+ }
+
+ float candidatePitch = Pitch + adjustment * OffsetAngleRadians;
+ TryWriteViewerOffset(Distance, candidatePitch, preserveHorizontalSign: true);
+ }
+
+ ///
+ /// Rotate the viewer offset around local Z using retail's eight-degree
+ /// adjustment unit (CameraSet::Rotate @ 0x00458310).
+ ///
+ public void AdjustYaw(float adjustment)
+ {
+ ExitLookDownForAdjustment();
+ if (!float.IsFinite(adjustment) || adjustment == 0f)
+ return;
+
+ if (_inHead)
+ {
+ _inHead = false;
+ SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp);
+ }
+
+ YawOffset += adjustment * OffsetAngleRadians;
}
public void SetRetailDefaultView()
@@ -416,20 +475,52 @@ public sealed class RetailChaseCamera : ICamera
RestoreLookDownView();
}
- private void ExitInHeadForAdjustment()
- {
- if (!_inHead)
- return;
- _inHead = false;
- Distance = DistanceMin;
- }
-
private void SetViewerOffset(float back, float up)
{
Distance = MathF.Sqrt(back * back + up * up);
Pitch = MathF.Atan2(up, back);
}
+ private bool TryWriteViewerOffset(
+ float candidateDistance,
+ float candidatePitch,
+ bool preserveHorizontalSign)
+ {
+ if (!float.IsFinite(candidateDistance)
+ || !float.IsFinite(candidatePitch)
+ || !(candidateDistance > 0f))
+ {
+ return false;
+ }
+
+ float currentHorizontal = Distance * MathF.Cos(Pitch);
+ float candidateHorizontal = candidateDistance * MathF.Cos(candidatePitch);
+ if (preserveHorizontalSign
+ && ((currentHorizontal > 0f && candidateHorizontal < 0f)
+ || (currentHorizontal < 0f && candidateHorizontal > 0f)))
+ {
+ return false;
+ }
+
+ // AC frame-local viewer_offset. Only magnitudes matter for Farther's
+ // X/Y limits, but including YawOffset is essential: at diagonal orbit
+ // angles retail can reach a longer boom before either component is 10.
+ float x = candidateHorizontal * MathF.Sin(YawOffset);
+ float y = -candidateHorizontal * MathF.Cos(YawOffset);
+ float z = candidateDistance * MathF.Sin(candidatePitch);
+ if (!(MathF.Abs(x) < MaximumHorizontalComponent)
+ || !(MathF.Abs(y) < MaximumHorizontalComponent)
+ || !(z < MaximumVerticalComponent)
+ || !(z > MinimumVerticalComponent))
+ {
+ return false;
+ }
+
+ Distance = candidateDistance;
+ Pitch = candidatePitch;
+ return true;
+ }
+
///
/// Public entry point for the mouse-input low-pass filter. Calls
/// on each axis with shared state.
diff --git a/src/AcDream.Core/Rendering/CameraDiagnostics.cs b/src/AcDream.Core/Rendering/CameraDiagnostics.cs
index 02a8d448..0c8725d9 100644
--- a/src/AcDream.Core/Rendering/CameraDiagnostics.cs
+++ b/src/AcDream.Core/Rendering/CameraDiagnostics.cs
@@ -73,8 +73,9 @@ public static class CameraDiagnostics
///
/// Per-second rate that held-key offset adjustments
- /// (CameraZoomIn/Out, CameraRaise/Lower) integrate into the
- /// camera's Distance / Pitch. Retail default 40.0.
+ /// (CameraZoomIn/Out, CameraRaise/Lower) feed into retail's
+ /// multiplicative zoom / eight-degree offset rotation. Retail
+ /// CameraManager::CameraManager @ 0x0045710A sets 40.0.
///
public static float CameraAdjustmentSpeed { get; set; } = 40.0f;
}
diff --git a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs
index bb2bd06f..9e2e0a7e 100644
--- a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs
@@ -177,7 +177,7 @@ public sealed class CameraPointerInputControllerTests
}
[Fact]
- public void ChaseMouseWheel_RetainsExtendedZoomOutRange()
+ public void ChaseMouseWheel_UsesRetailMultiplicativeZoomAndComponentLimit()
{
var fixture = Create([new RawSurface()]);
var legacy = new ChaseCamera();
@@ -187,21 +187,24 @@ public sealed class CameraPointerInputControllerTests
fixture.Chase.Retail = retail;
fixture.Camera.EnterChaseMode(legacy, retail);
- float before = CameraDiagnostics.UseRetailChaseCamera
- ? retail.Distance
- : legacy.Distance;
- fixture.Owner.HandleScroll(InputAction.ScrollDown);
- float afterOne = CameraDiagnostics.UseRetailChaseCamera
- ? retail.Distance
- : legacy.Distance;
- for (int i = 0; i < 100; i++)
+ bool savedRetail = CameraDiagnostics.UseRetailChaseCamera;
+ try
+ {
+ CameraDiagnostics.UseRetailChaseCamera = true;
+ float before = retail.Distance;
fixture.Owner.HandleScroll(InputAction.ScrollDown);
- float afterMany = CameraDiagnostics.UseRetailChaseCamera
- ? retail.Distance
- : legacy.Distance;
+ float afterOne = retail.Distance;
+ for (int i = 0; i < 100; i++)
+ fixture.Owner.HandleScroll(InputAction.ScrollDown);
- Assert.Equal(before + 0.8f, afterOne, 5);
- Assert.Equal(40f, afterMany, 5);
+ Assert.Equal(before * (1f + 0.8f * RetailChaseCamera.OffsetScalePerAdjustment), afterOne, 5);
+ Assert.True(retail.Distance < 10.5f);
+ Assert.True(retail.Distance > 8f);
+ }
+ finally
+ {
+ CameraDiagnostics.UseRetailChaseCamera = savedRetail;
+ }
}
private static Fixture Create(IReadOnlyList surfaces)
diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
index 9cba7c08..7f052be6 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
@@ -537,14 +537,50 @@ public class RetailChaseCameraTests
}
[Fact]
- public void AdjustDistance_ClampsToRange()
+ public void AdjustDistance_UsesRetailMultiplicativeScale()
{
var cam = new RetailChaseCamera { Distance = 5f };
- cam.AdjustDistance(-100f);
- Assert.Equal(RetailChaseCamera.DistanceMin, cam.Distance);
+ cam.AdjustDistance(+1f);
+ Assert.Equal(6f, cam.Distance, 5);
- cam.AdjustDistance(+200f);
- Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance);
+ cam.AdjustDistance(-1f);
+ Assert.Equal(4.8f, cam.Distance, 5);
+ }
+
+ [Fact]
+ public void AdjustDistance_RefusesWholeWriteAtRetailComponentAndNearLimits()
+ {
+ var far = new RetailChaseCamera
+ {
+ Distance = 9.9f,
+ Pitch = 0f,
+ YawOffset = 0f,
+ };
+ far.AdjustDistance(+1f);
+ Assert.Equal(9.9f, far.Distance, 5);
+
+ var near = new RetailChaseCamera
+ {
+ Distance = 0.55f,
+ Pitch = 0f,
+ };
+ near.AdjustDistance(-1f);
+ Assert.Equal(0.55f, near.Distance, 5);
+ }
+
+ [Fact]
+ public void AdjustDistance_DiagonalOrbitUsesIndependentRetailXAndYLimits()
+ {
+ var cam = new RetailChaseCamera
+ {
+ Distance = 12f,
+ Pitch = 0f,
+ YawOffset = MathF.PI / 4f,
+ };
+
+ cam.AdjustDistance(+0.5f);
+
+ Assert.Equal(13.2f, cam.Distance, 5);
}
[Fact]
@@ -579,18 +615,37 @@ public class RetailChaseCameraTests
cam.AdjustDistance(1f);
Assert.False(cam.IsInHead);
- Assert.Equal(RetailChaseCamera.DistanceMin + 1f, cam.Distance);
+ Assert.Equal(MathF.Sqrt(0.6f * 0.6f + 0.5f * 0.5f), cam.Distance, 5);
}
[Fact]
- public void AdjustPitch_ClampsToRange()
+ public void AdjustPitch_UsesRetailEightDegreeRotationAndPreservesLength()
{
- var cam = new RetailChaseCamera { Pitch = 0f };
- cam.AdjustPitch(-10f);
- Assert.Equal(RetailChaseCamera.PitchMin, cam.Pitch);
+ var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
+ cam.AdjustPitch(+1f);
- cam.AdjustPitch(+10f);
- Assert.Equal(RetailChaseCamera.PitchMax, cam.Pitch);
+ Assert.Equal(RetailChaseCamera.OffsetAngleRadians, cam.Pitch, 6);
+ Assert.Equal(5f, cam.Distance, 6);
+ }
+
+ [Fact]
+ public void AdjustPitch_RefusesWholeWriteBelowRetailVerticalLimit()
+ {
+ var cam = new RetailChaseCamera { Distance = 3f, Pitch = -0.55f };
+ float before = cam.Pitch;
+
+ cam.AdjustPitch(-1f);
+
+ Assert.Equal(before, cam.Pitch);
+ Assert.Equal(3f, cam.Distance);
+ }
+
+ [Fact]
+ public void AdjustYaw_UsesRetailEightDegreeRotationUnit()
+ {
+ var cam = new RetailChaseCamera();
+ cam.AdjustYaw(+1f);
+ Assert.Equal(RetailChaseCamera.OffsetAngleRadians, cam.YawOffset, 6);
}
// ── Camera collision (A8.F) ───────────────────────────────────────