USER-DIRECTED deviation from retail (register row AD-109, same commit): retail presents the empty pre-player gameplay screen — black behind the retained UI — from the Enter click (CPlayerSystem::LogOnCharacter @0x0055F890 -> CM_Login::SendNotice_BeginEnterWorld @0x006AD810, UI mode 0x10000008) until CreatePlayer raises SmartBox::teleport_in_progress @0x00451C20 and gmSmartBoxUI::UseTime @0x004D6EAB begins TAS_TUNNEL. The user prefers the tunnel to cover that whole wait. - ILocalPlayerTeleportNetworkSink.ArmLoginTunnel: begins the login wormhole presentation at the Enter click, consuming the sequencer's begin-edge events SYNCHRONOUSLY (the Enter command blocks the update thread for the whole ServerReady round trip, so a deferred first tick would leave exactly the black window this deviation removes). The enter cue plays at the click: retail's own rule is cue-at-animation- begin (Sound_UI_EnterPortal @0x004D638E, unconditional inside BeginTeleportAnimation), and the animation begin moved to the click. - Armed pre-reveal pump: tunnel animates across the round trip (worldReady pinned false, sequencer holds in Tunnel); the hold clock accumulates from the click. - Adoption: the Runtime login reveal ADOPTS the running presentation (no re-Begin, no second cue); rejected EnterWorld (lifecycle back to AwaitingSelection) disarms and retires the tunnel. - Wired at the ONE host edge every entry route shares: ILiveSessionLifecycleHost.ApplySelectedCharacter (direct connect, roster Enter, enter-after-create) via LiveSessionSelectionBindings.ArmLoginTunnel (default no-op keeps headless and every existing construction site unchanged). - ILocalPlayerLoginLifecycleSource: typed seam (not a stored delegate — the frame-phase owner delegate-field guard) projecting the Runtime character-selection lifecycle for the disarm edge. - Frame contract update: [login-frames] over a login is tunnel -> world from the click — no void, and no black between click and world. Tests: 4 new armed-tunnel tests (arm/adopt/disarm/frame-shape); App suite live-DAT 5516 passed / 3 skipped (baseline 5512/3 + 4 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1884 lines
84 KiB
C#
1884 lines
84 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;
|
|
using DatReaderWriter.Types;
|
|
|
|
// Alias the DatReaderWriter enum so it doesn't clash with
|
|
// AcDream.Core.Physics.MotionCommand (a static class of uint constants).
|
|
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
|
|
|
namespace AcDream.App.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// The OnPosition collapse's referee (contract §5,
|
|
/// docs/research/2026-08-04-onposition-collapse-contract.md): every scenario
|
|
/// below is driven for BOTH a <c>0x50xxxxxx</c> player-range guid and a
|
|
/// <c>0x8xxxxxxx</c> creature-range guid through the SAME production
|
|
/// <see cref="LiveEntityNetworkUpdateController.OnPosition"/> entry point —
|
|
/// never the extracted <c>ApplyRemoteContactRouting</c> seam directly. Before
|
|
/// the collapse this class did not exist as a structural pattern: every
|
|
/// existing test in this file's siblings drove exactly one guid range, which
|
|
/// is precisely how the 4b-3 defects (A1's zero-arm leash regression, A2/R3's
|
|
/// synthesized-velocity defect) survived — a defect in one hand-written copy
|
|
/// left the other copy's tests green. After the collapse there is one code
|
|
/// path, so running both guids through it is cheap; the point is it STAYS a
|
|
/// pair so a future re-divergence (a guid-gated edit smuggled into the
|
|
/// unified tail) fails a test instead of hiding.
|
|
///
|
|
/// <para>
|
|
/// This class reuses the <see cref="LiveEntityNetworkRemoteTeleportPresentationTests"/>
|
|
/// fixture's exact composition-only construction pattern (the controller's
|
|
/// 67+ collaborators are wired only by <c>SessionPlayerComposition</c> in
|
|
/// production, so a source pin would be the alternative — this drives the
|
|
/// real class instead).
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|
{
|
|
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);
|
|
|
|
/// <summary>Player-range guid — <c>0x50xxxxxx</c>, distinct from the
|
|
/// fixture's own local-player <c>NoopIdentitySource.ServerGuid</c>
|
|
/// (<c>0x50000099u</c>).</summary>
|
|
private const uint PlayerGuid = 0x50007001u;
|
|
|
|
/// <summary>Creature-range guid — <c>0x8xxxxxxx</c>, matching the
|
|
/// connected-gate evidence in the 4b-3 contract for how real creature
|
|
/// guids arrive on the wire.</summary>
|
|
private const uint CreatureGuid = 0x80007001u;
|
|
|
|
private const float SpawnHeight = 7f;
|
|
private const float FootSphereCenterLift = 0.48f;
|
|
|
|
private static bool IsPlayer(uint guid) => guid == PlayerGuid;
|
|
|
|
// ── Scenario 0: C5b's untested load-bearing claim ───────────────────
|
|
|
|
/// <summary>
|
|
/// C5b (#275) review, findings L1/L2. C5b made the steady-state merge stop
|
|
/// stamping the wire cell and justified it with a claim it never tested:
|
|
/// <i>"production installs the bucket at W2 in the same call (verified: no
|
|
/// return between the recovery call and W2 is conditioned on
|
|
/// IsSpatiallyProjected or FullCellId)"</i>. The commit also asserted "no
|
|
/// fixture covers pickup at that layer" — this file drives the real
|
|
/// <see cref="LiveEntityNetworkUpdateController.OnPosition"/> at ~26 call
|
|
/// sites, and every W2 assertion the commit added hand-calls
|
|
/// <c>RebucketLiveEntity</c>/<c>CommitRebucket</c> itself, so nothing
|
|
/// drove the sequence end-to-end. This does.
|
|
///
|
|
/// <para>The scenario is the leave-world/re-entry edge the production
|
|
/// comment at <c>LiveEntityNetworkUpdateController.cs:2073-2077</c> names:
|
|
/// the record keeps <c>WorldEntity</c> as its logical/render-resource
|
|
/// owner while <c>IsSpatiallyProjected</c> is false, and "a fresh retail
|
|
/// Position is the re-entry edge; testing only for the retained object
|
|
/// reference leaves dropped inventory permanently invisible after
|
|
/// InventoryPutObjectIn3D". The materializer DECLINES (the post-C5b
|
|
/// self-projection state — <c>DatLiveEntityProjectionMaterializer.cs:767</c>
|
|
/// now sees a canonical cell the merge did not advance), so the only
|
|
/// things that can restore the bucket and the cell are the two wire-cell
|
|
/// channels AD-60 says deliberately survive C5b.</para>
|
|
///
|
|
/// <para><b>Measured at the review, and NOT what C5b's commit message
|
|
/// assumed: W2 and W3 are REDUNDANT here.</b> Sabotaging W2 alone (both
|
|
/// "adopt the committed cell instead of the wire cell" and "skip the
|
|
/// rebucket entirely") leaves this test — and every other test in this
|
|
/// file — GREEN, because the post-routing wire-cell adopt
|
|
/// (<c>TryAdoptWireCellAfterRouting</c>, W3/AP-135) writes
|
|
/// <c>RemoteMotion.CellId</c>, which reads through to canonical
|
|
/// <c>FullCellId</c> via <c>CommitCanonicalCell</c>, whose graphical
|
|
/// <c>CellCommitted</c> recovery re-installs the bucket. Only removing
|
|
/// BOTH surviving channels turns this red — and it is then the ONLY red
|
|
/// in the file. So what this test pins is AD-60's actual surviving claim
|
|
/// ("two wire-cell writers deliberately remain downstream of the merge"),
|
|
/// not W2 in isolation. Naming it after W2 would have been a third
|
|
/// contract asserting a mechanism that is not the one doing the work.
|
|
/// </para>
|
|
///
|
|
/// <para>Both guid classes per this file's own discipline.</para>
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(PlayerGuid)]
|
|
[InlineData(CreatureGuid)]
|
|
public void WithdrawnProjection_AcceptedPositionRestoresBucketAndWireCell(
|
|
uint guid)
|
|
{
|
|
using var fixture = new Fixture(guid, decliningMaterializer: true);
|
|
Assert.True(fixture.Runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
|
Assert.True(record.IsSpatiallyProjected);
|
|
Assert.Equal(SourceCell, record.FullCellId);
|
|
|
|
// The withdrawal: render bucket gone, logical record + WorldEntity
|
|
// retained. This is what makes RequiresSpatialProjectionRecovery true
|
|
// on the next accepted Position.
|
|
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(guid));
|
|
Assert.False(record.IsSpatiallyProjected);
|
|
Assert.NotNull(record.WorldEntity);
|
|
Assert.Equal(SourceCell, record.FullCellId);
|
|
|
|
const uint WireCell = SourceLandblock | 0x0002u;
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
new Vector3(13f, 15f, SpawnHeight),
|
|
WireCell,
|
|
teleportSequence: 1,
|
|
guid: guid));
|
|
|
|
// The recovery branch really ran and really declined — without this
|
|
// the assertions below could pass on an entity that never needed
|
|
// recovering at all.
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
guid, out RuntimeEntityRecord canonical));
|
|
// The packet really was accepted and really did reach the recovery
|
|
// branch, which really did decline — without these the cell assertions
|
|
// below could pass on a rejected packet, or on an entity that never
|
|
// needed recovering at all.
|
|
Assert.Equal(
|
|
WireCell,
|
|
canonical.Snapshot.Position!.Value.LandblockId);
|
|
Assert.True(fixture.MaterializerDeclined);
|
|
|
|
// W2 is what closes both halves.
|
|
Assert.True(record.IsSpatiallyProjected);
|
|
Assert.Equal(WireCell, record.FullCellId);
|
|
Assert.Equal(WireCell, canonical.FullCellId);
|
|
Assert.Equal(WireCell, fixture.Entity.ParentCellId);
|
|
}
|
|
|
|
// ── Scenario 1: teleport commit ─────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(PlayerGuid)]
|
|
[InlineData(CreatureGuid)]
|
|
public void TeleportCommit_BothGuids_ArmsOnceAndNeverInstallsVelocity(uint guid)
|
|
{
|
|
using var fixture = new Fixture(guid);
|
|
fixture.PublishDestinationCollision();
|
|
fixture.ServiceWindow.Allow(DestinationLandblock);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
Assert.Null(host.PositionManager.Constraint);
|
|
var destination = new Vector3(12f, 14f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
destination, DestinationCell, teleportSequence: 5, guid: guid));
|
|
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
guid, 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(body.Position, fixture.Entity.Position);
|
|
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
|
Assert.Equal(DestinationCell, canonical.FullCellId);
|
|
|
|
// The leash armed exactly once (D4: teleport arms on every outcome).
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
|
|
// Row 6/invariant-6 (contract §5 scenario 1): retail's teleport
|
|
// branch writes NO velocity at all, for either guid.
|
|
Assert.False(fixture.Remote.HasServerVelocity);
|
|
Assert.Equal(Vector3.Zero, fixture.Remote.ServerVelocity);
|
|
|
|
// The shadow published at the resolved position.
|
|
ShadowEntry shadowEntry = Assert.Single(
|
|
fixture.Shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == fixture.Entity.Id);
|
|
Assert.Equal(body.Position, shadowEntry.Position);
|
|
|
|
fixture.DrainPlacementFifo();
|
|
}
|
|
|
|
// ── Scenario 2: landing packet (the preserved rows 2a/2b asymmetry) ─
|
|
|
|
[Fact]
|
|
public void LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved()
|
|
{
|
|
using var fixture = new Fixture(PlayerGuid);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
Assert.Null(host.PositionManager.Constraint);
|
|
// Mid-arc: body not in contact, wire IS grounded — the LANDING
|
|
// scenario. Pre-populate the interp queue so the clear is observable.
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active;
|
|
fixture.Remote.Interp.Enqueue(
|
|
new Vector3(1f, 1f, SpawnHeight),
|
|
Quaternion.Identity,
|
|
isMovingTo: false,
|
|
currentBodyPosition: new Vector3(50f, 50f, SpawnHeight),
|
|
currentBodyOrientation: Quaternion.Identity);
|
|
Assert.True(fixture.Remote.Interp.IsActive);
|
|
Vector3 spawnShadowPos = Assert.Single(
|
|
fixture.Shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == fixture.Entity.Id).Position;
|
|
var landingPos = new Vector3(12f, 14f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
landingPos, SourceCell, teleportSequence: 1,
|
|
guid: PlayerGuid, isGrounded: true));
|
|
|
|
// Body snapped to the landing pose; entity synced from the resolved
|
|
// body; armed exactly once.
|
|
Assert.Equal(landingPos, fixture.Remote.Body.Position);
|
|
Assert.Equal(landingPos, fixture.Entity.Position);
|
|
Assert.Equal(SourceCell, fixture.Entity.ParentCellId);
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
|
|
// Row 2a, PRESERVED for player guids: the interp queue IS cleared.
|
|
Assert.False(fixture.Remote.Interp.IsActive);
|
|
|
|
// Row 2b / #316, PRESERVED (NOT fixed): the shadow is NOT
|
|
// republished for a player-guid landing — it stays at whatever it
|
|
// was before this packet.
|
|
ShadowEntry shadowEntry = Assert.Single(
|
|
fixture.Shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == fixture.Entity.Id);
|
|
Assert.Equal(spawnShadowPos, shadowEntry.Position);
|
|
Assert.NotEqual(landingPos, shadowEntry.Position);
|
|
|
|
// AP-135's cell-adopt bookkeeping still ran (via the post-routing
|
|
// wire-cell adopt, not suppressed for AirborneSnap).
|
|
Assert.Equal(SourceCell, fixture.Remote.CellId);
|
|
}
|
|
|
|
[Fact]
|
|
public void LandingPacket_CreatureGuid_ShadowPublishedQueueNotCleared()
|
|
{
|
|
using var fixture = new Fixture(CreatureGuid);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
Assert.Null(host.PositionManager.Constraint);
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active;
|
|
fixture.Remote.Interp.Enqueue(
|
|
new Vector3(1f, 1f, SpawnHeight),
|
|
Quaternion.Identity,
|
|
isMovingTo: false,
|
|
currentBodyPosition: new Vector3(50f, 50f, SpawnHeight),
|
|
currentBodyOrientation: Quaternion.Identity);
|
|
Assert.True(fixture.Remote.Interp.IsActive);
|
|
var landingPos = new Vector3(12f, 14f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
landingPos, SourceCell, teleportSequence: 1,
|
|
guid: CreatureGuid, isGrounded: true));
|
|
|
|
Assert.Equal(landingPos, fixture.Remote.Body.Position);
|
|
Assert.Equal(landingPos, fixture.Entity.Position);
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
|
|
// Row 2a, PRESERVED for creature guids: the interp queue is NOT
|
|
// cleared here (the per-tick AP-139 edge owns it — see the arm's
|
|
// comment in OnPosition).
|
|
Assert.True(fixture.Remote.Interp.IsActive);
|
|
|
|
// Row 2b, the NPC/creature half: the shadow DOES publish at the
|
|
// resolved body pose — this is the behaviour #316 says the player
|
|
// half is missing.
|
|
ShadowEntry shadowEntry = Assert.Single(
|
|
fixture.Shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == fixture.Entity.Id);
|
|
Assert.Equal(landingPos, shadowEntry.Position);
|
|
}
|
|
|
|
// ── Scenario 3: wire-airborne, null-classified (login-window shape) ─
|
|
|
|
[Theory]
|
|
[InlineData(PlayerGuid)]
|
|
[InlineData(CreatureGuid)]
|
|
public void WireAirborneNullClassified_BothGuids_WritesOnlyAP135Bookkeeping(
|
|
uint guid)
|
|
{
|
|
using var fixture = new Fixture(guid, 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.Update(
|
|
wirePos, SourceCell, teleportSequence: 1,
|
|
guid: guid, isGrounded: false));
|
|
|
|
// No body write.
|
|
Assert.Equal(spawnBodyPose, fixture.Remote.Body.Position);
|
|
// No shadow republish.
|
|
ShadowEntry shadowEntry = Assert.Single(
|
|
fixture.Shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == fixture.Entity.Id);
|
|
Assert.Equal(spawnBodyPose, shadowEntry.Position);
|
|
// No arm (round-2 architecture review's discriminator: Constraint is
|
|
// lazily created only on a genuine arm).
|
|
Assert.Null(host.PositionManager.Constraint);
|
|
|
|
// Positive half (round-2 B1's lesson): AP-135's two writes DID
|
|
// happen.
|
|
Assert.Equal(SourceCell, fixture.Remote.CellId);
|
|
Assert.Equal(wirePos, fixture.Remote.LastServerPos);
|
|
Assert.NotEqual(0d, fixture.Remote.LastServerPosTime);
|
|
}
|
|
|
|
// ── Scenario 4: airborne no-op (NoPositionOperation, non-null route) ─
|
|
|
|
[Theory]
|
|
[InlineData(PlayerGuid)]
|
|
[InlineData(CreatureGuid)]
|
|
public void AirborneNoOperation_BothGuids_WritesOnlyAP135BookkeepingNoArm(
|
|
uint guid)
|
|
{
|
|
using var fixture = new Fixture(guid);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
Assert.Null(host.PositionManager.Constraint);
|
|
// Contact SET so this is NOT the landing/AirborneSnap scenario —
|
|
// this packet must classify NoPositionOperation via the WIRE
|
|
// has_contact bit (update.IsGrounded), the classifier's OWN
|
|
// predicate, independent of the body's own contact state.
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact;
|
|
Vector3 spawnBodyPose = fixture.Remote.Body.Position;
|
|
var wirePos = new Vector3(50f, 50f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
wirePos, SourceCell, teleportSequence: 1,
|
|
guid: guid, isGrounded: false));
|
|
|
|
Assert.Equal(spawnBodyPose, fixture.Remote.Body.Position);
|
|
ShadowEntry shadowEntry = Assert.Single(
|
|
fixture.Shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == fixture.Entity.Id);
|
|
Assert.Equal(spawnBodyPose, shadowEntry.Position);
|
|
Assert.Null(host.PositionManager.Constraint);
|
|
|
|
Assert.Equal(SourceCell, fixture.Remote.CellId);
|
|
Assert.Equal(wirePos, fixture.Remote.LastServerPos);
|
|
Assert.NotEqual(0d, fixture.Remote.LastServerPosTime);
|
|
}
|
|
|
|
// ── Scenario 5: near interpolate ─────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(PlayerGuid)]
|
|
[InlineData(CreatureGuid)]
|
|
public void NearInterpolate_BothGuids_EnqueuesAndArmsOnce(uint guid)
|
|
{
|
|
using var fixture = new Fixture(guid);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
Assert.Null(host.PositionManager.Constraint);
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact;
|
|
// AP-87 backstop: not firstUp, and further than the 4 m snap
|
|
// threshold so this packet enqueues rather than snaps.
|
|
fixture.Remote.Body.Position = new Vector3(2f, 2f, SpawnHeight);
|
|
fixture.Remote.LastServerPos = fixture.Remote.Body.Position;
|
|
fixture.Remote.LastServerPosTime =
|
|
(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds - 0.15;
|
|
// Within AP-87's 4 m snap threshold (distance ~2.24 m) but well
|
|
// outside InterpolationManager.Enqueue's 0.05 m "already-close" wipe
|
|
// — a genuine enqueue, not a snap and not a no-op.
|
|
var target = new Vector3(4f, 3f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
target, SourceCell, teleportSequence: 1,
|
|
guid: guid, isGrounded: true));
|
|
|
|
// Enqueued, not snapped: the body pose is unchanged (the per-tick
|
|
// catch-up walks toward the queued target — this packet does not
|
|
// move the body directly).
|
|
Assert.Equal(new Vector3(2f, 2f, SpawnHeight), fixture.Remote.Body.Position);
|
|
Assert.True(fixture.Remote.Interp.IsActive);
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
// Row 3: the wire cell still adopts (near-interpolate is not a
|
|
// placement arm, so TryAdoptWireCellAfterRouting does not suppress).
|
|
Assert.Equal(SourceCell, fixture.Remote.CellId);
|
|
// The render entity tracks the (unchanged) body, not the wire pose —
|
|
// route 4a's generic-write suppression plus the unified tail's
|
|
// resync from the resolved body.
|
|
Assert.Equal(fixture.Remote.Body.Position, fixture.Entity.Position);
|
|
}
|
|
|
|
// ── Scenario 6: far snap ─────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(PlayerGuid)]
|
|
[InlineData(CreatureGuid)]
|
|
public void FarSnap_BothGuids_PlacesAndArmsOnEveryOutcome(uint guid)
|
|
{
|
|
using var fixture = new Fixture(guid);
|
|
fixture.PublishDestinationCollision();
|
|
fixture.ServiceWindow.Allow(DestinationLandblock);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
Assert.Null(host.PositionManager.Constraint);
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact;
|
|
Vector3 spawnPose = fixture.Entity.Position;
|
|
// Far enough (>=96 m from the stub player controller's origin) via a
|
|
// packet whose destination cell differs, WITHOUT advancing
|
|
// TELEPORT_TS — the SetPositionSimple (far) classification, not
|
|
// SetPosition (teleport/cell-less).
|
|
var destination = new Vector3(12f, 14f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
destination, DestinationCell, teleportSequence: 1,
|
|
guid: guid, isGrounded: true));
|
|
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
guid, out RuntimeEntityRecord canonical));
|
|
Assert.NotNull(canonical.PhysicsBody);
|
|
PhysicsBody body = canonical.PhysicsBody!;
|
|
Assert.NotEqual(spawnPose, body.Position);
|
|
Assert.Equal(body.Position, fixture.Entity.Position);
|
|
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
|
Assert.Equal(DestinationCell, canonical.FullCellId);
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
|
|
ShadowEntry shadowEntry = Assert.Single(
|
|
fixture.Shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == fixture.Entity.Id);
|
|
Assert.Equal(body.Position, shadowEntry.Position);
|
|
|
|
fixture.DrainPlacementFifo();
|
|
}
|
|
|
|
// ── Scenario 7: sticky-suppressed steady-state (row 8's asymmetry) ──
|
|
|
|
[Fact]
|
|
public void StickySuppressed_CreatureGuid_RoutingSkippedButStillArmed()
|
|
{
|
|
using var fixture = new Fixture(CreatureGuid);
|
|
EntityPhysicsHost host = fixture.ArmSticky(stickTargetGuid: 0x70009999u);
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact;
|
|
Vector3 bodyBefore = fixture.Remote.Body.Position;
|
|
var target = new Vector3(12f, 14f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
target, SourceCell, teleportSequence: 1,
|
|
guid: CreatureGuid, isGrounded: true));
|
|
|
|
// Routing never ran: body and queue are untouched.
|
|
Assert.Equal(bodyBefore, fixture.Remote.Body.Position);
|
|
Assert.False(fixture.Remote.Interp.IsActive);
|
|
// D4: sticky-suppressed still arms, with UnroutedCatchUp.
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
// Still stuck — nothing in this path unsticks it (only the teleport
|
|
// hook does).
|
|
Assert.NotEqual(0u, host.PositionManager.GetStickyObjectId());
|
|
}
|
|
|
|
[Fact]
|
|
public void StickySuppressed_PlayerGuid_GateNeverAppliesRoutingRunsAnyway()
|
|
{
|
|
using var fixture = new Fixture(PlayerGuid);
|
|
EntityPhysicsHost host = fixture.ArmSticky(stickTargetGuid: 0x70009999u);
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact;
|
|
fixture.Remote.Body.Position = new Vector3(2f, 2f, SpawnHeight);
|
|
fixture.Remote.LastServerPos = fixture.Remote.Body.Position;
|
|
fixture.Remote.LastServerPosTime =
|
|
(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds - 0.15;
|
|
var target = new Vector3(4f, 3f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
target, SourceCell, teleportSequence: 1,
|
|
guid: PlayerGuid, isGrounded: true));
|
|
|
|
// Row 8's named asymmetry: TS-44 never gates a player guid, so
|
|
// routing ran despite sticky being armed — the interp queue is now
|
|
// populated (or the body snapped), unlike the creature case above.
|
|
Assert.True(fixture.Remote.Interp.IsActive);
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
// Sticky itself is untouched by this non-teleport path (only the
|
|
// teleport hook unsticks).
|
|
Assert.NotEqual(0u, host.PositionManager.GetStickyObjectId());
|
|
}
|
|
|
|
// ── Scenario 8: NPC velocity-cycle (row 7/14's data-driven survivor) ─
|
|
|
|
[Fact]
|
|
public void VelocityCycle_CreatureGuid_PlansACycleFromWireVelocity()
|
|
{
|
|
using var fixture = new Fixture(CreatureGuid, withAnimation: true);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact;
|
|
// InitializeState() (fixture ctor) already seeded Ready via the
|
|
// motion table's StyleDefaults — verify the precondition rather
|
|
// than re-stating the seed.
|
|
Assert.Equal(
|
|
AcDream.Core.Physics.MotionCommand.Ready,
|
|
fixture.Animated!.Sequencer!.CurrentMotion);
|
|
var target = new Vector3(12f, 14f, SpawnHeight);
|
|
var wireVelocity = new Vector3(0.7f, 0f, 0f);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
target, SourceCell, teleportSequence: 1,
|
|
guid: CreatureGuid, isGrounded: true, velocity: wireVelocity));
|
|
|
|
Assert.True(fixture.Remote.HasServerVelocity);
|
|
Assert.Equal(wireVelocity, fixture.Remote.ServerVelocity);
|
|
Assert.NotEqual(
|
|
AcDream.Core.Physics.MotionCommand.Ready,
|
|
fixture.Animated.Sequencer.CurrentMotion);
|
|
_ = host;
|
|
}
|
|
|
|
[Fact]
|
|
public void VelocityCycle_PlayerGuid_SequencerUntouchedByWireVelocity()
|
|
{
|
|
using var fixture = new Fixture(PlayerGuid, withAnimation: true);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact;
|
|
Assert.Equal(
|
|
AcDream.Core.Physics.MotionCommand.Ready,
|
|
fixture.Animated!.Sequencer!.CurrentMotion);
|
|
var target = new Vector3(12f, 14f, SpawnHeight);
|
|
var wireVelocity = new Vector3(0.7f, 0f, 0f);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
target, SourceCell, teleportSequence: 1,
|
|
guid: PlayerGuid, isGrounded: true, velocity: wireVelocity));
|
|
|
|
// Row 6: the synth-velocity write is still write-only for players —
|
|
// it happens (the unified NPC formula runs for every guid), but
|
|
// nothing production reads it here either.
|
|
Assert.True(fixture.Remote.HasServerVelocity);
|
|
// Row 7/14: RemoteServerControlledVelocityCycle.Apply's internal
|
|
// IsPlayerGuid return means the sequencer NEVER changes for a player
|
|
// guid, regardless of the wire velocity supplied.
|
|
Assert.Equal(
|
|
AcDream.Core.Physics.MotionCommand.Ready,
|
|
fixture.Animated.Sequencer.CurrentMotion);
|
|
_ = host;
|
|
}
|
|
|
|
// ── Sabotage check (contract §5, one-time, manual) ──────────────────
|
|
//
|
|
// Performed by hand during implementation, not committed as a test (a
|
|
// committed sabotage would defeat its own purpose): temporarily deleted
|
|
// the `RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation`
|
|
// call from the unified tail in `OnPosition` and re-ran this file.
|
|
// Result: TeleportCommit_BothGuids (both InlineData rows),
|
|
// LandingPacket_PlayerGuid, LandingPacket_CreatureGuid,
|
|
// NearInterpolate_BothGuids (both rows), FarSnap_BothGuids (both rows),
|
|
// and StickySuppressed_CreatureGuid / StickySuppressed_PlayerGuid all
|
|
// failed — 9 of 9 non-airborne scenarios, both guid halves of every
|
|
// dual-guid Theory. This is the structural fix for the 4b-3 defect
|
|
// class: a sabotage that used to leave one guid's tests green now fails
|
|
// both, because there is one call site left to sabotage. Reverted before
|
|
// committing.
|
|
|
|
// ── C4 route 5 (A5 fix): missile matrix ─────────────────────────────
|
|
//
|
|
// The architecture review's FAIL-level finding: zero App tests drove
|
|
// OnPosition with a missile packet, so A1 (App discards the seam's
|
|
// status) and A2 (an unbound missile's Position silently dropped) both
|
|
// lived in the ~35 lines of "thin glue" the implementer argued were
|
|
// covered by two well-tested layers. These tests extend THIS file's own
|
|
// fixture — never a lighter one — with a genuine Missile-flagged,
|
|
// RuntimeProjectile-bound, RemoteMotion-less incarnation, and assert the
|
|
// SAME observable surface the remote scenarios above assert: body/entity
|
|
// position and ParentCellId (that pair is the exact A1 assertion), plus
|
|
// the projectile-specific half — no RemoteMotion is EVER created
|
|
// (invariant 8's mutual exclusion) and no early wire-pose write occurs.
|
|
// Destinations for the commit scenarios are airborne (well above
|
|
// PublishDestinationCollision's flat terrain), isolating the placement
|
|
// assertions from the shared pipeline's ordinary ground-contact response
|
|
// (Claim 4's confound, restated here for the App layer).
|
|
|
|
private const uint MissileGuid = 0x80007101u;
|
|
private static readonly Vector3 MissileAirborneDestination =
|
|
new(12f, 14f, SpawnHeight + 10f);
|
|
|
|
[Fact]
|
|
public void MissileTeleportCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody()
|
|
{
|
|
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
|
fixture.PublishDestinationCollision();
|
|
fixture.ServiceWindow.Allow(DestinationLandblock);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
MissileAirborneDestination, DestinationCell, teleportSequence: 5,
|
|
guid: MissileGuid, isGrounded: true));
|
|
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
MissileGuid, out RuntimeEntityRecord canonical));
|
|
Assert.Null(canonical.RemoteMotion);
|
|
Assert.NotNull(canonical.Projectile);
|
|
PhysicsBody body = canonical.PhysicsBody!;
|
|
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
|
|
Assert.Equal(resolved, body.Position);
|
|
Assert.Equal(body.Position, fixture.Entity.Position);
|
|
// A1's own assertion: ParentCellId agrees with the RESOLVED body's
|
|
// OWN cell — the review's concrete wrong-cell scenario, checked
|
|
// positively here rather than only on the no-op scenarios below.
|
|
Assert.Equal(body.CellPosition.ObjCellId, fixture.Entity.ParentCellId);
|
|
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
|
// D-P4: the collision table was force-ended (no seeded owner here to
|
|
// assert a 1->0 transition — that half is covered at
|
|
// tests/AcDream.Runtime.Tests — but the call must not throw or
|
|
// leave the entity uncollidable long-term; Tick below proves it is
|
|
// still a live, ordinary object).
|
|
ShadowEntry shadowEntry = Assert.Single(
|
|
fixture.Shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == fixture.Entity.Id);
|
|
Assert.Equal(body.Position, shadowEntry.Position);
|
|
|
|
fixture.DrainPlacementFifo();
|
|
}
|
|
|
|
[Fact]
|
|
public void MissileFarCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody()
|
|
{
|
|
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
|
fixture.PublishDestinationCollision();
|
|
fixture.ServiceWindow.Allow(DestinationLandblock);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
|
|
guid: MissileGuid, isGrounded: true));
|
|
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
MissileGuid, out RuntimeEntityRecord canonical));
|
|
Assert.Null(canonical.RemoteMotion);
|
|
PhysicsBody body = canonical.PhysicsBody!;
|
|
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
|
|
Assert.Equal(resolved, body.Position);
|
|
Assert.Equal(body.Position, fixture.Entity.Position);
|
|
Assert.Equal(body.CellPosition.ObjCellId, fixture.Entity.ParentCellId);
|
|
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
|
|
|
fixture.DrainPlacementFifo();
|
|
}
|
|
|
|
/// <summary>
|
|
/// B1 EnvCell id — an indoor-format low word (0x100+, outside
|
|
/// <c>LandDefs.AdjustToOutside</c>'s 1-0x40 outdoor range) staged onto
|
|
/// the body BEFORE the store-path dispatch below. This is what makes
|
|
/// the regression assertion actually discriminate: for an OUTDOOR
|
|
/// source/destination pair, <c>PhysicsBody.Position</c>'s setter
|
|
/// delta-syncs <c>CellPosition</c> through <c>AdjustToOutside</c> and
|
|
/// happens to re-derive the correct destination landblock anyway (pure
|
|
/// geometry, no collision data needed) — so a wrong
|
|
/// <c>body.CellPosition.ObjCellId</c> read would coincidentally agree
|
|
/// with <c>record.FullCellId</c> and the test would pass either way.
|
|
/// An INDOOR source cell takes <c>SyncCellPositionDelta</c>'s OTHER
|
|
/// branch (<c>PhysicsBody.cs:295-300</c>): it carries the position delta
|
|
/// but never re-derives the cell id, so <c>body.CellPosition.ObjCellId</c>
|
|
/// stays PINNED at the stale indoor source cell through the store path.
|
|
/// <c>record.FullCellId</c> has no such blind spot — it is merged from
|
|
/// the wire unconditionally — so this is the scenario where the two
|
|
/// expressions genuinely diverge and MAJOR 1's revert is provable.
|
|
/// </summary>
|
|
private const uint IndoorSourceCell = SourceLandblock | 0x0100u;
|
|
|
|
/// <summary>
|
|
/// Residual 1 close (round-2 review): the STORE path — <c>Refused</c> —
|
|
/// at the App layer, closing both the coverage gap AND standing as the
|
|
/// regression test for MAJOR 1 (the <c>ParentCellId</c> revert). The
|
|
/// destination is deliberately left outside the service window
|
|
/// (<see cref="RemoteServiceWindow.Allow"/> is never called for
|
|
/// <see cref="DestinationLandblock"/>), so
|
|
/// <c>CanAttemptDestination</c> refuses before the engine ever runs and
|
|
/// <c>StoreAcceptedDestinationPose</c> resolves the destination through
|
|
/// Runtime's own world frame instead of a commit. <c>Refused</c> is
|
|
/// still a storing (A1-admitted) outcome, so the App-level presentation
|
|
/// sync runs — the entity's position moves to the DESTINATION and its
|
|
/// <c>ParentCellId</c> tracks <c>record.FullCellId</c>, never the stale
|
|
/// INDOOR source cell a <c>body.CellPosition.ObjCellId</c> read would
|
|
/// have produced (see <see cref="IndoorSourceCell"/>'s doc comment for
|
|
/// why an outdoor source cell would not have discriminated here).
|
|
///
|
|
/// <para>
|
|
/// C5b (#275), trap T6: <c>record.FullCellId</c> is now the COMMITTED
|
|
/// cell, so a REFUSED placement leaves it at the source. The missile arm
|
|
/// returns before the OnPosition generic tail, so its residency is
|
|
/// placement-receipt-driven and nothing else — which is retail
|
|
/// (a projectile's cell comes from <c>SetPosition</c>, full stop). The
|
|
/// MAJOR 1 regression check is unchanged in substance and is asserted
|
|
/// directly below as the identity it always meant:
|
|
/// <c>ParentCellId == record.FullCellId != body.CellPosition.ObjCellId</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithCommittedCell()
|
|
{
|
|
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
MissileGuid, out RuntimeEntityRecord canonicalBeforeStage));
|
|
PhysicsBody stagedBody = canonicalBeforeStage.PhysicsBody!;
|
|
stagedBody.SnapToCell(
|
|
IndoorSourceCell, stagedBody.Position, stagedBody.Position);
|
|
Assert.Equal(IndoorSourceCell, stagedBody.CellPosition.ObjCellId);
|
|
fixture.PublishDestinationCollision();
|
|
// Deliberately NOT allowed — CanAttemptDestination refuses.
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
|
|
guid: MissileGuid, isGrounded: true));
|
|
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
MissileGuid, out RuntimeEntityRecord canonical));
|
|
Assert.Null(canonical.RemoteMotion);
|
|
Assert.NotNull(canonical.Projectile);
|
|
PhysicsBody body = canonical.PhysicsBody!;
|
|
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
|
|
Assert.Equal(resolved, body.Position);
|
|
Assert.True(body.InWorld);
|
|
// The store fallback never re-derives an INDOOR cell id — confirms
|
|
// the divergence this test is built to exercise actually occurred.
|
|
Assert.Equal(IndoorSourceCell, body.CellPosition.ObjCellId);
|
|
// The exact B1 regression check: ParentCellId IS record.FullCellId —
|
|
// never body.CellPosition.ObjCellId, which just asserted it is STILL
|
|
// the stale indoor source cell.
|
|
Assert.Equal(body.Position, fixture.Entity.Position);
|
|
Assert.Equal(canonical.FullCellId, fixture.Entity.ParentCellId);
|
|
Assert.NotEqual(
|
|
body.CellPosition.ObjCellId,
|
|
fixture.Entity.ParentCellId);
|
|
// C5b: the refused placement committed nothing, so the committed cell
|
|
// is still the source. The wire cell is NOT residency here.
|
|
Assert.Equal(SourceCell, canonical.FullCellId);
|
|
Assert.NotEqual(DestinationCell, fixture.Entity.ParentCellId);
|
|
|
|
fixture.DrainPlacementFifo();
|
|
}
|
|
|
|
[Fact]
|
|
public void MissileNear_NoOp_BodyUnchangedNoRemoteMotionNoWirePoseWrite()
|
|
{
|
|
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
|
Vector3 spawnPose = fixture.Entity.Position;
|
|
var target = new Vector3(4f, 3f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
target, SourceCell, teleportSequence: 1,
|
|
guid: MissileGuid, isGrounded: true));
|
|
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
MissileGuid, out RuntimeEntityRecord canonical));
|
|
Assert.Null(canonical.RemoteMotion);
|
|
PhysicsBody body = canonical.PhysicsBody!;
|
|
Assert.Equal(spawnPose, body.Position);
|
|
// A1's regression, asserted directly: no early wire-pose write —
|
|
// the render entity was never moved to `target`.
|
|
Assert.Equal(spawnPose, fixture.Entity.Position);
|
|
Assert.NotEqual(target, fixture.Entity.Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissileAirborne_NoOp_BodyUnchangedNoRemoteMotion()
|
|
{
|
|
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
|
Vector3 spawnPose = fixture.Entity.Position;
|
|
var wirePos = new Vector3(50f, 50f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
wirePos, SourceCell, teleportSequence: 1,
|
|
guid: MissileGuid, isGrounded: false));
|
|
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
MissileGuid, out RuntimeEntityRecord canonical));
|
|
Assert.Null(canonical.RemoteMotion);
|
|
PhysicsBody body = canonical.PhysicsBody!;
|
|
Assert.Equal(spawnPose, body.Position);
|
|
Assert.Equal(spawnPose, fixture.Entity.Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissileNullClassification_Swallowed_NoRemoteMotionNoWriteNoPredictionChange()
|
|
{
|
|
using var fixture = new Fixture(
|
|
MissileGuid, nullClassification: true, isMissile: true);
|
|
Vector3 spawnPose = fixture.Entity.Position;
|
|
ulong predictionBefore = fixture.Projectile!.PredictionAuthorityVersion;
|
|
var wirePos = new Vector3(50f, 50f, SpawnHeight);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
wirePos, SourceCell, teleportSequence: 1,
|
|
guid: MissileGuid, isGrounded: true));
|
|
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
MissileGuid, out RuntimeEntityRecord canonical));
|
|
Assert.Null(canonical.RemoteMotion);
|
|
PhysicsBody body = canonical.PhysicsBody!;
|
|
Assert.Equal(spawnPose, body.Position);
|
|
Assert.Equal(spawnPose, fixture.Entity.Position);
|
|
Assert.Equal(
|
|
predictionBefore, fixture.Projectile.PredictionAuthorityVersion);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A2/R1's regression scenario, driven end to end: Missile bit set, but
|
|
/// no <c>RuntimeProjectile</c> bound (TryBind refused, or has not run
|
|
/// yet — <c>ProjectileController.cs:160-166</c>'s unsupported-Setup
|
|
/// case, or the pre-bind window). Retail places every non-player object
|
|
/// unconditionally; the fixed classifier must classify this packet
|
|
/// Remote and route it through the SAME generic remote placement path
|
|
/// an ordinary remote uses — never the frozen silent drop the
|
|
/// unconjoined discriminator produced.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MissileUnbound_FallsThroughToRemoteTail_TracksInsteadOfFreezing()
|
|
{
|
|
using var fixture = new Fixture(MissileGuid, isMissile: false);
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
MissileGuid, out RuntimeEntityRecord canonical));
|
|
// Flip Missile AFTER construction, deliberately WITHOUT binding a
|
|
// RuntimeProjectile — the unbound shape A2 names.
|
|
fixture.Lifetime.Entities.SetFinalPhysicsState(
|
|
canonical,
|
|
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
|
|
Assert.Null(canonical.Projectile);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact;
|
|
fixture.PublishDestinationCollision();
|
|
fixture.ServiceWindow.Allow(DestinationLandblock);
|
|
Vector3 spawnPose = fixture.Entity.Position;
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
|
|
guid: MissileGuid, isGrounded: true));
|
|
|
|
// Placed via the ordinary remote far-snap arm — not frozen, and
|
|
// armed exactly like FarSnap_BothGuids above.
|
|
Assert.NotEqual(spawnPose, fixture.Remote.Body.Position);
|
|
Assert.Equal(fixture.Remote.Body.Position, fixture.Entity.Position);
|
|
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
|
|
fixture.DrainPlacementFifo();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A3/R2's adopted-body scenario: an ordinary remote (populated Interp
|
|
/// queue, an armed ConstrainTo leash) whose Missile bit is later set by
|
|
/// a State packet — <c>ProjectileController.TryBind</c>'s shared-body
|
|
/// branch adopts the SAME <c>RemoteMotion</c>/body rather than replacing
|
|
/// it. Retail's <c>teleport_hook</c> per-manager guards are satisfied
|
|
/// for this shape, so all six actions run; the App-level pre-dispatch
|
|
/// hook call (mirroring the remote teleport arm's own
|
|
/// <c>RunRemoteTeleportHook</c> wiring) must un-arm the leash and clear
|
|
/// the queue before the placement, exactly like retail's ordering.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MissileAdoptedBody_TeleportCommit_UnConstrainsAndClearsInterpQueue()
|
|
{
|
|
using var fixture = new Fixture(MissileGuid, isMissile: false);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact;
|
|
// Arm the leash and populate the queue directly — the pre-teleport
|
|
// "live remote" state the adopted-body scenario requires.
|
|
host.PositionManager.ConstrainTo(
|
|
new AcDream.Core.Physics.Position(
|
|
SourceCell, fixture.Remote.Body.Position, Quaternion.Identity),
|
|
startDistance: 1f,
|
|
maxDistance: 5f);
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
fixture.Remote.Interp.Enqueue(
|
|
fixture.Remote.Body.Position + Vector3.UnitX,
|
|
heading: 0f,
|
|
isMovingTo: false,
|
|
currentBodyPosition: fixture.Remote.Body.Position);
|
|
Assert.True(fixture.Remote.Interp.IsActive);
|
|
|
|
// TryBind's shared-body branch: adopt the SAME body into a
|
|
// RuntimeProjectile, and set Missile — the record now carries BOTH
|
|
// a RemoteMotion and a bound projectile, exactly like production.
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
MissileGuid, out RuntimeEntityRecord canonical));
|
|
fixture.Lifetime.Entities.SetFinalPhysicsState(
|
|
canonical,
|
|
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
|
|
fixture.Lifetime.Physics.BindProjectile(
|
|
canonical,
|
|
canonical.PhysicsBody!,
|
|
new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
|
|
Assert.NotNull(canonical.Projectile);
|
|
Assert.NotNull(canonical.RemoteMotion);
|
|
|
|
fixture.PublishDestinationCollision();
|
|
fixture.ServiceWindow.Allow(DestinationLandblock);
|
|
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
MissileAirborneDestination, DestinationCell, teleportSequence: 5,
|
|
guid: MissileGuid, isGrounded: true));
|
|
|
|
// UnConstrain and StopInterpolating both ran. UnConstrain unmarks
|
|
// IsConstrained rather than nulling the manager back out (it was
|
|
// lazily CREATED by the ConstrainTo test-setup call above, and
|
|
// creation is one-way) — this is the exact discriminator
|
|
// WireAirborneNullClassified_BothGuids_WritesOnlyAP135Bookkeeping's
|
|
// own comment names ("Constraint is lazily created only on a
|
|
// genuine arm").
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
Assert.False(host.PositionManager.Constraint!.IsConstrained);
|
|
Assert.False(fixture.Remote.Interp.IsActive);
|
|
// The placement itself still committed through the projectile arm.
|
|
Assert.Equal(
|
|
MissileAirborneDestination + DestinationWorldOffset,
|
|
canonical.PhysicsBody!.Position);
|
|
|
|
fixture.DrainPlacementFifo();
|
|
}
|
|
|
|
/// <summary>
|
|
/// B1/B2 fix (round-2 review): the far-branch counterpart to
|
|
/// <see cref="MissileAdoptedBody_TeleportCommit_UnConstrainsAndClearsInterpQueue"/>.
|
|
/// Retail's far branch (<c>SetPositionSimple</c>,
|
|
/// <c>player_distance >= 96 m</c>) runs <c>StopInterpolating</c>
|
|
/// @0x005163C9-@0x005163CB whenever <c>position_manager != 0</c> — the
|
|
/// SAME guard the teleport branch's full <c>teleport_hook</c> shares
|
|
/// for its own <c>StopInterpolating</c> action — but the far branch
|
|
/// does NOT run the other five teleport_hook actions (no
|
|
/// <c>UnConstrain</c>). For a bare missile this is structurally inert
|
|
/// (no <c>RemoteMotion</c>), which is why the pre-round-2 AP-141 row
|
|
/// could call the far-branch skip "faithful by consequence." The
|
|
/// adopted-body case breaks that: it carries a live <c>Interp</c>
|
|
/// queue the far branch must ALSO clear, while its armed
|
|
/// <c>ConstrainTo</c> leash must stay armed (proving the far branch
|
|
/// really does run only <c>StopInterpolating</c>, not the full hook).
|
|
///
|
|
/// <para>
|
|
/// Round-3 nit (C2): "leash still armed" pins acdream's OWN divergence,
|
|
/// not retail's behaviour. Retail's <c>HandleReceivedPosition</c>
|
|
/// @0x00454254/@0x00454272 re-anchors an existing leash at the object's
|
|
/// just-updated position on every nonzero <c>MoveOrTeleport</c> return —
|
|
/// including the far branch's. acdream's far arm never calls
|
|
/// <c>ConstrainTo</c> at all (D-P4, AP-141 clause (b)), so "still armed"
|
|
/// here means "left exactly as staged," not "correctly re-anchored." A
|
|
/// future reader should not read this assertion as full far-branch
|
|
/// leash fidelity — only the <c>StopInterpolating</c> half is ported.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void MissileAdoptedBody_FarCommit_ClearsInterpQueueButLeavesConstraintArmed()
|
|
{
|
|
using var fixture = new Fixture(MissileGuid, isMissile: false);
|
|
EntityPhysicsHost host = fixture.InstallHost();
|
|
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact;
|
|
host.PositionManager.ConstrainTo(
|
|
new AcDream.Core.Physics.Position(
|
|
SourceCell, fixture.Remote.Body.Position, Quaternion.Identity),
|
|
startDistance: 1f,
|
|
maxDistance: 5f);
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
Assert.True(host.PositionManager.Constraint!.IsConstrained);
|
|
fixture.Remote.Interp.Enqueue(
|
|
fixture.Remote.Body.Position + Vector3.UnitX,
|
|
heading: 0f,
|
|
isMovingTo: false,
|
|
currentBodyPosition: fixture.Remote.Body.Position);
|
|
Assert.True(fixture.Remote.Interp.IsActive);
|
|
|
|
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
|
MissileGuid, out RuntimeEntityRecord canonical));
|
|
fixture.Lifetime.Entities.SetFinalPhysicsState(
|
|
canonical,
|
|
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
|
|
fixture.Lifetime.Physics.BindProjectile(
|
|
canonical,
|
|
canonical.PhysicsBody!,
|
|
new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
|
|
Assert.NotNull(canonical.Projectile);
|
|
Assert.NotNull(canonical.RemoteMotion);
|
|
|
|
fixture.PublishDestinationCollision();
|
|
fixture.ServiceWindow.Allow(DestinationLandblock);
|
|
|
|
// teleportSequence: 1 (unchanged from the fixture's baseline) with a
|
|
// cross-landblock destination classifies as the FAR disposition
|
|
// (SetPositionSimple) — the same discriminator
|
|
// MissileFarCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody
|
|
// uses above, just against the adopted-body shape instead of a bare
|
|
// missile.
|
|
fixture.Controller.OnPosition(fixture.Update(
|
|
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
|
|
guid: MissileGuid, isGrounded: true));
|
|
|
|
// StopInterpolating ran — the queue is cleared.
|
|
Assert.False(fixture.Remote.Interp.IsActive);
|
|
// UnConstrain did NOT run — the far branch is one action, not six.
|
|
// The leash is still armed.
|
|
Assert.NotNull(host.PositionManager.Constraint);
|
|
Assert.True(host.PositionManager.Constraint!.IsConstrained);
|
|
// The placement itself still committed through the projectile arm.
|
|
Assert.Equal(
|
|
MissileAirborneDestination + DestinationWorldOffset,
|
|
canonical.PhysicsBody!.Position);
|
|
|
|
fixture.DrainPlacementFifo();
|
|
}
|
|
|
|
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!;
|
|
internal LiveEntityAnimationState? Animated { get; private set; }
|
|
/// <summary>
|
|
/// C4 route 5 (A5 fix): the bound projectile component for an
|
|
/// <c>isMissile</c> fixture — <see langword="null"/> for an ordinary
|
|
/// remote fixture. Exposed so tests can assert prediction-version
|
|
/// movement without re-deriving it from <see cref="Lifetime"/>.
|
|
/// </summary>
|
|
internal RuntimeProjectile? Projectile { get; private set; }
|
|
private readonly GpuWorldState _spatial;
|
|
private readonly uint _guid;
|
|
private readonly bool _nullClassification;
|
|
|
|
/// <summary>
|
|
/// C5b review L1/L2: true once the hydration controller's materializer
|
|
/// was actually reached and DECLINED. Only meaningful when the fixture
|
|
/// was built with <c>decliningMaterializer: true</c>.
|
|
/// </summary>
|
|
internal bool MaterializerDeclined => _decliningMaterializer?.Declined
|
|
?? false;
|
|
|
|
private readonly DecliningMaterializer? _decliningMaterializer;
|
|
|
|
internal Fixture(
|
|
uint guid,
|
|
bool nullClassification = false,
|
|
bool withAnimation = false,
|
|
bool isMissile = false,
|
|
bool decliningMaterializer = false)
|
|
{
|
|
_guid = guid;
|
|
_nullClassification = nullClassification;
|
|
_decliningMaterializer =
|
|
decliningMaterializer ? new DecliningMaterializer() : null;
|
|
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();
|
|
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);
|
|
// C4 route 5 (A5 fix): a missile fixture carries the Missile bit
|
|
// from spawn — the SAME data-driven bit
|
|
// RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition
|
|
// (D-P1) reads, so this fixture's record classifies exactly like
|
|
// a real missile would once TryBind/BindProjectile below binds
|
|
// the component.
|
|
PhysicsStateFlags baseState = PhysicsStateFlags.ReportCollisions
|
|
| (isMissile ? PhysicsStateFlags.Missile : PhysicsStateFlags.None);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)baseState,
|
|
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,
|
|
"onposition-collapse-fixture",
|
|
null,
|
|
null,
|
|
0x09000001u,
|
|
PhysicsState: (uint)baseState,
|
|
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));
|
|
|
|
// C4 route 5 (A5 fix): a missile fixture binds a
|
|
// RuntimeProjectile directly through the SAME production Runtime
|
|
// entry point ProjectileController.TryBind eventually calls
|
|
// (RuntimePhysicsState.BindProjectile) — never a RemoteMotion.
|
|
// This is the exact shape invariant 8's mutual exclusion pins:
|
|
// Missile-set means a projectile arm and NO RemoteMotion ever
|
|
// exists for the entity.
|
|
if (isMissile)
|
|
{
|
|
var body = new PhysicsBody
|
|
{
|
|
Position = Entity.Position,
|
|
Orientation = Entity.Rotation,
|
|
LastUpdateTime = 1d,
|
|
State = baseState,
|
|
TransientState = TransientStateFlags.Active,
|
|
};
|
|
body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
|
|
RuntimeEntityRecord canonical = record.Canonical!;
|
|
Lifetime.Entities.SetPhysicsBody(canonical, body);
|
|
canonical.ObjectClock.Activate();
|
|
Lifetime.Physics.AcknowledgeSpatialProjection(canonical, spatial: true);
|
|
Projectile = (RuntimeProjectile)Lifetime.Physics.BindProjectile(
|
|
canonical, body, new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
|
|
Shadows.Register(
|
|
Entity.Id,
|
|
0x02000001u,
|
|
Entity.Position,
|
|
Entity.Rotation,
|
|
radius: 0.1f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: SourceLandblock,
|
|
collisionType: ShadowCollisionType.Sphere,
|
|
state: (uint)baseState,
|
|
seedCellId: SourceCell,
|
|
isStatic: false);
|
|
}
|
|
else
|
|
{
|
|
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));
|
|
|
|
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animatedEntities;
|
|
if (withAnimation)
|
|
{
|
|
var slot = new LiveEntityRuntimeSlot();
|
|
slot.Bind(Runtime);
|
|
animatedEntities =
|
|
new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(slot);
|
|
// A working (style, motion) -> animation table for BOTH
|
|
// Ready and WalkForward, so SetCycle's dispatch through
|
|
// PerformMovement genuinely succeeds and CurrentMotion
|
|
// actually changes — a bare `new MotionTable()` has no
|
|
// cycles, so every SetCycle silently no-ops
|
|
// (AnimationSequencer.SetCycle: "Failed dispatch ... leaves
|
|
// sequence AND state untouched"), which would make every
|
|
// scenario 8 assertion vacuously true regardless of the
|
|
// collapse. Pattern mirrors
|
|
// tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs's
|
|
// own `Fixtures.MakeMtable`.
|
|
const uint animStyle = 0x8000003Du;
|
|
const uint readyAnimId = 0x03000101u;
|
|
const uint walkAnimId = 0x03000102u;
|
|
var mt = new DatReaderWriter.DBObjs.MotionTable
|
|
{
|
|
DefaultStyle = (DRWMotionCommand)animStyle,
|
|
};
|
|
mt.StyleDefaults[(DRWMotionCommand)animStyle] =
|
|
(DRWMotionCommand)AcDream.Core.Physics.MotionCommand.Ready;
|
|
mt.Cycles[(int)((animStyle << 16)
|
|
| (AcDream.Core.Physics.MotionCommand.Ready & 0xFFFFFFu))] =
|
|
MakeMotionData(readyAnimId);
|
|
mt.Cycles[(int)((animStyle << 16)
|
|
| (AcDream.Core.Physics.MotionCommand.WalkForward & 0xFFFFFFu))] =
|
|
MakeMotionData(walkAnimId);
|
|
var loader = new FakeAnimationLoader();
|
|
loader.Register(readyAnimId, MakeTwoFrameAnim());
|
|
loader.Register(walkAnimId, MakeTwoFrameAnim());
|
|
var setup = new DatReaderWriter.DBObjs.Setup();
|
|
setup.Parts.Add(0x01000000u);
|
|
setup.DefaultScale.Add(Vector3.One);
|
|
var sequencer = new AcDream.Core.Physics.AnimationSequencer(
|
|
setup, mt, loader);
|
|
sequencer.InitializeState();
|
|
Animated = new LiveEntityAnimationState
|
|
{
|
|
Entity = Entity,
|
|
Setup = setup,
|
|
Animation = new DatReaderWriter.DBObjs.Animation(),
|
|
LowFrame = 0,
|
|
HighFrame = 0,
|
|
Framerate = 0f,
|
|
Scale = 1f,
|
|
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
|
|
PartAvailability = Array.Empty<bool>(),
|
|
Sequencer = sequencer,
|
|
};
|
|
animatedEntities[Entity.Id] = Animated;
|
|
}
|
|
else
|
|
{
|
|
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(),
|
|
(ILiveEntityProjectionMaterializer?)_decliningMaterializer
|
|
?? 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);
|
|
|
|
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 Update(
|
|
Vector3 destination,
|
|
uint cellId,
|
|
ushort teleportSequence,
|
|
uint guid,
|
|
bool isGrounded = true,
|
|
Vector3? velocity = null) => new(
|
|
guid,
|
|
new CreateObject.ServerPosition(
|
|
cellId,
|
|
destination.X,
|
|
destination.Y,
|
|
destination.Z,
|
|
1f, 0f, 0f, 0f),
|
|
Velocity: velocity,
|
|
PlacementId: null,
|
|
IsGrounded: isGrounded,
|
|
InstanceSequence: 1,
|
|
PositionSequence: 2,
|
|
TeleportSequence: teleportSequence,
|
|
ForcePositionSequence: 0);
|
|
|
|
private static DatReaderWriter.Types.MotionData MakeMotionData(uint animId)
|
|
{
|
|
var md = new DatReaderWriter.Types.MotionData();
|
|
md.Anims.Add(new DatReaderWriter.Types.AnimData
|
|
{
|
|
AnimId = (QualifiedDataId<DatReaderWriter.DBObjs.Animation>)animId,
|
|
LowFrame = 0,
|
|
HighFrame = -1,
|
|
Framerate = 30f,
|
|
});
|
|
return md;
|
|
}
|
|
|
|
private static DatReaderWriter.DBObjs.Animation MakeTwoFrameAnim()
|
|
{
|
|
var anim = new DatReaderWriter.DBObjs.Animation();
|
|
var pf0 = new DatReaderWriter.Types.AnimationFrame(1u);
|
|
var pf1 = new DatReaderWriter.Types.AnimationFrame(1u);
|
|
pf0.Frames.Add(new DatReaderWriter.Types.Frame
|
|
{
|
|
Origin = Vector3.Zero,
|
|
Orientation = Quaternion.Identity,
|
|
});
|
|
pf1.Frames.Add(new DatReaderWriter.Types.Frame
|
|
{
|
|
Origin = Vector3.Zero,
|
|
Orientation = Quaternion.Identity,
|
|
});
|
|
anim.PartFrames.Add(pf0);
|
|
anim.PartFrames.Add(pf1);
|
|
return anim;
|
|
}
|
|
|
|
private sealed class FakeAnimationLoader : IAnimationLoader
|
|
{
|
|
private readonly Dictionary<uint, DatReaderWriter.DBObjs.Animation> _anims = new();
|
|
|
|
internal void Register(uint id, DatReaderWriter.DBObjs.Animation anim) =>
|
|
_anims[id] = anim;
|
|
|
|
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) =>
|
|
_anims.TryGetValue(id, out var a) ? a : null;
|
|
}
|
|
|
|
internal EntityPhysicsHost ArmSticky(uint stickTargetGuid)
|
|
{
|
|
EntityPhysicsHost host = InstallHost();
|
|
host.PositionManager.StickTo(stickTargetGuid, radius: 1f, height: 1f);
|
|
Assert.NotEqual(0u, host.PositionManager.GetStickyObjectId());
|
|
return host;
|
|
}
|
|
|
|
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) { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// C5b review L1/L2: models the post-C5b
|
|
/// <c>DatLiveEntityProjectionMaterializer</c> self-projection branch
|
|
/// DECLINING (<c>DatLiveEntityProjectionMaterializer.cs:767-787</c>'s
|
|
/// <c>expectedCanonical.FullCellId != 0u</c> gate not being satisfied),
|
|
/// which is the state C5b's contract §14 item (1) says production now
|
|
/// reaches. Records that it was reached so the test cannot pass
|
|
/// vacuously, and returns <see langword="false"/> — the value
|
|
/// <c>OnPosition</c>'s recovery call site deliberately ignores.
|
|
/// </summary>
|
|
private sealed class DecliningMaterializer
|
|
: ILiveEntityProjectionMaterializer
|
|
{
|
|
internal bool Declined { get; private set; }
|
|
|
|
public bool TryMaterialize(
|
|
RuntimeEntityRecord expectedCanonical,
|
|
WorldSession.EntitySpawn canonicalSpawn,
|
|
LiveProjectionPurpose purpose,
|
|
ulong expectedCreateIntegrationVersion,
|
|
AcDream.App.Rendering.LiveEntityAppearanceUpdateState?
|
|
appearanceUpdate = null)
|
|
{
|
|
Declined = true;
|
|
return false;
|
|
}
|
|
|
|
public void ResetSessionState() => Declined = false;
|
|
}
|
|
|
|
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 ArmLoginTunnel() { }
|
|
|
|
public void ResetSession() { }
|
|
|
|
public void ResetGenerationPresentation() { }
|
|
}
|
|
|
|
private sealed class StubLocalPlayerControllerSource
|
|
: IRuntimeLocalPlayerControllerSource
|
|
{
|
|
public PlayerMovementController? Controller { get; } =
|
|
new PlayerMovementController(new PhysicsEngine());
|
|
}
|
|
|
|
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() { }
|
|
}
|
|
}
|
|
}
|