Retail runs the SAME TAS_TUNNEL wormhole at initial login as at an F751 teleport, with no F751 involved: SmartBox::teleport_in_progress @0x00451C20 returns 1 the moment the login player exists with position_update_complete == 0, gmSmartBoxUI::UseTime @0x004D6EAB edge-detects it into BeginTeleportAnimation(TAS_TUNNEL) @0x004D6EC9 (playing Sound_UI_EnterPortal @0x004D638E), SmartBox::UseTime @0x00455483 ends the hold once destination cells stop blocking, Sound_UI_ExitPortal plays at the viewport swap @0x004D7405, and LoginComplete goes out at the WorldFadeIn end @0x004D745D -> CPlayerSystem::SendLoginCompleteNotification @0x00562E90 (ACE's own GameActionLoginComplete comment names this contract: 'called when the client player exits portal space. It includes initial login'). acdream skipped all of it at login — every entry route (direct auto-select, character-select Enter, enter-after-create) dropped onto the sky-only 'waiting for login' backdrop until the world reveal completed. The fix engages the EXISTING F751 presentation machinery on Runtime's login reveal — no duplicated presentation code, no timers: - LocalPlayerTeleportController gains a login arm keyed off the Runtime-owned login reveal generation (RuntimeWorldTransitState .BeginLoginReveal, begun on the first accepted local-player position on every entry route). It drives the same TeleportAnimSequencer/ PortalTunnelPresentation lifecycle and the same enter/exit cues; the Place edge is a no-op at login (the first-entry conductor already committed the canonical placement — retail's analogue only flips position_update_complete), and FireLoginComplete now performs EnterWorld + the single LoginComplete send + reveal Complete, exactly like the F751 pump. worldReady is latched on BOTH canonical first placement (OnLocalPlayerFirstEntryCompleted, the repointed GraphicalSessionEventRoute completion callback that used to send LoginComplete immediately) AND destination reveal readiness. ActiveDestinationCell now also reports the login destination so the render frame's reveal-preparation arm keeps running after portal-space entry flips ChaseModeEverEntered. - PlayerModeController.TryEnterPortalSpaceForLogin performs the player-mode presentation attach (the same BuildControllerAndCamera the post-reveal auto-entry used to run) before flipping into portal space — at login no player-mode entry has happened yet. TryEnterPortalSpace itself now refuses (retryable) on a constructed-but-unpublished Runtime controller via the documented CanExecuteLiveMovement skip predicate instead of faulting — the first connected run crashed on exactly that pre-publication State write. - HouseQuery stays at first-entry completion (retail: tail-called from CPlayerSystem::InitializePlayer @0x00563570, an object-arrival edge, not a tunnel edge). - An F751 arriving mid-login-tunnel withdraws the login claim and hands the presentation to the portal pump, which owns the single LoginComplete — matching retail's one teleportInProgress flag. TS-28 narrowed: the graphical host now runs the full login wormhole; the residual is headless-only (no presentation; placement-edge send). Live gates (testaccount2/+Horan vs local ACE, Release): the character-select Enter route and the --session-config direct auto-select route both play the wormhole with Sound_UI_EnterPortal at animation begin, hold with retail's 'In Portal Space - Please Wait...' notice until readiness, fade out with the view-plane warp, send LoginComplete at the WorldFadeIn end, and materialize in Holtburg; ACE-confirmed graceful logout. Tests: App 5493/3 skips (baseline 5490 + 3 new login tests), Runtime 1744/0, full solution green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1882 lines
84 KiB
C#
1882 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 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() { }
|
|
}
|
|
}
|
|
}
|