fix(physics): #30 #34 L.2a movement truth diagnostics

Pass explicit grounded/airborne contact bytes from MovementResult into MoveToState and AutonomousPosition, and add ACDREAM_DUMP_MOVE_TRUTH logging for outbound movement plus player UpdatePosition echoes.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-04-29 21:52:53 +02:00
parent d4c3f947d2
commit 3be0c8b7c7
5 changed files with 166 additions and 56 deletions

View file

@ -177,33 +177,6 @@ missing is the plugin-API surface.
---
## #30 — AutonomousPosition contact byte is too often grounded
**Status:** OPEN
**Severity:** HIGH
**Filed:** 2026-04-29
**Component:** physics / net / movement
**Description:** Outbound movement can claim grounded contact even when the
local resolver result is uncertain or airborne. `AutonomousPosition.Build`
defaults `lastContact` to 1, and the app path needs an audit to ensure
`ResolveResult.IsOnGround` is what reaches the wire.
**Root cause / status:** Tracked under Phase L.2b. This can make ACE accept
movement that is not actually retail-valid and can hide edge/step-down bugs.
**Files:** `src/AcDream.Core.Net/Messages/AutonomousPosition.cs`,
`src/AcDream.Core.Net/Messages/MoveToState.cs`,
`src/AcDream.App/Input/PlayerMovementController.cs`.
**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`.
**Acceptance:** Ground contact byte is derived from the current resolved
movement result for both autonomous heartbeat and movement-state sends. Tests
cover grounded, airborne, and failed-transition cases.
---
## #31 — Low outdoor cell id can go stale after transition movement
**Status:** OPEN
@ -285,33 +258,6 @@ one live creature case no longer use the single-cylinder fallback.
---
## #34 — Missing routine local/server correction diagnostic
**Status:** OPEN
**Severity:** MEDIUM
**Filed:** 2026-04-29
**Component:** physics / net / diagnostics
**Description:** The client needs an opt-in diagnostic that logs local predicted
position/contact/cell, outbound movement fields, server `UpdatePosition` echo,
and correction delta. Current correction visibility is too focused on portal
arrival and not enough on normal walking.
**Root cause / status:** Tracked under Phase L.2a/L.2b. Without this probe,
ACE's tolerance can hide local collision divergence.
**Files:** `src/AcDream.Core.Net/WorldSession.cs`,
`src/AcDream.App/Input/PlayerMovementController.cs`,
`src/AcDream.App/Rendering/GameWindow.cs`.
**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`.
**Acceptance:** With the diagnostic enabled, a walking session logs local
resolved placement, outbound cell/contact fields, server echo placement, and
correction delta in a grep-friendly format.
---
## #2 — Lightning visual mismatch (sky PES path disproved)
@ -494,6 +440,28 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the
# Recently closed
## #34 — [DONE 2026-04-29] Missing routine local/server correction diagnostic
**Closed:** 2026-04-29
**Commit:** `(this commit)`
**Resolution:** Added `ACDREAM_DUMP_MOVE_TRUTH=1`, which logs local resolved
position/contact/cell, outbound movement fields, server `UpdatePosition` echo,
and local/server correction delta for the player in grep-friendly
`move-truth OUT` / `move-truth ECHO` lines.
---
## #30 — [DONE 2026-04-29] AutonomousPosition contact byte is too often grounded
**Closed:** 2026-04-29
**Commit:** `(this commit)`
**Resolution:** `GameWindow` now derives the movement contact byte from
`MovementResult.IsOnGround` and passes it explicitly to both `MoveToState.Build`
and `AutonomousPosition.Build`. Added packet tests proving both builders encode
an explicit airborne contact byte.
---
## #27 — [DONE 2026-04-26] Cloud meshes appeared missing or faint vs retail
**Closed:** 2026-04-26

View file

@ -47,3 +47,11 @@ InputDispatcher / PlayerMovementController
ownership.
- L.1 animation work must coordinate with L.2 when root motion or observer
movement changes the predicted body path.
## Shipped Slices
- 2026-04-29: L.2a/L.2b first diagnostic slice. `ACDREAM_DUMP_MOVE_TRUTH=1`
logs `move-truth OUT` for outbound `MoveToState` / `AutonomousPosition` and
`move-truth ECHO` for player `UpdatePosition` echoes, including local/server
delta. `GameWindow` now passes explicit grounded/airborne contact bytes from
`MovementResult.IsOnGround` to both movement packet builders.

View file

