RuntimePlacementPresentationSink.TryPublishPlace previously published the local player's collision-shadow pose with a direct LocalPlayerShadowState.Set call — a plain cache write that never touched PhysicsEngine.ShadowObjects. Because LocalPlayerShadowSynchronizer.SyncPose's own dedup check compares against that same cache, the direct write could pre-seed the cache with the destination pose and cause the next real SyncPose call to see "nothing changed" and skip its own ShadowObjects publish — leaving the real collision shadow at the pre-teleport position until an unrelated movement tick forced a real publish. Fix: TryPublishPlace now calls _localPlayerShadowSync.SyncPose(..., force: true), the same publisher ordinary per-tick movement uses, so Place always drives a real ShadowObjects write before the cache updates. TryPublishWithdrawal carried the exact mirror asymmetry (a bare LocalPlayerShadowState.Clear with no ShadowObjects.Suspend, leaving a live phantom shadow row at the park's source cell for the whole park window — the #184 shape) and is fixed in the same commit, same one-call shape: _localPlayerShadowSync.Suspend(entity). The sink no longer holds a direct LocalPlayerShadowState reference; both halves route exclusively through the one synchronizer, which owns the cache internally. The single LocalPlayerShadowSynchronizer instance is now constructed in LivePresentationComposition (before the sink) and threaded through LivePresentationResult to SessionPlayerComposition, which no longer builds its own — this guarantees the sink's Place/Withdraw edge and ordinary per-tick movement publish through the exact same publisher and cache rather than two independent instances that could drift out of sync with each other. TryPublishPlace's xmldoc now states the behavioural nuance directly: routing through SyncPose means Place inherits SyncPose's own admission guard (IsHidden, cellId == 0, not-current-visible-projection), which the old direct .Set() call never consulted. Under those conditions SyncPose now calls Suspend instead of publishing — correct and symmetric, but new behaviour worth flagging at the call site, not just in a test comment. RuntimePlacementShadowCompositionTests.cs (#318) proves four facts against the real ShadowObjects registry, not the cache: a bare Place publishes a real row at the destination cell with the source cell's row gone; a subsequent ordinary per-tick Sync is then a correct no-op; a Place for a registered non-local-player entity leaves its row at the source cell untouched and never touches the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate); and Withdraw suspends the real registry row, not just the cache, with the retained (suspendable) registration surviving for a later restore. All four were sabotage-verified in both directions. RuntimeForcePositionRenderCommitTests.cs (B2) drives a real end-to-end accepted ForcePosition through RuntimeEntityObjectLifetime.TryApplyPosition and RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition against a live HostFixture, asserting both the committed render position AND a cell change that deliberately crosses out of the spawn's outdoor grid cell, so the cell assertion is independently falsifiable rather than riding along with the position assertion. Retires AP-145 (this fix) in docs/architecture/retail-divergence-register.md. AP-1 and AD-1 are untouched by this commit — they retire separately in the deletion-sweep commit that follows. Evidence chain: docs/research/2026-08-05-c5a-contract.md (the governing C5a slice contract), docs/research/2026-08-05-c5a-architecture-review.md (round 1, FAIL — three MAJORs: vacuous route-7 P4 test, unfixed Withdraw-side mirror asymmetry, non-driving B2 test), docs/research/2026-08-05-c5a-architecture-review-round2.md (round 2, PASS with two MINORs — an unfalsifiable B2 cell assertion and the undocumented SyncPose guard nuance, both fixed here). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
683 lines
30 KiB
C#
683 lines
30 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.World;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.Physics;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Plugins;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Physics;
|
|
using AcDream.Runtime.World;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.App.Tests.World;
|
|
|
|
/// <summary>
|
|
/// C5a §5.1 (2026-08-05) — the #318 composition test, landed WITH its fix
|
|
/// (coordinator override of the contract's original "red branch, fix lands
|
|
/// separately" plan — every commit in this campaign stays green). Drives a
|
|
/// REAL portal arrival through <see cref="RuntimePlacementPresentationSink"/>
|
|
/// AND the REAL <see cref="PhysicsEngine"/> / <see cref="LocalPlayerShadowSynchronizer"/>
|
|
/// pair, closing the gap every prior sink test left open: those tests never
|
|
/// wired a real <see cref="PhysicsEngine"/> at all, so they could only ever
|
|
/// assert on <see cref="LocalPlayerShadowState"/>'s dedup cache — which
|
|
/// AP-145's bug pre-seeded regardless of whether the real collision shadow
|
|
/// moved.
|
|
///
|
|
/// <para>
|
|
/// <b>AP-145, RETIRED by this commit:</b>
|
|
/// <c>RuntimePlacementPresentationSink.TryPublishPlace</c> used to call
|
|
/// <c>_localPlayerShadow.Set(...)</c> directly — a pure cache write — instead
|
|
/// of routing through <see cref="LocalPlayerShadowSynchronizer.SyncPose"/>,
|
|
/// the ONLY code path that calls <c>ShadowPositionSynchronizer.Sync</c> →
|
|
/// <c>ShadowObjectRegistry.UpdatePosition</c>, the real collision-shadow
|
|
/// publish. Two things went wrong together: (1) the portal jump's real
|
|
/// shadow entry never moved to the destination, and (2) the direct cache
|
|
/// write PRE-SEEDED <c>SyncPose</c>'s own dedup check, so even a later
|
|
/// per-tick <c>SyncPose</c> call would see "nothing changed" and skip the
|
|
/// publish it would otherwise have performed. A cache-only assertion — the
|
|
/// shape every prior sink test used — was satisfied by the bug: the cache
|
|
/// said the right thing, only the real registry didn't.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>The fix:</b> <c>TryPublishPlace</c> now calls
|
|
/// <c>_localPlayerShadowSync.SyncPose(entity, entity.Position, entity.Rotation,
|
|
/// record.FullCellId, force: true)</c> — the SAME publisher ordinary per-tick
|
|
/// movement uses, constructed once and shared (composition root:
|
|
/// <c>LivePresentationComposition.cs</c> now builds the ONE
|
|
/// <see cref="LocalPlayerShadowSynchronizer"/> instance BEFORE the sink and
|
|
/// threads it through <c>LivePresentationResult</c> to
|
|
/// <c>SessionPlayerComposition.cs</c>, which no longer constructs its own).
|
|
/// <c>force: true</c> because this is the authoritative placement commit,
|
|
/// not an ordinary per-tick refresh — it must never be skipped by the dedup
|
|
/// path. <see cref="ShadowObjectRegistry.Register"/> (which
|
|
/// <c>UpdatePosition</c> calls internally) deregisters every prior cell row
|
|
/// for the entity before adding the new ones, so the SOURCE cell's row is
|
|
/// replaced, not duplicated — verified explicitly below.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Architecture review A2:</b> the exact mirror asymmetry lived six lines
|
|
/// below the Place fix, on <c>TryPublishWithdrawal</c> — a bare
|
|
/// <c>_localPlayerShadow.Clear()</c> with no <c>ShadowObjects.Suspend</c>,
|
|
/// leaving a LIVE phantom collision row at the park's source cell for the
|
|
/// whole park window (the #184 shape). Fixed in this same commit, in the
|
|
/// same one-call shape: <c>TryPublishWithdrawal</c> now calls
|
|
/// <c>_localPlayerShadowSync.Suspend(entity)</c>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Architecture review A1:</b> the original route-7-P4 test
|
|
/// (<see cref="Place_ForNonLocalPlayerEntity_NeverTouchesShadowObjects"/>)
|
|
/// never registered the non-player entity, so
|
|
/// <c>ShadowObjectRegistry.UpdatePosition</c>'s own not-registered early
|
|
/// return made every assertion pass whether or not the player-only gate
|
|
/// existed — it was vacuous on both branches. Corrected to establish a REAL
|
|
/// baseline registration first, so removing the gate would actually move the
|
|
/// row and actually pollute the player's cache.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Sabotage-verified (manual, all four facts, both directions):</b> with
|
|
/// each fix/gate reverted in turn, the corresponding fact fails at exactly
|
|
/// its discriminating assertion; with the fix applied, all four are green.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class RuntimePlacementShadowCompositionTests
|
|
{
|
|
private const uint SourceCell = 0x01010001u;
|
|
private const uint DestinationCell = 0x01020001u;
|
|
private const uint Guid = 0x7000A101u;
|
|
private static readonly Vector3 SourcePosition = new(10f, 10f, 5f);
|
|
// Landblock-local (10,10) relative to the destination landblock's
|
|
// worldOffsetX=192 — falls in the same grid cell (0x0001, cx=0,cy=0,
|
|
// TerrainSurface.CellSize=24) as SourcePosition does in ITS landblock,
|
|
// so ShadowObjectRegistry's flood actually lands under DestinationCell.
|
|
private static readonly Vector3 DestinationPosition = new(202f, 10f, 5f);
|
|
private static readonly Vector3 WirePoseDoubleWrite = new(-900f, -900f, -900f);
|
|
private static readonly Quaternion DestinationOrientation =
|
|
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
|
|
|
|
/// <summary>
|
|
/// The discriminating composition. Establishes a REAL baseline shadow
|
|
/// registration at the source pose (proving the synchronizer mechanism
|
|
/// works in general), drives a real portal arrival through the sink
|
|
/// (T8: with a tolerated "wire pose" double-write already sitting on the
|
|
/// entity, exactly like <c>LiveEntityNetworkUpdateController</c>'s early
|
|
/// generic write — the committed suffix must overwrite it), then checks
|
|
/// the dedup cache (informational negative control — would pass even
|
|
/// under the AP-145 bug, kept to make the sabotage argument
|
|
/// self-contained), the REAL <see cref="PhysicsEngine.ShadowObjects"/>
|
|
/// registry at the destination (the load-bearing assertion — this is
|
|
/// what the fix makes true), AND that the source cell's row is gone, not
|
|
/// duplicated.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Place_PublishesRealPhysicsShadowAtDestination_NotOnlyTheDedupCache()
|
|
{
|
|
Fixture fixture = Fixture.Create();
|
|
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
|
|
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
|
|
|
|
// Baseline: establish the player's REAL collision shadow at the
|
|
// source pose exactly like ordinary world entry does, and prove the
|
|
// synchronizer mechanism is not itself broken (sanity, not the gate).
|
|
fixture.Physics.ShadowObjects.Register(
|
|
entity.Id,
|
|
gfxObjId: entity.SourceGfxObjOrSetupId,
|
|
worldPos: SourcePosition,
|
|
rotation: Quaternion.Identity,
|
|
radius: 0.48f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: SourceCell & 0xFFFF0000u,
|
|
collisionType: ShadowCollisionType.Sphere,
|
|
cylHeight: 1.835f,
|
|
seedCellId: SourceCell);
|
|
fixture.Synchronizer.Sync(entity, SourceCell, force: true);
|
|
Assert.Contains(
|
|
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
|
|
e => e.EntityId == entity.Id);
|
|
|
|
// T8 (route 3 §8 item 9): model the tolerated generic render-pose
|
|
// write (LiveEntityNetworkUpdateController.cs:2284-2314) landing on
|
|
// the entity BEFORE the committed placement suffix runs. The
|
|
// intermediate "wire pose" must never leak past the Place edge.
|
|
entity.Position = WirePoseDoubleWrite;
|
|
entity.Rotation = Quaternion.Identity;
|
|
|
|
// A real portal arrival through the canonical placement authority.
|
|
RuntimePortalPlacementAuthority portal = fixture.BeginPortal(
|
|
DestinationCell,
|
|
teleportSequence: 1);
|
|
record.FullCellId = DestinationCell;
|
|
record.CanonicalLandblockId = (DestinationCell & 0xFFFF0000u) | 0xFFFFu;
|
|
record.Canonical.AdvancePlacementCommit();
|
|
RuntimePlacementProjectionSnapshot place = Placement(
|
|
fixture,
|
|
record,
|
|
portal,
|
|
DestinationPosition,
|
|
DestinationOrientation);
|
|
|
|
Assert.True(fixture.Sink.TryApply(in place));
|
|
|
|
// T8 END STATE: the committed suffix's resolved pose wins — the
|
|
// intermediate wire-pose write never leaks past the Place edge.
|
|
Assert.Equal(DestinationPosition, entity.Position);
|
|
Assert.Equal(DestinationOrientation, entity.Rotation);
|
|
Assert.Equal(DestinationCell, entity.ParentCellId);
|
|
|
|
// NEGATIVE CONTROL — deliberately NOT the gate. This is the exact
|
|
// assertion shape every prior sink test uses
|
|
// (RuntimePlacementPresentationSinkTests.Place_Reframes...); it
|
|
// would pass EVEN under the AP-145 bug, since the cache is what the
|
|
// buggy direct write updated. Kept here to make the sabotage
|
|
// argument self-contained: if this assertion were the only
|
|
// coverage, the bug would have been invisible.
|
|
Assert.Equal(
|
|
new LocalPlayerShadowState.Snapshot(
|
|
DestinationPosition,
|
|
DestinationOrientation,
|
|
DestinationCell),
|
|
fixture.LocalShadow.Current);
|
|
|
|
// THE DISCRIMINATING ASSERTION — this is what AP-145's bug broke and
|
|
// the fix restores: the real collision shadow used by every OTHER
|
|
// entity's collision sweep now actually followed the portal jump.
|
|
Assert.Contains(
|
|
fixture.Physics.ShadowObjects.GetObjectsInCell(DestinationCell),
|
|
e => e.EntityId == entity.Id);
|
|
|
|
// No double-publish, no stale row left behind: Register/UpdatePosition
|
|
// deregisters every prior cell row before adding the new ones, so the
|
|
// SOURCE cell must carry zero rows for this entity now.
|
|
Assert.DoesNotContain(
|
|
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
|
|
e => e.EntityId == entity.Id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The second half of AP-145's mechanism, and the second half of the
|
|
/// fix's proof: the OLD direct cache write did not merely skip the
|
|
/// publish once — it pre-seeded
|
|
/// <see cref="LocalPlayerShadowSynchronizer.SyncPose"/>'s own dedup
|
|
/// check, so even a SUBSEQUENT ordinary per-tick <c>Sync</c> call (the
|
|
/// thing that would normally self-heal a one-frame miss) saw "nothing
|
|
/// changed" and skipped the publish too. With the fix, <c>TryPublishPlace</c>
|
|
/// itself now runs the real publish through the SAME synchronizer, so
|
|
/// the subsequent ordinary tick's dedup skip is no longer a bug — it is
|
|
/// CORRECTLY a no-op, because the real registry is already right.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Place_ThenOrdinaryTick_DoesNotNeedToSelfHeal_RealShadowAlreadyRight()
|
|
{
|
|
Fixture fixture = Fixture.Create();
|
|
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
|
|
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
|
|
fixture.Physics.ShadowObjects.Register(
|
|
entity.Id,
|
|
gfxObjId: entity.SourceGfxObjOrSetupId,
|
|
worldPos: SourcePosition,
|
|
rotation: Quaternion.Identity,
|
|
radius: 0.48f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: SourceCell & 0xFFFF0000u,
|
|
collisionType: ShadowCollisionType.Sphere,
|
|
cylHeight: 1.835f,
|
|
seedCellId: SourceCell);
|
|
fixture.Synchronizer.Sync(entity, SourceCell, force: true);
|
|
|
|
RuntimePortalPlacementAuthority portal = fixture.BeginPortal(
|
|
DestinationCell,
|
|
teleportSequence: 1);
|
|
record.FullCellId = DestinationCell;
|
|
record.CanonicalLandblockId = (DestinationCell & 0xFFFF0000u) | 0xFFFFu;
|
|
record.Canonical.AdvancePlacementCommit();
|
|
RuntimePlacementProjectionSnapshot place = Placement(
|
|
fixture,
|
|
record,
|
|
portal,
|
|
DestinationPosition,
|
|
DestinationOrientation);
|
|
Assert.True(fixture.Sink.TryApply(in place));
|
|
|
|
// Simulate the very next ordinary frame's shadow-sync tick — exactly
|
|
// what production runs every frame for the local player. Not forced:
|
|
// this is the real per-tick call shape, dedup and all. It is
|
|
// EXPECTED to be a no-op now: TryPublishPlace's own SyncPose call
|
|
// already did the real work, so the cache already matches.
|
|
fixture.Synchronizer.Sync(entity, DestinationCell);
|
|
|
|
Assert.Contains(
|
|
fixture.Physics.ShadowObjects.GetObjectsInCell(DestinationCell),
|
|
e => e.EntityId == entity.Id);
|
|
Assert.DoesNotContain(
|
|
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
|
|
e => e.EntityId == entity.Id);
|
|
// Exactly one row for this entity anywhere — the ordinary tick's
|
|
// dedup no-op did not create a second one.
|
|
Assert.Single(
|
|
fixture.Physics.ShadowObjects.AllEntriesForDebug(),
|
|
e => e.EntityId == entity.Id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Route 7 P4 still binds: a committed CHILD (or any non-local-player
|
|
/// entity) must not gain a broadphase row, and must not have its OWN
|
|
/// pose written into the PLAYER's dedup cache. The fix lives entirely
|
|
/// inside <c>TryPublishPlace</c>'s pre-existing
|
|
/// <c>record.ServerGuid == _localPlayerGuid()</c> gate — unchanged by
|
|
/// this fix, only what runs INSIDE it changed.
|
|
///
|
|
/// <para>
|
|
/// C5a architecture review A1 (2026-08-05): the FIRST version of this
|
|
/// test registered nothing for the child, so
|
|
/// <c>ShadowObjectRegistry.UpdatePosition</c>'s own
|
|
/// not-registered early return (<c>ShadowObjectRegistry.cs:696</c>) made
|
|
/// every assertion pass whether or not the gate existed — removing the
|
|
/// gate was unobservable because the sabotaged code path was ALSO a
|
|
/// no-op. This version establishes a REAL baseline registration for the
|
|
/// child first (mirroring fact 1's own baseline), so a gate-removed
|
|
/// sabotage would actually move the child's row and would actually
|
|
/// pollute the player's cache — both of which the assertions below now
|
|
/// check directly, not by absence of registration.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void Place_ForNonLocalPlayerEntity_NeverTouchesShadowObjects()
|
|
{
|
|
const uint ChildGuid = 0x7000A102u;
|
|
Fixture fixture = Fixture.Create();
|
|
LiveEntityRecord record = fixture.Materialize(Spawn(ChildGuid, 1, SourceCell));
|
|
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
|
|
Assert.NotEqual(Guid, ChildGuid);
|
|
|
|
// REAL baseline: the child has its own genuine collision shadow at
|
|
// the source pose, exactly like fact 1 establishes for the player.
|
|
// A sabotaged (gate-removed) TryPublishPlace WOULD move this row to
|
|
// the destination and WOULD write the child's pose into the
|
|
// player's LocalShadow cache — both are asserted against below.
|
|
fixture.Physics.ShadowObjects.Register(
|
|
entity.Id,
|
|
gfxObjId: entity.SourceGfxObjOrSetupId,
|
|
worldPos: SourcePosition,
|
|
rotation: Quaternion.Identity,
|
|
radius: 0.48f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: SourceCell & 0xFFFF0000u,
|
|
collisionType: ShadowCollisionType.Sphere,
|
|
cylHeight: 1.835f,
|
|
seedCellId: SourceCell);
|
|
Assert.Contains(
|
|
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
|
|
e => e.EntityId == entity.Id);
|
|
|
|
RuntimePortalPlacementAuthority portal = fixture.BeginPortal(
|
|
DestinationCell,
|
|
teleportSequence: 1);
|
|
record.FullCellId = DestinationCell;
|
|
record.CanonicalLandblockId = (DestinationCell & 0xFFFF0000u) | 0xFFFFu;
|
|
record.Canonical.AdvancePlacementCommit();
|
|
RuntimePlacementProjectionSnapshot place = Placement(
|
|
fixture,
|
|
record,
|
|
portal,
|
|
DestinationPosition,
|
|
DestinationOrientation);
|
|
|
|
Assert.True(fixture.Sink.TryApply(in place));
|
|
|
|
// The render entity DID move (Place still works for a non-player
|
|
// entity) — only the shadow-publish branch is player-gated.
|
|
Assert.Equal(DestinationPosition, entity.Position);
|
|
|
|
// THE DISCRIMINATING ASSERTIONS. Under a sabotaged (gate-removed)
|
|
// TryPublishPlace, SyncPose would run for the child: the child's own
|
|
// record IS its own current visible projection, so
|
|
// IsCurrentVisibleProjection would be true and the real publish
|
|
// would execute — moving the row to DestinationCell and vacating
|
|
// SourceCell (exactly what fact 1 asserts is CORRECT for the
|
|
// player). Here it must NOT happen — this is what tells the
|
|
// sabotage apart from the fix.
|
|
Assert.Contains(
|
|
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
|
|
e => e.EntityId == entity.Id);
|
|
Assert.DoesNotContain(
|
|
fixture.Physics.ShadowObjects.GetObjectsInCell(DestinationCell),
|
|
e => e.EntityId == entity.Id);
|
|
Assert.Equal(1, fixture.Physics.ShadowObjects.TotalRegistered);
|
|
|
|
// The player's OWN dedup cache must stay untouched by a non-player
|
|
// Place — a sabotaged TryPublishPlace would call
|
|
// _localPlayerShadowSync.SyncPose using the CHILD's guid check but
|
|
// the PLAYER's cache field (the gate exists to prevent exactly a
|
|
// non-player entity's pose from being written where the player's
|
|
// pose belongs).
|
|
Assert.Null(fixture.LocalShadow.Current);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Architecture review A2 (2026-08-05): the exact mirror of AP-145's
|
|
/// Place-side bug lived six lines below it, on the <c>Withdraw</c> path
|
|
/// — <c>TryPublishWithdrawal</c> cleared only the dedup cache, leaving a
|
|
/// LIVE phantom row in <see cref="PhysicsEngine.ShadowObjects"/> at the
|
|
/// park's source cell for the whole park window (the #184 shape: every
|
|
/// other entity's collision sweep in that cell would collide with a
|
|
/// player who is, per every other acdream predicate, gone). Fixed in the
|
|
/// same commit as the Place half, via the SAME one-call shape:
|
|
/// <c>TryPublishWithdrawal</c> now calls
|
|
/// <c>_localPlayerShadowSync.Suspend(entity)</c>, which does the real
|
|
/// registry suspend AND the cache clear together.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Withdraw_SuspendsRealPhysicsShadow_NotOnlyTheDedupCache()
|
|
{
|
|
Fixture fixture = Fixture.Create();
|
|
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
|
|
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
|
|
|
|
// Baseline: a real collision shadow at the source pose, exactly like
|
|
// ordinary world entry / fact 1 establishes.
|
|
fixture.Physics.ShadowObjects.Register(
|
|
entity.Id,
|
|
gfxObjId: entity.SourceGfxObjOrSetupId,
|
|
worldPos: SourcePosition,
|
|
rotation: Quaternion.Identity,
|
|
radius: 0.48f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: SourceCell & 0xFFFF0000u,
|
|
collisionType: ShadowCollisionType.Sphere,
|
|
cylHeight: 1.835f,
|
|
seedCellId: SourceCell);
|
|
fixture.Synchronizer.Sync(entity, SourceCell, force: true);
|
|
Assert.Contains(
|
|
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
|
|
e => e.EntityId == entity.Id);
|
|
Assert.NotNull(fixture.LocalShadow.Current);
|
|
|
|
RuntimePlacementProjectionSnapshot withdraw = Placement(
|
|
fixture,
|
|
record,
|
|
portal: default,
|
|
entity.Position,
|
|
entity.Rotation,
|
|
RuntimePlacementProjectionKind.Withdraw);
|
|
|
|
Assert.True(fixture.Sink.TryApply(in withdraw));
|
|
|
|
// THE DISCRIMINATING ASSERTIONS. Under the pre-fix bug, the cache
|
|
// clears (this passes either way) but the real registry keeps the
|
|
// SOURCE-cell row (a live phantom for the park's duration) — that is
|
|
// what the sabotage below must be able to catch.
|
|
Assert.Null(fixture.LocalShadow.Current);
|
|
Assert.DoesNotContain(
|
|
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
|
|
e => e.EntityId == entity.Id);
|
|
// Suspend removes the entity from every cell bucket (TotalRegistered
|
|
// — what any collision sweep can find) but is deliberately NOT
|
|
// logical teardown (ShadowObjectRegistry.Suspend's own xmldoc: "the
|
|
// registry counterpart of retail CPhysicsObj::remove_shadows_from_cells
|
|
// during temporary leave-world/pending-cell residence"), so the
|
|
// RETAINED registration survives for TryPublishPlace's later
|
|
// force:true SyncPose (WithdrawalRestored, or a fresh Place) to
|
|
// re-publish from.
|
|
Assert.Equal(0, fixture.Physics.ShadowObjects.TotalRegistered);
|
|
Assert.Equal(1, fixture.Physics.ShadowObjects.RetainedRegistrationCount);
|
|
}
|
|
|
|
private static RuntimePlacementProjectionSnapshot Placement(
|
|
Fixture fixture,
|
|
LiveEntityRecord record,
|
|
RuntimePortalPlacementAuthority portal,
|
|
Vector3 position,
|
|
Quaternion orientation,
|
|
RuntimePlacementProjectionKind kind = RuntimePlacementProjectionKind.Place)
|
|
{
|
|
RuntimeEntityRecord canonical = record.Canonical;
|
|
var token = new RuntimePlacementProjectionToken(
|
|
Sequence: 1,
|
|
Revision: 1,
|
|
Entity: record.ProjectionKey!.Value,
|
|
PositionAuthorityVersion: canonical.PositionAuthorityVersion,
|
|
SpatialAuthorityVersion: canonical.SpatialAuthorityVersion,
|
|
PlacementCommitVersion: canonical.PlacementCommitVersion,
|
|
SessionLifetimeVersion: fixture.Runtime.SessionLifetimeVersion,
|
|
ExactCellId: canonical.FullCellId,
|
|
CollisionGeneration: 1,
|
|
Portal: portal);
|
|
return new RuntimePlacementProjectionSnapshot(
|
|
token,
|
|
kind,
|
|
position,
|
|
orientation,
|
|
CellLocalPosition: position,
|
|
InContact: false,
|
|
OnWalkable: false);
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn Spawn(
|
|
uint guid,
|
|
ushort instance,
|
|
uint cell)
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
Position: 1,
|
|
Movement: 1,
|
|
State: 1,
|
|
Vector: 1,
|
|
Teleport: 0,
|
|
ServerControlledMove: 1,
|
|
ForcePosition: 0,
|
|
ObjDesc: 1,
|
|
Instance: instance);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
guid,
|
|
position,
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
null,
|
|
null,
|
|
"fixture",
|
|
null,
|
|
null,
|
|
0x09000001u,
|
|
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
InstanceSequence: instance,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
|
|
private sealed class Fixture
|
|
{
|
|
private Fixture(
|
|
PhysicsEngine physics,
|
|
GpuWorldState spatial,
|
|
LiveEntityRuntime runtime,
|
|
RuntimeWorldTransitState transit,
|
|
WorldGameState worldState,
|
|
WorldEvents worldEvents,
|
|
EntityEffectPoseRegistry effectPoses,
|
|
LocalPlayerShadowState localShadow,
|
|
LocalPlayerShadowSynchronizer synchronizer)
|
|
{
|
|
Physics = physics;
|
|
Spatial = spatial;
|
|
Runtime = runtime;
|
|
Transit = transit;
|
|
WorldState = worldState;
|
|
WorldEvents = worldEvents;
|
|
EffectPoses = effectPoses;
|
|
LocalShadow = localShadow;
|
|
Synchronizer = synchronizer;
|
|
Sink = new RuntimePlacementPresentationSink(
|
|
runtime,
|
|
transit,
|
|
worldState,
|
|
worldEvents,
|
|
effectPoses,
|
|
synchronizer,
|
|
() => Guid,
|
|
_ => { },
|
|
[(_, _) => { }]);
|
|
}
|
|
|
|
internal PhysicsEngine Physics { get; }
|
|
internal GpuWorldState Spatial { get; }
|
|
internal LiveEntityRuntime Runtime { get; }
|
|
internal RuntimeWorldTransitState Transit { get; }
|
|
internal WorldGameState WorldState { get; }
|
|
internal WorldEvents WorldEvents { get; }
|
|
internal EntityEffectPoseRegistry EffectPoses { get; }
|
|
internal LocalPlayerShadowState LocalShadow { get; }
|
|
internal LocalPlayerShadowSynchronizer Synchronizer { get; }
|
|
internal RuntimePlacementPresentationSink Sink { get; }
|
|
|
|
internal static Fixture Create()
|
|
{
|
|
var physics = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
|
physics.AddLandblock(
|
|
SourceCell & 0xFFFF0000u,
|
|
new TerrainSurface(new byte[81], new float[256]),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
physics.AddLandblock(
|
|
DestinationCell & 0xFFFF0000u,
|
|
new TerrainSurface(new byte[81], new float[256]),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 192f,
|
|
worldOffsetY: 0f);
|
|
|
|
var spatial = new GpuWorldState();
|
|
spatial.AddLandblock(EmptyLandblock(SourceCell | 0xFFFFu));
|
|
spatial.AddLandblock(EmptyLandblock(DestinationCell | 0xFFFFu));
|
|
var resources = new RecordingResources();
|
|
LiveEntityRuntime runtime = LiveEntityRuntimeFixture.Create(
|
|
spatial,
|
|
resources,
|
|
physics);
|
|
|
|
var identity = new LocalPlayerIdentityState { ServerGuid = Guid };
|
|
var origin = new LiveWorldOriginState();
|
|
origin.SetPlaceholder(0, 0);
|
|
var localShadow = new LocalPlayerShadowState();
|
|
var synchronizer = new LocalPlayerShadowSynchronizer(
|
|
physics,
|
|
runtime,
|
|
identity,
|
|
origin,
|
|
localShadow);
|
|
|
|
return new Fixture(
|
|
physics,
|
|
spatial,
|
|
runtime,
|
|
new RuntimeWorldTransitState(),
|
|
new WorldGameState(),
|
|
new WorldEvents(),
|
|
new EntityEffectPoseRegistry(),
|
|
localShadow,
|
|
synchronizer);
|
|
}
|
|
|
|
internal LiveEntityRecord Materialize(WorldSession.EntitySpawn spawn)
|
|
{
|
|
LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(spawn);
|
|
Assert.False(Runtime.HasActiveInitialCreateResidence(
|
|
record.Canonical));
|
|
Assert.True(record.ResourcesRegistered);
|
|
WorldEntity entity = record.WorldEntity!;
|
|
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
|
entity.Id,
|
|
entity.SourceGfxObjOrSetupId,
|
|
entity.Position,
|
|
entity.Rotation);
|
|
WorldState.Add(snapshot);
|
|
WorldEvents.UpsertCurrent(snapshot);
|
|
EffectPoses.PublishMeshRefs(entity);
|
|
return record;
|
|
}
|
|
|
|
internal RuntimePortalPlacementAuthority BeginPortal(
|
|
uint cell,
|
|
ushort teleportSequence)
|
|
{
|
|
Assert.True(Transit.TryQueueTeleportStart(teleportSequence));
|
|
Assert.True(Transit.ActivateQueuedTeleport());
|
|
Assert.True(Transit.OfferTeleportDestination(
|
|
new RuntimeTeleportDestination(
|
|
Guid,
|
|
InstanceSequence: 1,
|
|
PositionSequence: 1,
|
|
TeleportSequence: teleportSequence,
|
|
ForcePositionSequence: 1,
|
|
new Position(
|
|
cell,
|
|
new Vector3(1f, 2f, 3f),
|
|
Quaternion.Identity)),
|
|
teleportTimestampAdvanced: true));
|
|
Assert.True(Transit.TryBeginPortalReveal(
|
|
teleportSequence,
|
|
cell,
|
|
out long generation));
|
|
Assert.True(Transit.TryRegisterHostProjection(
|
|
generation,
|
|
cell,
|
|
out RuntimeWorldHostProjectionToken host));
|
|
return new RuntimePortalPlacementAuthority(
|
|
true,
|
|
generation,
|
|
teleportSequence,
|
|
host);
|
|
}
|
|
|
|
private static LoadedLandblock EmptyLandblock(uint canonicalId) =>
|
|
new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>());
|
|
}
|
|
|
|
private sealed class RecordingResources : ILiveEntityResourceLifecycle
|
|
{
|
|
public void Register(WorldEntity entity) { }
|
|
public void Unregister(WorldEntity entity) { }
|
|
}
|
|
}
|