acdream/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs
Erik 5ca1d47d7a feat(world): the login wormhole — every world entry runs retail's portal-space presentation with sound (TS-28 narrowed)
Retail runs the SAME TAS_TUNNEL wormhole at initial login as at an F751
teleport, with no F751 involved: SmartBox::teleport_in_progress
@0x00451C20 returns 1 the moment the login player exists with
position_update_complete == 0, gmSmartBoxUI::UseTime @0x004D6EAB
edge-detects it into BeginTeleportAnimation(TAS_TUNNEL) @0x004D6EC9
(playing Sound_UI_EnterPortal @0x004D638E), SmartBox::UseTime
@0x00455483 ends the hold once destination cells stop blocking,
Sound_UI_ExitPortal plays at the viewport swap @0x004D7405, and
LoginComplete goes out at the WorldFadeIn end @0x004D745D ->
CPlayerSystem::SendLoginCompleteNotification @0x00562E90 (ACE's own
GameActionLoginComplete comment names this contract: 'called when the
client player exits portal space. It includes initial login'). acdream
skipped all of it at login — every entry route (direct auto-select,
character-select Enter, enter-after-create) dropped onto the sky-only
'waiting for login' backdrop until the world reveal completed.

The fix engages the EXISTING F751 presentation machinery on Runtime's
login reveal — no duplicated presentation code, no timers:

- LocalPlayerTeleportController gains a login arm keyed off the
  Runtime-owned login reveal generation (RuntimeWorldTransitState
  .BeginLoginReveal, begun on the first accepted local-player position
  on every entry route). It drives the same TeleportAnimSequencer/
  PortalTunnelPresentation lifecycle and the same enter/exit cues; the
  Place edge is a no-op at login (the first-entry conductor already
  committed the canonical placement — retail's analogue only flips
  position_update_complete), and FireLoginComplete now performs
  EnterWorld + the single LoginComplete send + reveal Complete, exactly
  like the F751 pump. worldReady is latched on BOTH canonical first
  placement (OnLocalPlayerFirstEntryCompleted, the repointed
  GraphicalSessionEventRoute completion callback that used to send
  LoginComplete immediately) AND destination reveal readiness.
  ActiveDestinationCell now also reports the login destination so the
  render frame's reveal-preparation arm keeps running after portal-space
  entry flips ChaseModeEverEntered.
- PlayerModeController.TryEnterPortalSpaceForLogin performs the
  player-mode presentation attach (the same BuildControllerAndCamera the
  post-reveal auto-entry used to run) before flipping into portal space
  — at login no player-mode entry has happened yet. TryEnterPortalSpace
  itself now refuses (retryable) on a constructed-but-unpublished
  Runtime controller via the documented CanExecuteLiveMovement skip
  predicate instead of faulting — the first connected run crashed on
  exactly that pre-publication State write.
- HouseQuery stays at first-entry completion (retail: tail-called from
  CPlayerSystem::InitializePlayer @0x00563570, an object-arrival edge,
  not a tunnel edge).
- An F751 arriving mid-login-tunnel withdraws the login claim and hands
  the presentation to the portal pump, which owns the single
  LoginComplete — matching retail's one teleportInProgress flag.

TS-28 narrowed: the graphical host now runs the full login wormhole;
the residual is headless-only (no presentation; placement-edge send).

Live gates (testaccount2/+Horan vs local ACE, Release): the
character-select Enter route and the --session-config direct auto-select
route both play the wormhole with Sound_UI_EnterPortal at animation
begin, hold with retail's 'In Portal Space - Please Wait...' notice
until readiness, fade out with the view-plane warp, send LoginComplete
at the WorldFadeIn end, and materialize in Holtburg; ACE-confirmed
graceful logout. Tests: App 5493/3 skips (baseline 5490 + 3 new login
tests), Runtime 1744/0, full solution green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:51:33 +02:00

1135 lines
50 KiB
C#

using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Content.Pak;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib.IO;
namespace AcDream.App.Tests.Physics;
/// <summary>
/// C4 route 4b-3, test-plan item 9 (the #312 layer, strong form): drives the
/// COMPLETE production entry point — a real
/// <see cref="LiveEntityNetworkUpdateController.OnPosition"/> call, not the
/// extracted <see cref="LiveEntityNetworkUpdateController.ApplyRemoteContactRouting"/>
/// seam the far-snap/steady-state integration tests use — for a remote
/// teleport, and asserts the render layer directly: the <see cref="WorldEntity"/>
/// pose, <c>ParentCellId</c>, spatial visibility, and collision-shadow
/// publication. #312 shipped because a presentation-restore fix's tests
/// asserted only <c>InWorld</c>/clock/residency, never this layer — these
/// tests exist so the same class of regression cannot ship silently for the
/// teleport arm.
///
/// <para>
/// Every dependency is a REAL production class except the ~15 interface
/// seams <c>LiveEntityNetworkUpdateController</c> takes for local-player-only
/// or DAT-backed concerns that a remote-teleport call never reaches (verified
/// by reading <c>OnPosition</c>: <c>_dats</c>/<c>_animLoader</c> are read only
/// from <c>OnMotion</c>; <c>_localPlayerTeleport</c>/<c>_playerHostSource</c>/
/// <c>_acceptedPositionDrive</c> are gated on <c>update.Guid ==
/// _playerServerGuid</c>, which the remote guid here never satisfies). The
/// canonical <see cref="RuntimeEntityObjectLifetime"/> is shared between
/// <see cref="LiveEntityRuntime"/> and <see cref="RuntimeRemotePlacementDriveController"/>,
/// exactly like production's <c>SessionPlayerComposition</c> wires them.
/// </para>
///
/// <para>
/// <see cref="RemoteGuid"/> deliberately does NOT satisfy
/// <c>IsPlayerGuid</c> (<c>(guid &amp; 0xFF000000u) == 0x50000000u</c>), so
/// <c>OnPosition</c> takes the "GROUNDED ROUTING" creature/NPC branch and
/// its own render+shadow tail (the block after the
/// <c>#184: sync the NPC shadow...</c> comment), not the sibling copy inside
/// the <c>IsPlayerGuid</c> branch above it — the two are separate inline
/// copies of the same invariant, not a shared helper. Verified live: a
/// sabotage of the wrong (player-guid) copy left both tests green, which is
/// what caught the branch mismatch during authoring; sabotaging the actual
/// NPC-branch copy fails both tests as expected.
/// </para>
/// </summary>
public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
{
private const uint SourceLandblock = 0xB1000000u;
private const uint SourceCell = SourceLandblock | 0x0001u;
private const uint DestinationLandblock = 0xB2000000u;
private const uint DestinationCell = DestinationLandblock | 0x0001u;
private static readonly Vector3 DestinationWorldOffset = new(192f, 0f, 0f);
private const uint RemoteGuid = 0x70006001u;
/// <summary>
/// In <c>IsPlayerGuid</c>'s <c>0x50xxxxxx</c> range but distinct from
/// the fixture's own <c>NoopIdentitySource.ServerGuid</c>
/// (<c>0x50000099u</c>) — an OTHER player's remote character, exactly
/// the guid shape that takes <c>OnPosition</c>'s <c>IsPlayerGuid</c>
/// branch (and its own airborne "landing block" early return) while
/// still classifying as a remote (<c>update.Guid != _playerServerGuid</c>).
/// </summary>
private const uint OtherPlayerGuid = 0x50006001u;
private const float SpawnHeight = 7f;
/// <summary>
/// Retail's foot-sphere convention (<c>PhysicsEngine.Resolve</c>'s
/// <c>FootSphereCenterLift</c>, #107 2026-06-10): a body's Z sits this
/// far above the terrain sample its foot sphere rests on, center-to-
/// contact. A canonical placement onto flat terrain therefore resolves
/// to <c>terrainHeight + FootSphereCenterLift</c>, not the raw wire Z.
/// </summary>
private const float FootSphereCenterLift = 0.48f;
/// <summary>
/// The strong form of test-plan item 9's commit half: after a remote
/// teleport commits, the render <see cref="WorldEntity"/> pose equals the
/// resolved body pose, <c>ParentCellId</c> equals the resolved cell, the
/// entity is spatially visible, and the collision shadow was published
/// (re-synced) at the resolved position — not merely registered once at
/// spawn. Fails against a broken implementation that skips the teleport
/// arm's render/shadow tail (invariant 2): reverting that tail to a
/// no-op leaves <c>Entity.Position</c> at the pre-teleport spawn pose and
/// the shadow registry's position unsynced, both asserted below.
/// </summary>
[Fact]
public void TeleportCommit_RenderEntityMatchesResolvedBody_VisibleAndShadowSynced()
{
using var fixture = new Fixture();
fixture.PublishDestinationCollision();
fixture.ServiceWindow.Allow(DestinationLandblock);
Vector3 spawnPose = fixture.Entity.Position;
var destination = new Vector3(12f, 14f, SpawnHeight);
fixture.Controller.OnPosition(fixture.TeleportUpdate(
destination, DestinationCell, teleportSequence: 5));
Assert.True(fixture.Lifetime.Entities.TryGetActive(
RemoteGuid, out RuntimeEntityRecord canonical));
Assert.NotNull(canonical.PhysicsBody);
PhysicsBody body = canonical.PhysicsBody!;
Vector3 resolved = destination + DestinationWorldOffset
+ new Vector3(0f, 0f, FootSphereCenterLift);
Assert.Equal(resolved, body.Position);
Assert.NotEqual(spawnPose, body.Position);
// Invariant 2: the render entity advances from the RESOLVED body.
Assert.Equal(body.Position, fixture.Entity.Position);
Assert.Equal(body.Orientation, fixture.Entity.Rotation);
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
Assert.Equal(DestinationCell, canonical.FullCellId);
Assert.True(fixture.Runtime.TryGetRecord(
RemoteGuid, out LiveEntityRecord liveRecord));
Assert.True(liveRecord.IsSpatiallyVisible);
// The collision shadow was RE-PUBLISHED at the resolved position —
// not merely the stale spawn-time registration.
AcDream.Core.Physics.ShadowEntry shadowEntry = Assert.Single(
fixture.Shadows.AllEntriesForDebug(),
entry => entry.EntityId == fixture.Entity.Id);
Assert.Equal(body.Position, shadowEntry.Position);
fixture.DrainPlacementFifo();
}
/// <summary>
/// Test-plan item 9's refused half: invariant 1 (the pose still
/// advances) restated at the render layer, plus the #312-specific claim
/// — the entity REMAINS visible. Fails against a regression that leaves
/// the render entity at its stale pose or withdraws it from the spatial
/// working set on a refusal.
///
/// <para>
/// A4 fix round (2026-08-04): every prior assertion here was ALSO
/// satisfied by a regression that misclassified this packet as
/// <c>UnroutedCatchUp</c> instead of the teleport arm — its
/// <c>ApplyInterpolate</c> hard-place lands the body at the exact same
/// wire pose (AP-87's <c>bodyToTarget &gt; 4 m</c> branch) and the SAME
/// NPC tail then syncs entity/shadow identically, so none of those
/// assertions could tell the two arms apart. Arming sticky BEFORE the
/// packet and asserting it was cleared afterward closes that gap:
/// <c>UnroutedCatchUp</c> never reaches <c>ApplyRemoteContactRouting</c>'s
/// teleport branch at all, so it never runs the hook, so a stuck NPC
/// misclassified onto that arm would stay stuck — only the real teleport
/// arm's hook calls <c>UnStick</c>.
/// </para>
/// </summary>
[Fact]
public void TeleportRefused_RenderPoseTracksStoredDestination_EntityRemainsVisible()
{
using var fixture = new Fixture();
fixture.PublishDestinationCollision();
// Deliberately NOT ServiceWindow.Allow(DestinationLandblock) — the
// canonical placement refuses, so store_position's fallback is what
// must move the body (and the render entity behind it).
Vector3 spawnPose = fixture.Entity.Position;
var destination = new Vector3(12f, 14f, SpawnHeight);
EntityPhysicsHost host = fixture.ArmSticky(stickTargetGuid: 0x70009998u);
fixture.Controller.OnPosition(fixture.TeleportUpdate(
destination, DestinationCell, teleportSequence: 5));
// The discriminator: only the real teleport arm's hook clears this.
Assert.Equal(0u, host.PositionManager.GetStickyObjectId());
Assert.True(fixture.Lifetime.Entities.TryGetActive(
RemoteGuid, out RuntimeEntityRecord canonical));
PhysicsBody body = canonical.PhysicsBody!;
// Unlike the commit half, a REFUSED placement never runs the
// ground-contact sweep that applies FootSphereCenterLift — the
// store_position fallback stamps the raw wire position verbatim.
Vector3 resolved = destination + DestinationWorldOffset;
Assert.Equal(resolved, body.Position);
Assert.NotEqual(spawnPose, body.Position);
Assert.Equal(body.Position, fixture.Entity.Position);
Assert.True(fixture.Runtime.TryGetRecord(
RemoteGuid, out LiveEntityRecord liveRecord));
Assert.True(liveRecord.IsSpatiallyVisible);
// The collision shadow was RE-PUBLISHED at the stored destination —
// the generic top-of-OnPosition wire-pose write never touches the
// shadow registry, so this specifically exercises the tail.
AcDream.Core.Physics.ShadowEntry shadowEntry = Assert.Single(
fixture.Shadows.AllEntriesForDebug(),
entry => entry.EntityId == fixture.Entity.Id);
Assert.Equal(body.Position, shadowEntry.Position);
fixture.DrainPlacementFifo();
}
/// <summary>
/// Test-plan item 6 / contract D5: TS-44's sticky suppression is an
/// NPC-only CALLER gate that must NOT suppress the teleport arm — retail
/// sticky cannot survive a teleport (<c>UnStick</c> is
/// <c>teleport_hook</c>'s second action, @0x00514EEE). The exact gate
/// under test is
/// <c>LiveEntityNetworkUpdateController.OnPosition</c>'s
/// <c>if (!snapSuppressedByStick || isTeleportRoute)</c> — a stuck NPC's
/// teleport packet must still run the hook (observable here as
/// <c>UnStick</c> actually clearing the sticky lease) and place
/// canonically, exactly as an unstuck NPC's would.
/// </summary>
[Fact]
public void StuckNpc_TeleportPacket_RunsTheHookAndPlacesDespiteStickySuppression()
{
using var fixture = new Fixture();
fixture.PublishDestinationCollision();
fixture.ServiceWindow.Allow(DestinationLandblock);
EntityPhysicsHost host = fixture.ArmSticky(stickTargetGuid: 0x70009999u);
var destination = new Vector3(12f, 14f, SpawnHeight);
fixture.Controller.OnPosition(fixture.TeleportUpdate(
destination, DestinationCell, teleportSequence: 5));
// The hook's UnStick action actually ran — proof the sticky lease
// did not block dispatch, not merely that the placement happened to
// succeed for some unrelated reason.
Assert.Equal(0u, host.PositionManager.GetStickyObjectId());
Assert.True(fixture.Lifetime.Entities.TryGetActive(
RemoteGuid, out RuntimeEntityRecord canonical));
Assert.NotNull(canonical.PhysicsBody);
PhysicsBody body = canonical.PhysicsBody!;
Vector3 resolved = destination + DestinationWorldOffset
+ new Vector3(0f, 0f, FootSphereCenterLift);
Assert.Equal(resolved, body.Position);
Assert.Equal(DestinationCell, canonical.FullCellId);
fixture.DrainPlacementFifo();
}
/// <summary>
/// Test-plan item 6 / contract D5, the OnPosition-level restatement:
/// "the player arm's landing block (<c>!rmState.Body.InContact</c>
/// hard-snap + return) must not claim a teleport-classified packet" and
/// "the 4a <c>IsAirborneNoOperation</c> early returns... cannot claim a
/// <c>SetPosition</c> route". <see cref="OtherPlayerGuid"/> takes
/// <c>OnPosition</c>'s <c>IsPlayerGuid</c> branch (a DIFFERENT code path
/// than every other test in this file, which use the creature-guid
/// branch) — an ungrounded wire packet (<c>IsGrounded: false</c>) for a
/// teleport-classified body must still reach the canonical placement
/// through that branch's own copy of <c>ApplyRemoteContactRouting</c>,
/// not the branch's airborne early return at the top of the
/// <c>IsPlayerGuid</c> block.
/// </summary>
[Fact]
public void AirborneOtherPlayer_TeleportPacket_PlacesThroughThePlayerArmWithoutTheLandingBlock()
{
using var fixture = new Fixture(OtherPlayerGuid);
fixture.PublishDestinationCollision();
fixture.ServiceWindow.Allow(DestinationLandblock);
// Same mid-arc state the routing-seam test above uses: no wire
// contact, no body contact — the strongest form of "airborne".
fixture.Remote.Airborne = true;
fixture.Remote.Body.TransientState = TransientStateFlags.Active;
var destination = new Vector3(12f, 14f, SpawnHeight);
fixture.Controller.OnPosition(fixture.TeleportUpdate(
destination,
DestinationCell,
teleportSequence: 5,
guid: OtherPlayerGuid,
isGrounded: false));
Assert.True(fixture.Lifetime.Entities.TryGetActive(
OtherPlayerGuid, out RuntimeEntityRecord canonical));
Assert.NotNull(canonical.PhysicsBody);
PhysicsBody body = canonical.PhysicsBody!;
Vector3 resolved = destination + DestinationWorldOffset
+ new Vector3(0f, 0f, FootSphereCenterLift);
Assert.Equal(resolved, body.Position);
Assert.Equal(DestinationCell, canonical.FullCellId);
// The airborne early return writes NOTHING and returns before the
// spatial rebucket that follows a real dispatch — resident
// visibility is proof OnPosition did not take that exit.
Assert.True(fixture.Runtime.TryGetRecord(
OtherPlayerGuid, out LiveEntityRecord liveRecord));
Assert.True(liveRecord.IsSpatiallyVisible);
fixture.DrainPlacementFifo();
}
/// <summary>
/// A1 fix round (2026-08-04): the NPC arm must still arm the leash for
/// an ordinary landing packet — wire IS grounded (retail's
/// <c>arg4 != 0</c>), body is NOT in contact (acdream's own
/// snap-vs-interpolate axis, unrelated to retail's arming predicate).
/// <see cref="LiveEntityNetworkUpdateController.ApplyRemoteContactRouting"/>'s
/// free-flight carve-out claims this as <c>AirborneSnap</c> regardless of
/// classification (near/far/leftover would all otherwise apply); before
/// the fix, <c>ToConstraintArm</c> mapped <c>AirborneSnap</c> to the one
/// Runtime value that never arms, so this exact packet — a creature
/// knocked off a ledge — armed ZERO times where retail's
/// <c>MoveOrTeleport</c> returns nonzero and arms unconditionally
/// @0x00454272. <c>host.PositionManager.Constraint</c> is lazily created
/// only on a genuine arm, so its presence after the packet is direct,
/// unambiguous proof of the fix — not an inference from body position.
/// </summary>
[Fact]
public void NpcAirborneSnap_LandingPacket_StillArmsTheLeash()
{
using var fixture = new Fixture();
EntityPhysicsHost host = fixture.InstallHost();
Assert.Null(host.PositionManager.Constraint);
// Mid-arc: body not in contact, wire IS grounded (this packet is an
// ordinary landing correction, not a free-flight UP).
fixture.Remote.Airborne = true;
fixture.Remote.Body.TransientState = TransientStateFlags.Active;
var landingPos = new Vector3(12f, 14f, SpawnHeight);
fixture.Controller.OnPosition(fixture.TeleportUpdate(
landingPos, SourceCell, teleportSequence: 1, isGrounded: true));
Assert.NotNull(host.PositionManager.Constraint);
}
/// <summary>
/// R3/A2 fix round (2026-08-04): a teleported NPC must not synthesize a
/// locomotion velocity from the teleport distance. Before the fix, the
/// deleted shared <c>remotePlacementRequired</c> block used to sit ABOVE
/// the NPC synth-velocity code and always returned, so a teleport packet
/// never reached it; once that block was deleted and the teleport arm
/// routed through the NPC tail, the synth-velocity install ran
/// unconditionally, computing <c>(worldPos - LastServerPos) / elapsed</c>
/// across the WHOLE teleport distance over one packet interval —
/// hundreds to thousands of m/s — and installing it as
/// <c>ServerVelocity</c>. <see cref="Fixture.Remote"/>'s
/// <c>LastServerPos</c>/<c>LastServerPosTime</c> are seeded to simulate
/// an already-tracked creature (the real <c>@teleto</c>-on-a-visible-
/// drudge scenario) so a broken implementation's synth would have a real
/// distance/interval to compute from, not a degenerate first-packet
/// no-op.
/// </summary>
[Fact]
public void NpcTeleport_DoesNotInstallASynthesizedVelocity()
{
using var fixture = new Fixture();
fixture.PublishDestinationCollision();
fixture.ServiceWindow.Allow(DestinationLandblock);
fixture.Remote.LastServerPos = fixture.Entity.Position;
fixture.Remote.LastServerPosTime =
(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds - 0.15;
var destination = new Vector3(12f, 14f, SpawnHeight);
fixture.Controller.OnPosition(fixture.TeleportUpdate(
destination, DestinationCell, teleportSequence: 5));
Assert.False(fixture.Remote.HasServerVelocity);
Assert.Equal(Vector3.Zero, fixture.Remote.ServerVelocity);
fixture.DrainPlacementFifo();
}
/// <summary>
/// Test-plan item 10 (R1/A7 fix round, 2026-08-04): "a wire-airborne
/// null-classified packet writes exactly AP-135's two fields and nothing
/// else (body, entity, queue, leash all untouched)" — driven for the NPC
/// arm specifically, which shipped WITHOUT this D2 shape (the player arm
/// had it from the start). Before the fix, a wire-airborne, null/
/// <c>Rejected*</c>-classified NPC packet fell through to
/// <see cref="LiveEntityNetworkUpdateController.ApplyRemoteContactRouting"/>'s
/// own free-flight carve-out and received a hard-snap body write, a
/// leash arm, and a shadow republish — none of which retail's
/// <c>return 0</c> (@0x0051636D) produces. <see cref="NoopLocalPlayerControllerSource"/>
/// forces the null-classification shape (no local player exists yet to
/// supply <c>player_distance</c> — the login-window case D2 is scoped
/// to); <c>teleportSequence: 1</c> matches the spawn's own committed
/// teleport timestamp so this packet does NOT advance TELEPORT_TS
/// (never teleport-classified, even setting classification aside).
/// </summary>
[Fact]
public void NullClassifiedNpc_WireAirbornePacket_WritesOnlyBookkeepingNoBodyOrShadow()
{
using var fixture = new Fixture(nullClassification: true);
EntityPhysicsHost host = fixture.InstallHost();
Assert.Null(host.PositionManager.Constraint);
Vector3 spawnBodyPose = fixture.Remote.Body.Position;
var wirePos = new Vector3(50f, 50f, SpawnHeight);
fixture.Controller.OnPosition(fixture.TeleportUpdate(
wirePos,
SourceCell,
teleportSequence: 1,
isGrounded: false));
// D2's core claim: no body write. A broken implementation that lets
// this packet reach ApplyRemoteContactRouting's free-flight carve-out
// hard-snaps the body to wirePos here.
Assert.Equal(spawnBodyPose, fixture.Remote.Body.Position);
// No shadow republish — still exactly the one spawn-time entry, at
// the spawn pose, not wirePos.
AcDream.Core.Physics.ShadowEntry shadowEntry = Assert.Single(
fixture.Shadows.AllEntriesForDebug(),
entry => entry.EntityId == fixture.Entity.Id);
Assert.Equal(spawnBodyPose, shadowEntry.Position);
// Round-2 architecture review B1: everything above asserts only what
// must NOT happen, so an EMPTIED ApplyWireAirborneLeftoverBookkeeping
// passed every test in the tree. AP-135's two writes are the POSITIVE
// half of D2's shape and are load-bearing: RemoteMotion.CellId feeds
// RuntimeRemotePhysicsUpdater's free-fall sweep gate (its `rm.CellId
// != 0` test) and the LastServerPos/LastServerPosTime pair seeds the
// first-grounded-packet velocity synthesis. Losing them silently is
// the "airborne remote falls through the floor" shape.
Assert.Equal(SourceCell, fixture.Remote.CellId);
Assert.Equal(wirePos, fixture.Remote.LastServerPos);
Assert.NotEqual(0d, fixture.Remote.LastServerPosTime);
// Round-2 retail review: this test's name and doc both claim the leash
// is untouched — a D4 "no arm" row, because retail returns 0
// @0x0051636D and never reaches ConstrainTo @0x00454272 — but nothing
// asserted it. The constraint sub-manager is lazily created only
// inside a genuine arm, so null is direct proof of zero arming calls.
Assert.Null(host.PositionManager.Constraint);
}
private sealed class Fixture : IDisposable
{
internal RuntimeEntityObjectLifetime Lifetime { get; }
internal LiveEntityRuntime Runtime { get; }
internal LiveEntityNetworkUpdateController Controller { get; }
internal RemoteServiceWindow ServiceWindow { get; } = new();
internal ShadowObjectRegistry Shadows { get; }
internal WorldEntity Entity { get; }
internal RemoteMotion Remote { get; private set; } = null!;
private readonly GpuWorldState _spatial;
private readonly uint _guid;
private readonly bool _nullClassification;
/// <param name="guid">
/// Defaults to <see cref="RemoteGuid"/> (an ordinary creature-style
/// guid, NOT in <c>IsPlayerGuid</c>'s <c>0x50xxxxxx</c> range).
/// Tests that need the OTHER production remote branch — the
/// <c>IsPlayerGuid(update.Guid)</c> block in <c>OnPosition</c>, which
/// has its own airborne early return (the "player arm's landing
/// block") — pass a guid in that range instead.
/// </param>
internal Fixture(uint guid = RemoteGuid, bool nullClassification = false)
{
_guid = guid;
_nullClassification = nullClassification;
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);
Lifetime = new RuntimeEntityObjectLifetime(engine);
Lifetime.BindEventContext(
static () => new RuntimeGenerationToken(1UL),
static () => 1UL);
Shadows = engine.ShadowObjects;
var spatial = new GpuWorldState();
// GpuWorldState's own bucket key is the CANONICAL landblock form
// ((id & 0xFFFF0000) | 0xFFFF) — the same form
// RebucketLiveEntity/RuntimeEntityObjectLifetime derive from a
// cell id. Registering the raw 0x....0000 id here would silently
// leave the entity in the PENDING bucket forever.
spatial.AddLandblock(new LoadedLandblock(
CanonicalLandblock(SourceLandblock),
new DatReaderWriter.DBObjs.LandBlock(),
Array.Empty<WorldEntity>()));
_spatial = spatial;
Runtime = new LiveEntityRuntime(
spatial,
new NoopResources(),
NullLiveEntityRuntimeComponentLifecycle.Instance,
Lifetime);
var wirePosition = new CreateObject.ServerPosition(
SourceCell, 10f, 10f, SpawnHeight, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 1,
ServerControlledMove: 1,
ForcePosition: 1,
ObjDesc: 1,
Instance: 1);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: wirePosition,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: 1f,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
var spawn = new WorldSession.EntitySpawn(
_guid,
wirePosition,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"remote-teleport-fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: 1,
PositionSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
Physics: physics);
LiveEntityRecord record =
Runtime.RegisterAndMaterializeProjection(spawn);
Entity = record.WorldEntity
?? throw new InvalidOperationException(
"fixture failed to materialize the remote entity");
Assert.True(Runtime.RebucketLiveEntity(_guid, SourceCell));
// Adopts this body as the canonical PhysicsBody (none exists yet)
// — mirrors LiveEntityLifecycleStressTests' RecallPortalFixture.
var remote = new RemoteMotion();
remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
remote.CellId = SourceCell;
Runtime.SetRemoteMotionRuntime(_guid, remote);
Remote = remote;
Shadows.Register(
Entity.Id,
0x02000001u,
Entity.Position,
Entity.Rotation,
radius: 0.48f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: SourceLandblock,
collisionType: ShadowCollisionType.Cylinder,
cylHeight: 1.835f,
seedCellId: SourceCell,
isStatic: false);
var origin = new LiveWorldOriginState();
origin.SetPlaceholder(
(int)((SourceLandblock >> 24) & 0xFFu),
(int)((SourceLandblock >> 16) & 0xFFu));
var animatedEntities =
new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(
new LiveEntityRuntimeSlot());
var remotePlacementDrive = new RuntimeRemotePlacementDriveController(
Lifetime,
new GameRuntimeClock(),
new NoopCollisionSource(),
ServiceWindow);
var acceptedPositionDrive = new RuntimeAcceptedPositionDriveController(
Lifetime,
new GameRuntimeClock(),
new NoopCollisionSource(),
new LocalPlayerOutboundController(static (_, _, _, _, _, _) => { }),
static () => new RuntimeGenerationToken(1UL),
static () => 0x50000099u,
static () => null,
static () => false,
static () => null);
var identity = new NoopIdentitySource();
var deletion = new LiveEntityDeletionController(
Runtime,
Lifetime,
new NoopTeardownCoordinator(),
identity);
var hydration = new LiveEntityHydrationController(
Runtime,
Lifetime,
new object(),
new NoopMaterializer(),
new NoopRelationships(),
new NoopReadyPublisher(),
new AlwaysKnownOrigin(),
new NoopNetworkSink(),
new NoopTimestampPublisher(),
identity,
deletion);
var entityEffects = new EntityEffectController(
Runtime,
new AcDream.Core.Vfx.PhysicsScriptRunner(
static _ => null,
new AcDream.Core.Physics.AnimationHookRouter(),
randomUnit: static () => 0.5),
new AcDream.Core.Vfx.PhysicsScriptTableResolver(static _ => null),
new EntityEffectPoseRegistry());
Controller = new LiveEntityNetworkUpdateController(
Runtime,
Lifetime.Objects,
hydration,
entityEffects,
new LiveEntityPresentationController(
Runtime,
Shadows,
(_, _, _) => true,
new LiveEntityPartArrayEnterWorldPort(_ => { })),
new LiveEntityLightController(
Runtime,
new EntityEffectPoseRegistry(),
new AcDream.Core.Lighting.LightingHookSink(
new AcDream.Core.Lighting.LightManager(),
new EntityEffectPoseRegistry()),
static _ => null),
new EquippedChildRenderController(
new NoopDatReaderWriter(),
new object(),
Lifetime.Objects,
Runtime,
new EntityEffectPoseRegistry(),
static _ => false,
static (_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Superseded,
null)),
new ProjectileController(Runtime),
animatedEntities,
new RemoteMovementObservationTracker(),
new RemotePhysicsUpdater(
Lifetime.Physics,
static (_, _) => (0.48f, 1.835f),
static (_, _) => (
System.Collections.Immutable
.ImmutableArray<FlatCollisionSphere>.Empty,
1f, 0.4f, 0.4f),
static (_, _, _, _) => { }),
new RemoteInboundMotionDispatcher(
static (_, _, _) => false,
static (_, _) => { }),
new LiveEntityMotionRuntimeController(
Runtime,
new PhysicsDataCache(),
static () => null,
new AcDream.Core.Selection.SelectionState(),
origin),
engine,
new NoopDatReaderWriter(),
new NoopAnimationLoader(),
combatTargetController: null,
origin,
new NoopTeleportSink(),
_nullClassification
? new NoopLocalPlayerControllerSource()
: new StubLocalPlayerControllerSource(),
new LocalPlayerOutboundController(static (_, _, _, _, _, _) => { }),
new NoopPhysicsHostSource(),
identity,
new FixedScriptTime(),
new NoopSessionSource(),
publishTimestamps: static (_, _) => { },
new NoopMovementTruthSink(),
acceptedPositionDrive,
remotePlacementDrive,
worldDropProjection: null);
}
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);
// The destination landblock must also be a LOADED spatial bucket
// — not merely collision-ready — or IsLiveEntityProjectionResident
// would (correctly) report the entity as not-yet-streamed-in and
// mask the render-layer assertions these tests exist to make.
// Streaming residency itself is out of scope here; C4 route 4b-3
// is about the physics/render sync tail once a destination is
// already resident, matching the two-tier streaming contract.
uint destinationCanonical = CanonicalLandblock(DestinationLandblock);
if (!_spatial.IsLoaded(destinationCanonical))
{
_spatial.AddLandblock(new LoadedLandblock(
destinationCanonical,
new DatReaderWriter.DBObjs.LandBlock(),
Array.Empty<WorldEntity>()));
}
}
private static uint CanonicalLandblock(uint landblockId) =>
(landblockId & 0xFFFF0000u) | 0xFFFFu;
internal WorldSession.EntityPositionUpdate TeleportUpdate(
Vector3 destination,
uint cellId,
ushort teleportSequence,
uint guid = RemoteGuid,
bool isGrounded = true) => new(
guid,
new CreateObject.ServerPosition(
cellId,
destination.X,
destination.Y,
destination.Z,
1f, 0f, 0f, 0f),
Velocity: null,
PlacementId: null,
IsGrounded: isGrounded,
InstanceSequence: 1,
PositionSequence: 2,
TeleportSequence: teleportSequence,
ForcePositionSequence: 0);
/// <summary>
/// Item 1(b): installs a real <see cref="EntityPhysicsHost"/> on the
/// remote's canonical incarnation and arms its sticky lease, exactly
/// as the production hydration pipeline does before a live NPC can
/// stick to a target. <c>RunRemoteTeleportHook</c> resolves the host
/// through <c>_liveEntities.TryGetPhysicsHost</c>, so a test that
/// wants the hook's <c>UnStick</c> action to be observable needs the
/// SAME installed host, not merely a bound <see cref="RemoteMotion.Host"/>
/// reader.
/// </summary>
internal EntityPhysicsHost ArmSticky(uint stickTargetGuid)
{
EntityPhysicsHost host = InstallHost();
host.PositionManager.StickTo(stickTargetGuid, radius: 1f, height: 1f);
Assert.NotEqual(0u, host.PositionManager.GetStickyObjectId());
return host;
}
/// <summary>
/// A1 fix round (2026-08-04): installs a real
/// <see cref="EntityPhysicsHost"/> WITHOUT arming sticky, so a test
/// can observe whether <c>TryArmConstraintAfterOperation</c> actually
/// armed the leash via <c>host.PositionManager.Constraint</c> — the
/// constraint sub-manager is lazily created only on a real arm, so
/// <see langword="null"/> after a packet is direct proof of zero
/// arming calls.
/// </summary>
internal EntityPhysicsHost InstallHost()
{
Assert.True(Runtime.TryGetRecord(
_guid, out LiveEntityRecord liveRecord));
var host = new EntityPhysicsHost(
_guid,
getPosition: () => new AcDream.Core.Physics.Position(
Remote.CellId, Remote.Body.Position, Remote.Body.Orientation),
getVelocity: () => Remote.Body.Velocity,
getRadius: () => 0.48f,
inContact: () => Remote.Body.InContact,
minterpMaxSpeed: () => null,
curTime: () => 0d,
physicsTimerTime: () => 0d,
getObjectA: _ => null,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
Runtime.InstallPhysicsHost(liveRecord, host);
Remote.MarkFullPhysicsHostBound();
return host;
}
internal void DrainPlacementFifo()
{
while (Lifetime.Physics.SetPosition.TryPeekProjection(
out RuntimePlacementProjectionSnapshot head))
{
if (!Lifetime.Physics.SetPosition.AcknowledgeProjection(head.Token))
break;
}
}
public void Dispose() => Lifetime.Dispose();
internal sealed class RemoteServiceWindow : 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 NoopResources : ILiveEntityResourceLifecycle
{
public void Register(WorldEntity entity) { }
public void Unregister(WorldEntity entity) { }
}
private sealed class NoopCollisionSource : IPreparedCollisionSource
{
public PreparedAssetPresence ProbeCollision(
PakAssetType type, uint sourceFileId) =>
PreparedAssetPresence.Available;
public PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
PreparedCollisionReadResult<FlatSetupCollision>.Loaded(
new FlatSetupCollision(
System.Collections.Immutable
.ImmutableArray<FlatCollisionCylinder>.Empty,
[new FlatCollisionSphere(Vector3.Zero, 0.48f)],
height: 0f,
radius: 0f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f));
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() { }
}
private sealed class NoopIdentitySource : ILocalPlayerIdentitySource
{
public uint ServerGuid => 0x50000099u;
}
private sealed class NoopTeardownCoordinator
: ILiveEntityTeardownCoordinator
{
public void TearDown(LiveEntityRecord record) { }
public void ForgetUnknownOwner(uint serverGuid) { }
}
private sealed class NoopMaterializer : ILiveEntityProjectionMaterializer
{
public bool TryMaterialize(
RuntimeEntityRecord expectedCanonical,
WorldSession.EntitySpawn canonicalSpawn,
LiveProjectionPurpose purpose,
ulong expectedCreateIntegrationVersion,
AcDream.App.Rendering.LiveEntityAppearanceUpdateState?
appearanceUpdate = null) =>
throw new InvalidOperationException(
"The fixture pre-materializes the remote entity; " +
"TryMaterialize should never be reached for an " +
"already-projected accepted Position.");
public void ResetSessionState() { }
}
private sealed class NoopRelationships : ILiveEntityRelationshipProjection
{
public void OnSpawn(WorldSession.EntitySpawn spawn) { }
public void OnParent(ParentEvent.Parsed update) { }
public void OnCreateParentAccepted(CreateParentUpdate update) { }
public AcDream.App.Rendering.ChildUnparentDisposition
OnChildBecameUnparented(uint childGuid) =>
AcDream.App.Rendering.ChildUnparentDisposition.NotAttached;
public bool TryApplyAttachedAppearance(
LiveEntityRecord record, ulong objDescAuthorityVersion) => false;
}
private sealed class NoopReadyPublisher : ILiveEntityReadyPublisher
{
public bool Publish(LiveEntityReadyCandidate candidate) => true;
}
private sealed class AlwaysKnownOrigin : ILiveEntityWorldOriginCoordinator
{
public bool IsKnown => true;
public LiveEntityOriginInitialization TryInitialize(
WorldSession.EntitySpawn spawn) => new(true, []);
}
private sealed class NoopNetworkSink : ILiveEntityNetworkUpdateSink
{
public void ApplySameGeneration(SameGenerationCreateObjectEvents events) { }
}
private sealed class NoopTimestampPublisher
: IAcceptedLocalPhysicsTimestampPublisher
{
public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps) { }
}
private sealed class NoopDatReaderWriter : IDatReaderWriter
{
private readonly StubDatabase _portal = new();
private readonly StubDatabase _highRes = new();
private readonly StubDatabase _language = new();
private readonly StubDatabase _cell = new();
public string SourceDirectory => string.Empty;
public IDatDatabase Portal => _portal;
public IDatDatabase Cell => _cell;
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
new(new Dictionary<uint, IDatDatabase>());
public IDatDatabase HighRes => _highRes;
public IDatDatabase Language => _language;
public IDatDatabase Local => _language;
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
new(new Dictionary<uint, uint>());
public int PortalIteration => 0;
public int CellIteration => 0;
public int HighResIteration => 0;
public int LanguageIteration => 0;
public bool TryGetFileBytes(
uint regionId,
uint fileId,
ref byte[] bytes,
out int bytesRead)
{
bytesRead = 0;
return false;
}
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
Array.Empty<uint>();
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
Array.Empty<IDatReaderWriter.IdResolution>();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public bool TrySave<T>(
uint regionId,
T obj,
int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj => default;
public bool TryGet<T>(
uint fileId,
[MaybeNullWhen(false)] out T value) where T : IDBObj
{
value = default;
return false;
}
public void Dispose() { }
}
private sealed class StubDatabase : IDatDatabase
{
public DatDatabase Db => throw new NotSupportedException();
public int Iteration => 0;
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
Array.Empty<uint>();
public bool TryGet<T>(
uint fileId,
[MaybeNullWhen(false)] out T value) where T : IDBObj
{
value = default;
return false;
}
public bool TryGetFileBytes(
uint fileId,
[MaybeNullWhen(false)] out byte[] value)
{
value = null;
return false;
}
public bool TryGetFileBytes(
uint fileId,
ref byte[] bytes,
out int bytesRead)
{
bytesRead = 0;
return false;
}
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public void Dispose() { }
}
private sealed class NoopAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
private sealed class NoopTeleportSink : ILocalPlayerTeleportNetworkSink
{
public void OnTeleportStarted(uint sequence) { }
public void OfferDestination(
RuntimeTeleportDestination destination,
bool teleportTimestampAdvanced)
{ }
public void OnLocalPlayerFirstEntryCompleted() { }
public void ResetSession() { }
public void ResetGenerationPresentation() { }
}
/// <summary>
/// A <see langword="null"/> <see cref="Controller"/> makes
/// <c>ClassifyRemoteAcceptedPosition</c>'s <c>player_distance</c>
/// input null, which
/// <see cref="AcDream.Runtime.Physics.RuntimeAcceptedPositionRouteRequests.TryBuild"/>
/// then refuses outright (no fabricated distance) — silently
/// starving every remote classification, including the teleport
/// arm this fixture exists to exercise. A real (if origin-parked)
/// controller supplies a finite distance instead.
/// </summary>
private sealed class StubLocalPlayerControllerSource
: IRuntimeLocalPlayerControllerSource
{
public PlayerMovementController? Controller { get; } =
new PlayerMovementController(new PhysicsEngine());
}
/// <summary>
/// Fix round (2026-08-04): the deliberate INVERSE of
/// <see cref="StubLocalPlayerControllerSource"/> — a null
/// <see cref="Controller"/> starves <c>player_distance</c>, which
/// makes <c>ClassifyRemoteAcceptedPosition</c> return a null route
/// (the exact "login window" shape D2 exists for), rather than a
/// real classification. Used ONLY by tests that specifically need a
/// null-classified packet.
/// </summary>
private sealed class NoopLocalPlayerControllerSource
: IRuntimeLocalPlayerControllerSource
{
public PlayerMovementController? Controller => null;
}
private sealed class NoopPhysicsHostSource : ILocalPlayerPhysicsHostSource
{
public EntityPhysicsHost? Host => null;
}
private sealed class FixedScriptTime : IPhysicsScriptTimeSource
{
public double CurrentScriptTime => 1_700_000_000d;
}
private sealed class NoopSessionSource : ILiveWorldSessionSource
{
public WorldSession? CurrentSession => null;
}
private sealed class NoopMovementTruthSink : IMovementTruthDiagnosticSink
{
public void OnOutbound(
string kind,
uint sequence,
MovementResult result,
Vector3 wirePosition,
uint wireCellId,
byte contactByte)
{ }
public void OnServerEcho(
WorldSession.EntityPositionUpdate update,
Vector3 serverWorldPosition)
{ }
public void ResetSession() { }
}
}
}