Flips the last remote classification (SetPosition: teleport-advanced and
cell-less) onto 4b-1's RuntimeRemotePlacementDriveController, runs retail's
teleport_hook before the placement, and deletes the legacy remote-teleport
machinery. Contract: docs/research/2026-08-04-c4-route-4b-3-contract.md.
Retail: MoveOrTeleport @0x00516330's branch @0x00516386 -> teleport_hook
@0x005163EF -> SetFlags(0x1012) @0x00516414 -> SetPosition @0x00516420 ->
return 1 @0x00516438. The hook @0x00514ED0 runs BEFORE the placement and
regardless of its outcome. Retail places this branch unconditionally, at any
distance and any contact state (arg4 is read only @0x0051638E, after the
branch) — which is what retires AP-137's cell-less enqueue-vs-place delta.
D1 — the classifier's cell-less input is now the PRE-merge committed cell.
Retail's predicate is `this_1->cell == 0`, the BODY's own cell at
MoveOrTeleport entry (this_1 is assigned from this @0x00516334). acdream fed
the POST-merge canonical.FullCellId, which RefreshSnapshot ->
RefreshDerivedState -> SetFullCell has already stamped with the accepted wire
cell; a zero wire cell fails validation into RejectedData first. The shipped
remote cell-less predicate was therefore dead code, not merely different from
remotePlacementRequired. Threaded via a builder overload; route 1's overload
is untouched. The graphical !IsSpatiallyVisible arm of
projectionRequiresTeleportHook is deleted — a presentation predicate with no
retail analogue that fired the teleport machinery on a routine hot path.
Deleted: RemoteTeleportController (605), RemoteTeleportPlacement (85),
RemoteShadowPlacementSynchronizer (49), their 1,709 lines of tests, the
remotePlacementRequired predicate, the TeleportHookRequired plumbing, the
legacy pre-operation ConstrainTo fallback, and the player arm's legacy
!IsGrounded fallback. Net -2,030 lines.
Structural fix (two independent Opus reviews, round 1 FAIL/FAIL): three of the
four MAJORs were one defect — OnPosition carried two parallel inline copies of
the routing tail (player-guid, NPC-guid) that had drifted. Extracted
RunRemoteArmTail (3 call sites) and ApplyWireAirborneLeftoverBookkeeping (2),
both branches now share one implementation.
A1 ToConstraintArm mapped AirborneSnap -> AirborneNoOperation, so the NPC
arm armed ConstrainTo ZERO times for an out-of-contact wire-grounded
creature — a regression this slice introduced while closing a
structurally identical hole. Now maps to NearInterpolate; switch made
total with a throwing default proven unreachable.
R1 D2's write-nothing shape existed on the player arm only; NPC packets
fell through and wrote the body. Retail makes no player/NPC distinction.
R2 report_collision_end(this,1) @0x00514F31 was bound to
ShadowObjects.Suspend, a port of a DIFFERENT retail function
(remove_shadows_from_cells) that teleport_hook never calls. Now routes
to RuntimeCollisionReportingState.LeaveWorld, which wraps the private
ForceEnd in an admission-blocking transaction so a DoCollisionEnd
callback cannot recreate the contact table.
R3/A2 A teleported NPC synthesized ServerVelocity from the teleport distance
(~1,000+ m/s) and planned a run cycle from it. Both the install and
RemoteServerControlledVelocityCycle.Apply now gate on !isTeleportRoute.
BISECT HAZARD — A1's fix is correct only BECAUSE R1 landed. AirborneSnap is
reachable wire-airborne on the NPC arm only while D2's shape is missing there.
Reverting R1 alone silently inverts A1 into the opposite divergence: arming
where retail returns 0. Revert both or neither.
Also in the velocity hunk: the NPC block's two !IsPlayerGuid(update.Guid)
guards were dropped when it was wrapped in `if (!isTeleportRoute)`. Safe — all
five exit paths of the enclosing IsPlayerGuid block return, so the predicate is
unconditionally false below it — but it was unremarked by both reviews.
Register: AP-137 REWRITTEN (not deleted) to the surviving acdream-only
divergences — null classification during the login window and Rejected*
through UnroutedCatchUp keep a row. AD-42's RemoteTeleportController citation
retired; AP-136/AP-138 writer lists corrected to the two surviving non-Position
rebucket writers; AP-138 gains the teleport arm as a second producer of the
visible-without-collision residual (retirement path remains #309). AP-135 is
untouched and its two airborne bookkeeping writes are preserved on both arms.
AP-131 does not retire; #276 does not close.
Proof obligation 1: ParkCollisionResidents' overlap throw stays unreachable —
the teleport arm adds packets to the same TryBeginExclusiveAuthoredPlacement
one-operation-per-key machinery the far arm uses, opens no new operation shape,
and every DeferredCell outcome cancels synchronously with
restoreCancelledPark: true. The guarded property remains
HasOldPrefixPlacementDebt's stall, not a throw (4b-1's B2 caveat stands).
Correction to an earlier claim: LiveEntityPresentationController's
_activePlacementOwners was NOT write-never at HEAD —
remotePlacementRequired -> BeginPlacement -> Begin -> BeginAuthoritativePlacement
was a live writer chain. It becomes write-never BECAUSE this slice deletes that
chain, which is why deleting the dead half is behaviour-preserving.
Probe: ACDREAM_PROBE_REMOTE_TELEPORT=1 emits one [remote-teleport] line per
routed arm (guid, cause, hook-ran, placement status). TEMPORARY, strip with the
probe family.
Carried, disclosed not fixed: no dedicated bidirectional collision-partner test
for R2 (the wiring, not LeaveWorld itself, is what lacks coverage); the
stress test's teleport step drives hand-written field assignments rather than
the canonical arm; the per-packet runTeleportHook closure allocation (network
path, not the resolve path Slice I's 0 B discipline governs — file before
route 5 adds a fourth call site). B2: IRuntimeCollisionReportObserver has zero
production implementations, so retail's bidirectional DoCollisionEnd half still
reaches no gameplay consumer — this fix closes the wrong-function binding, not
that nobody listens.
Complete Release suite MEASURED at 11,013 passed / 4 skipped / 0 failed
(baseline 11,027/4/0; net -14 = ~33 deleted test cases against ~19 added).
Neither known flake fired (#302 PortalProjectionTests GC-allocation, #308
NakEmissionTests wall-clock).
STILL OWED: the two-client connected gate, which MUST use an NPC/creature
teleport target. Both round-1 MAJORs lived on the NPC arm and the velocity
cycle early-returns for 0x50xxxxxx guids, so a player target structurally
cannot observe A1, A2, or R3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1133 lines
50 KiB
C#
1133 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 & 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 > 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 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() { }
|
|
}
|
|
}
|
|
}
|