diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7c18f3a8..7210cbaa 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -643,13 +643,11 @@ primary motion + collision resolution. - Verification: side-by-side vs legacy default in 2-client setup, identical visible behavior. -## #38 — [DONE 2026-05-06 · (this commit)] Chase camera + player feel "30 fps" since L.5 physics-tick gate +## #38 — Chase camera + player feel "30 fps" since L.5 physics-tick gate -**Status:** DONE +**Status:** OPEN **Severity:** MEDIUM (gameplay-feel regression; not a correctness bug) **Filed:** 2026-05-01 -**Closed:** 2026-05-06 -**Commit:** `(this commit)` **Component:** rendering / physics / camera **Description:** User reports that running around in third-person / @@ -706,8 +704,7 @@ collision fixes.) **Acceptance:** - Chase-camera run-around at 60+ FPS feels as smooth as render rate - suggests (no perceptual stepping) — user visually confirmed - 2026-05-06. + suggests (no perceptual stepping) - Network outbound (MoveToState / AutonomousPosition cadence + values) unchanged from current behavior - Collision behavior unchanged (the L.5 wedge / steep-roof scenarios @@ -1363,11 +1360,10 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the --- -## #47 — [DONE 2026-05-06 · 0bd9b96] Humanoid Setup 0x02000001 renders bulky / lacks shape detail vs retail +## #47 — [DONE 2026-05-06] Humanoid Setup 0x02000001 renders bulky / lacks shape detail vs retail -**Status:** DONE +**Status:** DONE (commit pending) **Closed:** 2026-05-06 -**Commit:** `0bd9b96` **Severity:** MEDIUM (cosmetic — characters readable but visibly different from retail) **Filed:** 2026-05-06 **Component:** rendering / mesh / character animation @@ -1386,10 +1382,10 @@ Concrete swaps the resolver now performs: - Heritage variants: `0x010004BF → 0x010017A8`, `0x010004BD → 0x010017A7`, `0x010004B7 → 0x0100179A`, etc. -Fix landed as `GfxObjDegradeResolver`, default-on and scoped to humanoid -setups (34-part with ≥8 null-sentinel attachment slots). Set -`ACDREAM_RETAIL_CLOSE_DEGRADES=0` only for diagnostic before/after -comparisons. User confirmed visually 2026-05-06. +Fix landed as `GfxObjDegradeResolver`, gated behind +`ACDREAM_RETAIL_CLOSE_DEGRADES=1` and scoped to humanoid setups +(34-part with ≥8 null-sentinel attachment slots). User confirmed +visually 2026-05-06. Files: `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs`, `src/AcDream.App/Rendering/GameWindow.cs` (wiring), 5 unit tests in diff --git a/docs/research/2026-05-06-issue-38-render-interp-pseudocode.md b/docs/research/2026-05-06-issue-38-render-interp-pseudocode.md deleted file mode 100644 index 11c9b97f..00000000 --- a/docs/research/2026-05-06-issue-38-render-interp-pseudocode.md +++ /dev/null @@ -1,98 +0,0 @@ -# Issue #38 render interpolation pseudocode - -## Problem - -Phase L.5 correctly restored retail's `CPhysicsObj::update_object` -physics gate: the body only integrates when accumulated frame time reaches -`PhysicsBody.MinQuantum` (1/30 second). That keeps collision behavior aligned -with retail, but the renderer currently reads `_body.Position` directly. - -At 60+ FPS that means several rendered frames can show the same physics -position, then jump to the next 30 Hz position. Chase camera makes the stepping -obvious because both the player mesh and camera target follow that discrete -physics sample. - -## Evidence - -- Named retail: `CPhysicsObj::update_object` at `0x00515d10` - (`docs/research/named-retail/acclient_2013_pseudo_c.txt:283950`) skips or - consumes time outside the valid quantum window and calls - `CPhysicsObj::UpdateObjectInternal` only for accepted physics quanta. -- ACE mirrors the same constants in `PhysicsGlobals`: `MinQuantum = 1/30`, - `MaxQuantum = 0.1`, `HugeQuantum = 2.0`. -- ACE's `InterpolationManager` / `PositionManager` prove client-style - smoothing is a known AC-family concept, but their queue chases network target - positions. Issue #38 is narrower: local render-only interpolation between the - last two authoritative local physics ticks. -- Glenn Fiedler's "Fix Your Timestep!" describes the canonical fixed-timestep - accumulator plus render interpolation pattern: - https://gafferongames.com/post/fix_your_timestep/ - -## Intentional divergence - -Retail did not need a separate high-FPS render interpolation layer because the -2013 client effectively presented near the same cadence as its physics gate. -acdream renders at modern display rates, so we preserve retail's 30 Hz physics -truth but draw a blended visual position between the last two physics truths. - -## Pseudocode - -State stored by `PlayerMovementController`: - -```text -physicsAccum: leftover render time not yet consumed by physics -prevPhysicsPos: body position at the start of the most recently completed tick -currPhysicsPos: body position at the end of the most recently completed tick -``` - -On spawn, teleport, or any authoritative SetPosition: - -```text -body.Position = newPosition -prevPhysicsPos = newPosition -currPhysicsPos = newPosition -physicsAccum = 0 -``` - -Each Update(dt): - -```text -physicsAccum += dt - -if physicsAccum > HugeQuantum: - physicsAccum = 0 - prevPhysicsPos = body.Position - currPhysicsPos = body.Position - return/render body.Position - -if physicsAccum >= MinQuantum: - oldTickEnd = currPhysicsPos - preIntegratePos = body.Position - - tickDt = min(physicsAccum, MaxQuantum) - calc_acceleration() - UpdatePhysicsInternal(tickDt) - - postIntegratePos = body.Position - resolve collision from preIntegratePos to postIntegratePos - body.Position = resolved physics position - update contact/cell/velocity exactly as before - - prevPhysicsPos = oldTickEnd - currPhysicsPos = body.Position - physicsAccum -= tickDt - -alpha = clamp(physicsAccum / MinQuantum, 0, 1) -renderPosition = lerp(prevPhysicsPos, currPhysicsPos, alpha) -``` - -Important constraints: - -- Never write `renderPosition` back to `_body.Position`. -- `MovementResult.Position` remains the authoritative physics/collision/network - position. -- Add `MovementResult.RenderPosition` for mesh and camera drawing only. -- Outbound `MoveToState` / `AutonomousPosition` keep using - `MovementResult.Position`. -- If no physics tick has completed yet, `prevPhysicsPos == currPhysicsPos`, so - interpolation is a no-op. diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 1bc88b8b..a57ccd97 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -41,7 +41,6 @@ public readonly record struct MovementInput( /// public readonly record struct MovementResult( Vector3 Position, - Vector3 RenderPosition, uint CellId, bool IsOnGround, bool MotionStateChanged, @@ -129,7 +128,6 @@ public sealed class PlayerMovementController public float Yaw { get; set; } public Vector3 Position => _body.Position; - public Vector3 RenderPosition => ComputeRenderPosition(); public uint CellId { get; private set; } /// @@ -215,8 +213,6 @@ public sealed class PlayerMovementController // ACE: PhysicsObj.UpdateObject (Physics.cs). // Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950). private float _physicsAccum; - private Vector3 _prevPhysicsPos; - private Vector3 _currPhysicsPos; public PlayerMovementController(PhysicsEngine physics) { @@ -291,8 +287,6 @@ public sealed class PlayerMovementController public void SetPosition(Vector3 pos, uint cellId) { _body.Position = pos; - _prevPhysicsPos = pos; - _currPhysicsPos = pos; CellId = cellId; // Treat as grounded after a server-side position snap. @@ -301,13 +295,6 @@ public sealed class PlayerMovementController // Reset physics clock so any subsequent update_object calls start fresh. _body.LastUpdateTime = 0.0; - _physicsAccum = 0f; - } - - private Vector3 ComputeRenderPosition() - { - float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f); - return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); } public MovementResult Update(float dt, MovementInput input) @@ -319,7 +306,6 @@ public sealed class PlayerMovementController { return new MovementResult( Position: Position, - RenderPosition: RenderPosition, CellId: CellId, IsOnGround: _body.OnWalkable, MotionStateChanged: false, @@ -538,16 +524,12 @@ public sealed class PlayerMovementController // accumulated dt) when the threshold is reached. See _physicsAccum // declaration for the full retail trace evidence. var preIntegratePos = _body.Position; - bool physicsTickRan = false; - Vector3 oldTickEndPos = _currPhysicsPos; _physicsAccum += dt; if (_physicsAccum > PhysicsBody.HugeQuantum) { // Stale frame (debugger break, GC pause). Discard accumulated dt. _physicsAccum = 0f; - _prevPhysicsPos = _body.Position; - _currPhysicsPos = _body.Position; } else if (_physicsAccum >= PhysicsBody.MinQuantum) { @@ -557,7 +539,6 @@ public sealed class PlayerMovementController _body.calc_acceleration(); _body.UpdatePhysicsInternal(tickDt); _physicsAccum -= tickDt; - physicsTickRan = true; } // Else: dt below MinQuantum threshold — skip integration. Position // and velocity remain unchanged; Resolve below runs as a zero-distance @@ -610,11 +591,6 @@ public sealed class PlayerMovementController // Apply resolved position. _body.Position = resolveResult.Position; - if (physicsTickRan) - { - _prevPhysicsPos = oldTickEndPos; - _currPhysicsPos = _body.Position; - } // L.3a (2026-04-30): retail wall-bounce / velocity reflection. // @@ -898,7 +874,6 @@ public sealed class PlayerMovementController return new MovementResult( Position: Position, - RenderPosition: RenderPosition, CellId: CellId, IsOnGround: _body.OnWalkable, MotionStateChanged: changed, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6e666478..21e72ead 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -173,15 +173,15 @@ public sealed class GameWindow : IDisposable private static readonly int s_hidePartIndex = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_HIDE_PART"), out var hp) ? hp : -1; - // Issue #47 — use retail's close-detail GfxObj selection on + // Issue #47 — opt in to retail's close-detail GfxObj selection on // humanoid setups. When enabled, every per-part GfxObj id (after // server AnimPartChanges are applied) is replaced with Degrades[0] // from its DIDDegrade table when present. See GfxObjDegradeResolver - // for the full retail-decomp citation. Default-on after visual - // confirmation; set ACDREAM_RETAIL_CLOSE_DEGRADES=0 only for - // diagnostic before/after comparisons. + // for the full retail-decomp citation. Off by default while the fix + // bakes; flip to default-on once we've confirmed no scenery/setup + // regressions. private static readonly bool s_retailCloseDegrades = - !string.Equals(Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal); + string.Equals(Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CLOSE_DEGRADES"), "1", StringComparison.Ordinal); /// /// Issue #47 humanoid-setup detector. Matches Aluvian Male @@ -5728,7 +5728,7 @@ public sealed class GameWindow : IDisposable // the physics-resolved location each frame. if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) { - pe.Position = result.RenderPosition; + pe.Position = result.Position; pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); @@ -5750,7 +5750,7 @@ public sealed class GameWindow : IDisposable // position never changes. With the pin: player visibly // rises above the camera, matching retail "you can see // yourself jump" feedback. - _chaseCamera.Update(result.RenderPosition, _playerController.Yaw, + _chaseCamera.Update(result.Position, _playerController.Yaw, isOnGround: result.IsOnGround, dt: (float)dt); diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index 8a09b79f..fe1e859a 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -49,81 +49,6 @@ public class PlayerMovementControllerTests Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward"); } - [Fact] - public void Update_SubQuantumFrame_InterpolatesRenderPositionWithoutAdvancingPhysicsPosition() - { - var engine = MakeFlatEngine(); - var controller = new PlayerMovementController(engine); - var start = new Vector3(96f, 96f, 50f); - controller.SetPosition(start, 0x0001); - controller.Yaw = 0f; - - var firstTick = controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); - Assert.True(firstTick.Position.X > start.X, "Physics tick should advance the authoritative body position"); - Assert.Equal(start.X, firstTick.RenderPosition.X, precision: 4); - - var halfFrame = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true)); - - Assert.Equal(firstTick.Position.X, halfFrame.Position.X, precision: 4); - Assert.True(halfFrame.RenderPosition.X > start.X, "Render position should move between physics ticks"); - Assert.True(halfFrame.RenderPosition.X < firstTick.Position.X, - $"Render X={halfFrame.RenderPosition.X} should stay between {start.X} and {firstTick.Position.X}"); - - float expectedMidpoint = start.X + ((firstTick.Position.X - start.X) * 0.5f); - Assert.Equal(expectedMidpoint, halfFrame.RenderPosition.X, precision: 3); - } - - [Fact] - public void SetPosition_ResnapsRenderInterpolationEndpoints() - { - var engine = MakeFlatEngine(); - var controller = new PlayerMovementController(engine); - controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); - controller.Yaw = 0f; - - controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); - controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true)); - - var snapped = new Vector3(120f, 80f, 50f); - controller.SetPosition(snapped, 0x0001); - var result = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput()); - - Assert.Equal(snapped, result.Position); - Assert.Equal(snapped, result.RenderPosition); - } - - [Fact] - public void Update_HugeQuantumDiscard_ResnapsRenderInterpolationEndpoints() - { - var engine = MakeFlatEngine(); - var controller = new PlayerMovementController(engine); - controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); - controller.Yaw = 0f; - - var moved = controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); - var stale = controller.Update(PhysicsBody.HugeQuantum + 0.1f, new MovementInput(Forward: true)); - - Assert.Equal(moved.Position.X, stale.Position.X, precision: 4); - Assert.Equal(stale.Position, stale.RenderPosition); - } - - [Fact] - public void Update_LeftoverAboveMinQuantum_ClampsRenderAlphaToCurrentPhysicsPosition() - { - var engine = MakeFlatEngine(); - var controller = new PlayerMovementController(engine); - controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); - controller.Yaw = 0f; - - var result = controller.Update( - PhysicsBody.MaxQuantum + PhysicsBody.MinQuantum, - new MovementInput(Forward: true)); - - Assert.Equal(result.Position.X, result.RenderPosition.X, precision: 4); - Assert.Equal(result.Position.Y, result.RenderPosition.Y, precision: 4); - Assert.Equal(result.Position.Z, result.RenderPosition.Z, precision: 4); - } - [Fact] public void Update_RunForward_MoveFasterThanWalk() {