C4 route 4b-3 collapse (docs/research/2026-08-04-onposition-collapse-contract.md). Behaviour-preserving: the ~640-line duplicated player-guid and NPC-guid copies of the remote routing tail in LiveEntityNetworkUpdateController.OnPosition become one guid-blind tail, reached by every remote guid through the single ApplyRemoteContactRouting/RunRemoteArmTail seam. Two guid-conditionals survive, both named and justified: - Row 8 (TS-44 sticky suppression, creature-only): retail's sticky is independent of this acdream-only steady-state gate; the register row already describes it as NPC-only and this collapse does not widen it. - The AirborneSnap arm's interp-clear + shadow-publish (rows 2a/2b, player-only preserve): unifying either way would be an unauthorized behaviour change. #316 (shadow publish) is a real, unmeasured pre-existing defect, deliberately preserved not fixed. The interp-clear's equivalence could not be proven for the steep-non-walkable-landing edge case (AdjustOffset's CONTACT-keyed gate vs. AP-139's WALKABLE-keyed per-tick clear) — preserved per contract stop condition 2 rather than shipped on an incomplete proof. Category-(c) resolutions (contract §2.1-2.5), each with its evidence: - Row 2a (interp clear): PRESERVED — AdjustOffset's `if (!inContact) return` proves inertness on flat landings, but not on the steep-contact edge case. - Row 2b (shadow publish / #316): PRESERVED — no design note ever sanctioned the player-guid skip; the file's own #184 Slice 2b comments contradict it. - Row 2c (EnsureRemoteMotionBindings): UNIFIED — the method is idempotent (`if (rm.Host is not null) return rm.Sink;`), so "always ensure" is safe. - Row 3 (wire-cell adopt ordering): UNIFIED — RebucketLiveEntity already commits the wire cell before either guid branch runs, so the deleted player-guid pre-write was a proven no-op. - Row 4 (LastServerPos/Time sample timing): UNIFIED — on a genuine first UP, InterpolationManager.Enqueue's already-close branch and the Snapped branch both converge on the same body pose/orientation for a zero-distance target. - Row 12 (wall-clock capture): UNIFIED — one shared `nowSec`, a microsecond-scale skew in acdream-only bookkeeping/diagnostics. Sabotage check (contract §5, performed and reverted, not committed): deleting the one remaining TryArmConstraintAfterOperation call failed 10/16 dual-guid matrix tests, spanning BOTH guid halves of every arming-dependent scenario (teleport, landing, near, far, sticky) — proof the matrix discriminates a defect regardless of which guid range exercises it, closing the class of bug that let 4b-3's A1/A2/R3 findings survive review when only one copy's tests were green. New tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs drives 8 scenarios x 2 guid ranges (0x50xxxxxx player, 0x8xxxxxxx creature) through the complete production OnPosition entry point. Doc comments on ApplyRemoteContactRouting, RunRemoteArmTail, ApplyWireAirborneLeftoverBookkeeping, TryAdoptWireCellAfterRouting, and the AirborneNoOperation throw guard updated to describe the collapsed one-path world (the "two callers stay one decision" claim was true before this commit and false after — fixed in the same commit that makes it false). One branch-routing source-text pin (LiveEntityNetworkBranchRoutingTests.cs) updated to follow the AP-140 CONTACT gate to its new address inside ApplyRemoteContactRouting. #316 stays OPEN, deliberately not fixed here — see its updated ISSUES.md entry. dotnet build AcDream.slnx -c Release: 0 errors. Verified independently bisectable at this exact commit: AcDream.App.Tests 4104/4107 (3 pre-existing skips), AcDream.Runtime.Tests 1125/1125. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1243 lines
52 KiB
C#
1243 lines
52 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 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.
|
|
|
|
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; }
|
|
private readonly GpuWorldState _spatial;
|
|
private readonly uint _guid;
|
|
private readonly bool _nullClassification;
|
|
|
|
internal Fixture(
|
|
uint guid,
|
|
bool nullClassification = false,
|
|
bool withAnimation = false)
|
|
{
|
|
_guid = guid;
|
|
_nullClassification = nullClassification;
|
|
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
|
engine.AddLandblock(
|
|
SourceLandblock,
|
|
new TerrainSurface(new byte[81], new float[256]),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
Lifetime = new RuntimeEntityObjectLifetime(engine);
|
|
Lifetime.BindEventContext(
|
|
static () => new RuntimeGenerationToken(1UL),
|
|
static () => 1UL);
|
|
Shadows = engine.ShadowObjects;
|
|
|
|
var spatial = new GpuWorldState();
|
|
spatial.AddLandblock(new LoadedLandblock(
|
|
CanonicalLandblock(SourceLandblock),
|
|
new DatReaderWriter.DBObjs.LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
_spatial = spatial;
|
|
Runtime = new LiveEntityRuntime(
|
|
spatial,
|
|
new NoopResources(),
|
|
NullLiveEntityRuntimeComponentLifecycle.Instance,
|
|
Lifetime);
|
|
|
|
var wirePosition = new CreateObject.ServerPosition(
|
|
SourceCell, 10f, 10f, SpawnHeight, 1f, 0f, 0f, 0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
Position: 1,
|
|
Movement: 1,
|
|
State: 1,
|
|
Vector: 1,
|
|
Teleport: 1,
|
|
ServerControlledMove: 1,
|
|
ForcePosition: 1,
|
|
ObjDesc: 1,
|
|
Instance: 1);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
Position: wirePosition,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: 1f,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
var spawn = new WorldSession.EntitySpawn(
|
|
_guid,
|
|
wirePosition,
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
null,
|
|
null,
|
|
"onposition-collapse-fixture",
|
|
null,
|
|
null,
|
|
0x09000001u,
|
|
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
InstanceSequence: 1,
|
|
PositionSequence: 1,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
Physics: physics);
|
|
LiveEntityRecord record =
|
|
Runtime.RegisterAndMaterializeProjection(spawn);
|
|
Entity = record.WorldEntity
|
|
?? throw new InvalidOperationException(
|
|
"fixture failed to materialize the remote entity");
|
|
Assert.True(Runtime.RebucketLiveEntity(_guid, SourceCell));
|
|
|
|
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(),
|
|
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) { }
|
|
}
|
|
|
|
private sealed class NoopMaterializer : ILiveEntityProjectionMaterializer
|
|
{
|
|
public bool TryMaterialize(
|
|
RuntimeEntityRecord expectedCanonical,
|
|
WorldSession.EntitySpawn canonicalSpawn,
|
|
LiveProjectionPurpose purpose,
|
|
ulong expectedCreateIntegrationVersion,
|
|
AcDream.App.Rendering.LiveEntityAppearanceUpdateState?
|
|
appearanceUpdate = null) =>
|
|
throw new InvalidOperationException(
|
|
"The fixture pre-materializes the remote entity; " +
|
|
"TryMaterialize should never be reached for an " +
|
|
"already-projected accepted Position.");
|
|
|
|
public void ResetSessionState() { }
|
|
}
|
|
|
|
private sealed class NoopRelationships : ILiveEntityRelationshipProjection
|
|
{
|
|
public void OnSpawn(WorldSession.EntitySpawn spawn) { }
|
|
public void OnParent(ParentEvent.Parsed update) { }
|
|
public void OnCreateParentAccepted(CreateParentUpdate update) { }
|
|
|
|
public AcDream.App.Rendering.ChildUnparentDisposition
|
|
OnChildBecameUnparented(uint childGuid) =>
|
|
AcDream.App.Rendering.ChildUnparentDisposition.NotAttached;
|
|
|
|
public bool TryApplyAttachedAppearance(
|
|
LiveEntityRecord record, ulong objDescAuthorityVersion) => false;
|
|
}
|
|
|
|
private sealed class NoopReadyPublisher : ILiveEntityReadyPublisher
|
|
{
|
|
public bool Publish(LiveEntityReadyCandidate candidate) => true;
|
|
}
|
|
|
|
private sealed class AlwaysKnownOrigin : ILiveEntityWorldOriginCoordinator
|
|
{
|
|
public bool IsKnown => true;
|
|
|
|
public LiveEntityOriginInitialization TryInitialize(
|
|
WorldSession.EntitySpawn spawn) => new(true, []);
|
|
}
|
|
|
|
private sealed class NoopNetworkSink : ILiveEntityNetworkUpdateSink
|
|
{
|
|
public void ApplySameGeneration(SameGenerationCreateObjectEvents events) { }
|
|
}
|
|
|
|
private sealed class NoopTimestampPublisher
|
|
: IAcceptedLocalPhysicsTimestampPublisher
|
|
{
|
|
public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps) { }
|
|
}
|
|
|
|
private sealed class NoopDatReaderWriter : IDatReaderWriter
|
|
{
|
|
private readonly StubDatabase _portal = new();
|
|
private readonly StubDatabase _highRes = new();
|
|
private readonly StubDatabase _language = new();
|
|
private readonly StubDatabase _cell = new();
|
|
|
|
public string SourceDirectory => string.Empty;
|
|
public IDatDatabase Portal => _portal;
|
|
public IDatDatabase Cell => _cell;
|
|
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
|
new(new Dictionary<uint, IDatDatabase>());
|
|
public IDatDatabase HighRes => _highRes;
|
|
public IDatDatabase Language => _language;
|
|
public IDatDatabase Local => _language;
|
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
|
new(new Dictionary<uint, uint>());
|
|
public int PortalIteration => 0;
|
|
public int CellIteration => 0;
|
|
public int HighResIteration => 0;
|
|
public int LanguageIteration => 0;
|
|
|
|
public bool TryGetFileBytes(
|
|
uint regionId,
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public bool TrySave<T>(
|
|
uint regionId,
|
|
T obj,
|
|
int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
[return: MaybeNull]
|
|
public T Get<T>(uint fileId) where T : IDBObj => default;
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public void Dispose() { }
|
|
}
|
|
|
|
private sealed class StubDatabase : IDatDatabase
|
|
{
|
|
public DatDatabase Db => throw new NotSupportedException();
|
|
public int Iteration => 0;
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetFileBytes(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out byte[] value)
|
|
{
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetFileBytes(
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public void Dispose() { }
|
|
}
|
|
|
|
private sealed class NoopAnimationLoader : IAnimationLoader
|
|
{
|
|
public Animation? LoadAnimation(uint id) => null;
|
|
}
|
|
|
|
private sealed class NoopTeleportSink : ILocalPlayerTeleportNetworkSink
|
|
{
|
|
public void OnTeleportStarted(uint sequence) { }
|
|
|
|
public void OfferDestination(
|
|
RuntimeTeleportDestination destination,
|
|
bool teleportTimestampAdvanced)
|
|
{ }
|
|
|
|
public void ResetSession() { }
|
|
|
|
public void ResetGenerationPresentation() { }
|
|
}
|
|
|
|
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() { }
|
|
}
|
|
}
|
|
}
|