The two gates that decide whether an accepted remote Position is interpolated
or hard-snapped read `Airborne`, which is `!Body.OnWalkable` — WALKABILITY.
Retail reads CONTACT: InterpolationManager::adjust_offset @0x00555D30 gates its
entire body on `transient_state & 1` @0x00555D52, so a retail body in contact
with a non-walkable face still interpolates.
The two predicates disagree in exactly one state — in contact, not on walkable
ground — which 204d0ae0 turned from unreachable into ordinary. Before it, the
per-tick forge made every non-airborne remote walkable by construction, so the
disagreement could not occur.
Both gates now read `!Body.InContact`: ApplyRemoteContactRouting's flight
carve-out and OnPosition's player-remote arm.
`Airborne` is deliberately NOT re-derived from CONTACT. That would perturb all
five of its writers and contradict a pinned assertion in
RemoteTeleportPlacementTests.Apply_PendingGroundToSteepContact_ (InContact:
true, OnWalkable: false -> Assert.True(remote.Airborne)); a previous
implementer attempted it and correctly backed out rather than editing the
assertion. This narrower shape touches no existing test.
AP-140's register row is retired in this commit, as the row itself specified.
Honest scope: this is a faithfulness fix, not a visible one. ACE derives its
IsGrounded flag with the same floor_z test, so during a slide it almost
certainly reports not-grounded, the classifier returns NoPositionOperation, and
neither arm is taken. Expect no observable change against ACE.
Suite 11,027 passed / 4 skipped / 0 failed (baseline 11,023).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
9.2 KiB
C#
232 lines
9.2 KiB
C#
using System.Numerics;
|
|
using AcDream.Content;
|
|
using AcDream.Content.Pak;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Physics;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.App.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// C4 route 4b-2: a REAL
|
|
/// <see cref="RuntimeRemotePlacementDriveController"/> over a bare
|
|
/// <see cref="RuntimeEntityObjectLifetime"/>, so App-layer acceptance tests
|
|
/// drive the production far-snap path end to end instead of simulating it.
|
|
/// Mirrors <c>RuntimeRemotePlacementDriveControllerTests</c>' own fixture.
|
|
/// </summary>
|
|
internal sealed class RemotePlacementDriveFixture : IDisposable
|
|
{
|
|
internal const uint SourceLandblock = 0xB1000000u;
|
|
internal const uint SourceCell = SourceLandblock | 0x0001u;
|
|
internal const uint DestinationLandblock = 0xB2000000u;
|
|
internal const uint DestinationCell = DestinationLandblock | 0x0001u;
|
|
/// <summary>The +X world offset <see cref="PublishDestinationCollision"/>
|
|
/// gives the destination landblock, so a committed placement's world
|
|
/// position is the authored local position plus this.</summary>
|
|
internal static readonly Vector3 DestinationWorldOffset = new(192f, 0f, 0f);
|
|
internal const float SpawnHeight = 7f;
|
|
|
|
private readonly ServiceWindow _window = new();
|
|
|
|
internal RemotePlacementDriveFixture()
|
|
{
|
|
Lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
|
Drive = new RuntimeRemotePlacementDriveController(
|
|
Lifetime,
|
|
new GameRuntimeClock(),
|
|
new UnusedCollisionSource(),
|
|
_window);
|
|
}
|
|
|
|
internal RuntimeEntityObjectLifetime Lifetime { get; }
|
|
|
|
internal RuntimeRemotePlacementDriveController Drive { get; }
|
|
|
|
internal void AllowDestination() => _window.Allow(DestinationLandblock);
|
|
|
|
/// <summary>
|
|
/// Commits the destination landblock's collision generation and observes
|
|
/// the source world frame once, so a placement into
|
|
/// <see cref="DestinationCell"/> can actually resolve.
|
|
/// </summary>
|
|
internal void PublishDestinationCollision()
|
|
{
|
|
var heights = new byte[81];
|
|
Array.Fill(heights, (byte)SpawnHeight);
|
|
var heightTable = new float[256];
|
|
for (int index = 0; index < heightTable.Length; index++)
|
|
heightTable[index] = index;
|
|
Lifetime.Physics.ObserveLocalWorldFrame(
|
|
SourceCell, teleportAdvanced: false);
|
|
Lifetime.Physics.SetPosition.BeginCollisionGeneration(
|
|
DestinationLandblock, 1UL);
|
|
Lifetime.Physics.Engine.AddLandblock(
|
|
DestinationLandblock,
|
|
new TerrainSurface(heights, heightTable),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
worldOffsetX: DestinationWorldOffset.X,
|
|
worldOffsetY: DestinationWorldOffset.Y);
|
|
Lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
|
DestinationLandblock, 1UL, ready: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers one remote incarnation with a canonical body, its shared
|
|
/// <see cref="RemoteMotion"/>, and the accepted destination already merged
|
|
/// onto its snapshot — exactly the state
|
|
/// <c>RuntimeEntityObjectLifetime.TryApplyPosition</c> leaves behind
|
|
/// before <c>OnPosition</c> routes the packet.
|
|
/// </summary>
|
|
internal (RuntimeEntityRecord Record, RemoteMotion Remote, PhysicsBody Body)
|
|
AddRemote(uint guid, Vector3 destination)
|
|
{
|
|
RuntimeEntityRecord record = Lifetime.RegisterEntity(
|
|
Spawn(guid)).Canonical!;
|
|
Lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity);
|
|
Lifetime.Entities.SetFullCell(
|
|
record, SourceCell, (SourceCell & 0xFFFF0000u) | 0xFFFFu);
|
|
var body = new PhysicsBody
|
|
{
|
|
Position = new Vector3(10f, 10f, SpawnHeight),
|
|
Orientation = Quaternion.Identity,
|
|
LastUpdateTime = 1d,
|
|
State = PhysicsStateFlags.Gravity,
|
|
// AP-140 (retired 2026-08-04): the remote stands on the flat
|
|
// source landblock, so the sweep's SetPositionInternal commit
|
|
// would derive CONTACT (`contact_plane_valid` @0x00515430) and
|
|
// ON_WALKABLE (`contact_plane.N.z >= floor_z`
|
|
// @0x00515465-0x0051548E) for it. Both bits now have to be
|
|
// present, because the accepted-Position routing gates read
|
|
// retail's CONTACT predicate
|
|
// (`InterpolationManager::adjust_offset` @0x00555D52) rather than
|
|
// the client `Airborne` walkability flag: a body with a bare
|
|
// `Active` transient state is in FREE FLIGHT and every routing
|
|
// test would take the free-flight snap arm. Tests that want free
|
|
// flight clear these explicitly.
|
|
TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact
|
|
| TransientStateFlags.OnWalkable,
|
|
};
|
|
body.SnapToCell(SourceCell, body.Position, body.Position);
|
|
Lifetime.Entities.SetPhysicsBody(record, body);
|
|
record.ObjectClock.Activate();
|
|
Lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
|
|
record.Snapshot = record.Snapshot with
|
|
{
|
|
Position = new CreateObject.ServerPosition(
|
|
DestinationCell,
|
|
destination.X,
|
|
destination.Y,
|
|
destination.Z,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0f),
|
|
};
|
|
RemoteMotion remote = Lifetime.Physics.GetOrCreateRemoteMotion(record);
|
|
// Past AP-87's firstUp hint, so the 4 m body-to-target guard is the
|
|
// condition under test rather than the first-sample one.
|
|
remote.LastServerPosTime = 1_700_000_000d;
|
|
return (record, remote, body);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stands in for the production placement-projection subscription this
|
|
/// bare fixture never wires.
|
|
/// </summary>
|
|
internal void DrainPlacementFifo()
|
|
{
|
|
while (Lifetime.Physics.SetPosition.TryPeekProjection(
|
|
out RuntimePlacementProjectionSnapshot head))
|
|
{
|
|
if (!Lifetime.Physics.SetPosition.AcknowledgeProjection(head.Token))
|
|
break;
|
|
}
|
|
}
|
|
|
|
internal int LiveOperationCount =>
|
|
Lifetime.Physics.CaptureOwnership().SetPositionOperationCount;
|
|
|
|
internal int RemotePlacementLedger =>
|
|
Lifetime.CaptureOwnership().RemotePlacementDrivePendingCount;
|
|
|
|
public void Dispose() => Lifetime.Dispose();
|
|
|
|
private static WorldSession.EntitySpawn Spawn(uint guid) => new(
|
|
guid,
|
|
new CreateObject.ServerPosition(
|
|
SourceCell, 10f, 10f, SpawnHeight, 1f, 0f, 0f, 0f),
|
|
SetupTableId: null,
|
|
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
|
|
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
|
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
BasePaletteId: null,
|
|
ObjScale: null,
|
|
Name: "remote",
|
|
ItemType: null,
|
|
MotionState: null,
|
|
MotionTableId: 0x09000001u);
|
|
|
|
private static PhysicsEngine FlatEngine()
|
|
{
|
|
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
|
engine.AddLandblock(
|
|
SourceLandblock,
|
|
new TerrainSurface(new byte[81], new float[256]),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
return engine;
|
|
}
|
|
|
|
private sealed class ServiceWindow : IRuntimeRemotePlacementServiceWindow
|
|
{
|
|
private readonly HashSet<uint> _within = [];
|
|
|
|
internal void Allow(uint landblockId) =>
|
|
_within.Add((landblockId & 0xFFFF0000u) | 0xFFFFu);
|
|
|
|
public bool IsWithinServiceWindow(uint landblockId) =>
|
|
_within.Contains((landblockId & 0xFFFF0000u) | 0xFFFFu);
|
|
}
|
|
|
|
private sealed class UnusedCollisionSource : IPreparedCollisionSource
|
|
{
|
|
public PreparedAssetPresence ProbeCollision(
|
|
PakAssetType type,
|
|
uint sourceFileId) => PreparedAssetPresence.Available;
|
|
|
|
public PreparedCollisionReadResult<FlatSetupCollision> ReadSetupCollision(
|
|
uint sourceFileId,
|
|
CancellationToken cancellationToken = default) =>
|
|
PreparedCollisionReadResult<FlatSetupCollision>.Missing;
|
|
|
|
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset> ReadGfxObjCollision(
|
|
uint sourceFileId,
|
|
CancellationToken cancellationToken = default) =>
|
|
throw new NotSupportedException();
|
|
|
|
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
|
|
ReadCellStructureCollision(
|
|
uint sourceFileId,
|
|
CancellationToken cancellationToken = default) =>
|
|
throw new NotSupportedException();
|
|
|
|
public PreparedCollisionReadResult<FlatEnvCellTopology> ReadEnvCellTopology(
|
|
uint sourceFileId,
|
|
CancellationToken cancellationToken = default) =>
|
|
throw new NotSupportedException();
|
|
|
|
public PreparedCollisionSourceStats CollisionStats => default;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
}
|