@ -409,6 +409,8 @@ public sealed class GameWindow : IDisposable
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
private static readonly bool DevToolsEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1";
private static readonly bool DumpMoveTruthEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOVE_TRUTH") == "1";
// Phase I.3 — real ICommandBus for live sessions. Constructed when
// the live session spins up (so SendChatCmd handlers can close over
@ -464,6 +466,19 @@ public sealed class GameWindow : IDisposable
private uint? _playerCurrentAnimCommand;
private float _playerCurrentAnimSpeed = 1f;
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
private MovementTruthOutbound? _lastMovementTruthOutbound;
private readonly record struct MovementTruthOutbound(
string Kind,
uint Sequence,
System.DateTime TimeUtc,
System.Numerics.Vector3 LocalWorldPosition,
uint LocalCellId,
System.Numerics.Vector3 WirePosition,
uint WireCellId,
bool IsOnGround,
byte ContactByte,
System.Numerics.Vector3 Velocity);
// K-fix7 (2026-04-26): server-authoritative Run + Jump skill values
// received from PlayerDescription. -1 = "not yet received, fall back
@ -3076,6 +3091,7 @@ public sealed class GameWindow : IDisposable
0f);
var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
DumpMovementTruthServerEcho(update, worldPos);
// Capture the pre-update render position for the soft-snap residual
// calculation below. Assign entity.Position to the server truth up
@ -4853,6 +4869,7 @@ public sealed class GameWindow : IDisposable
uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (result.CellId & 0xFFFFu);
var wirePos = new System.Numerics.Vector3(localX, localY, result.Position.Z);
var wireRot = YawToAcQuaternion(_playerController.Yaw);
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
if (result.MotionStateChanged)
{
@ -4885,7 +4902,10 @@ public sealed class GameWindow : IDisposable
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
forcePositionSequence: _liveSession.ForcePositionSequence);
forcePositionSequence: _liveSession.ForcePositionSequence,
contactLongJump: contactByte);
DumpMovementTruthOutbound(
"MTS", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
}
@ -4900,7 +4920,10 @@ public sealed class GameWindow : IDisposable
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
forcePositionSequence: _liveSession.ForcePositionSequence);
forcePositionSequence: _liveSession.ForcePositionSequence,
lastContact: contactByte);
DumpMovementTruthOutbound(
"AP", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
}
@ -4924,6 +4947,76 @@ public sealed class GameWindow : IDisposable
}
}
private void DumpMovementTruthOutbound(
string kind,
uint sequence,
AcDream.App.Input.MovementResult result,
System.Numerics.Vector3 wirePosition,
uint wireCellId,
byte contactByte)
{
if (!DumpMoveTruthEnabled) return;
var velocity = _playerController?.BodyVelocity ?? System.Numerics.Vector3.Zero;
_lastMovementTruthOutbound = new MovementTruthOutbound(
kind,
sequence,
System.DateTime.UtcNow,
result.Position,
result.CellId,
wirePosition,
wireCellId,
result.IsOnGround,
contactByte,
velocity);
Console.WriteLine(System.FormattableString.Invariant($"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}"));
}
private void DumpMovementTruthServerEcho(
AcDream.Core.Net.WorldSession.EntityPositionUpdate update,
System.Numerics.Vector3 serverWorldPosition)
{
if (!DumpMoveTruthEnabled || update.Guid != _playerServerGuid) return;
var now = System.DateTime.UtcNow;
var localPosition = _playerController?.Position;
var localCellId = _playerController?.CellId;
var deltaLocal = localPosition.HasValue
? serverWorldPosition - localPosition.Value
: (System.Numerics.Vector3?)null;
string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-";
string localCellText = localCellId.HasValue
? System.FormattableString.Invariant($"0x{localCellId.Value:X8}")
: "-";
string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-";
string deltaLocalLen = deltaLocal.HasValue
? System.FormattableString.Invariant($"{deltaLocal.Value.Length():F3}")
: "-";
string lastText = "-";
if (_lastMovementTruthOutbound is { } last)
{
var deltaOut = serverWorldPosition - last.LocalWorldPosition;
var ageMs = (now - last.TimeUtc).TotalMilliseconds;
lastText = System.FormattableString.Invariant($"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}");
}
string state = _playerController?.State.ToString() ?? "-";
string velocityText = update.Velocity.HasValue ? Fmt(update.Velocity.Value) : "-";
Console.WriteLine(System.FormattableString.Invariant($"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLen} serverVel={velocityText} state={state} lastOut={lastText}"));
}
private static string Fmt(System.Numerics.Vector3 v) =>
System.FormattableString.Invariant($"({v.X:F3},{v.Y:F3},{v.Z:F3})");
private static string FmtCmd(uint? command) =>
command.HasValue
? System.FormattableString.Invariant($"0x{command.Value:X8}")
: "-";
/// <summary>
/// Convert our internal yaw (math convention: 0=+X East, PI/2=+Y North)
/// to AC's quaternion heading convention.

View file

@ -105,6 +105,23 @@ public class AutonomousPositionTests
Assert.Equal(56, body.Length);
}
[Fact]
public void Build_UsesExplicitAirborneContactByte()
{
var body = AutonomousPosition.Build(
gameActionSequence: 7,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
lastContact: 0);
Assert.Equal(0, body[52]);
}
[Fact]
public void Build_ContainsIdentityRotation_AfterPosition()
{

View file

@ -142,6 +142,30 @@ public class MoveToStateTests
Assert.Equal(0, body.Length % 4);
}
[Fact]
public void Build_UsesExplicitAirborneContactByte()
{
var body = MoveToState.Build(
gameActionSequence: 7,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contactLongJump: 0);
Assert.Equal(0, body[56]);
}
[Fact]
public void Build_WithHoldKey_IncludesHoldKeyFlag()
{