From 0cb60d98a045150dbcd7781f57b812af80732974 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 07:47:06 +0200 Subject: [PATCH 01/73] test(physics): pin issue 270 animation fixes --- .../Net/LiveSessionRuntimeFactory.cs | 19 +-- .../Net/StaminaExhaustionEdgeTracker.cs | 32 +++++ .../LiveEntityNetworkUpdateController.cs | 40 ++----- .../Physics/RemoteSpawnPlacementSettler.cs | 62 ++++++++++ .../Net/StaminaExhaustionEdgeTrackerTests.cs | 52 +++++++++ .../Physics/Issue270ProductionWiringTests.cs | 66 +++++++++++ .../RemoteSpawnPlacementSettlerTests.cs | 109 ++++++++++++++++++ 7 files changed, 337 insertions(+), 43 deletions(-) create mode 100644 src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs create mode 100644 src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs create mode 100644 tests/AcDream.App.Tests/Net/StaminaExhaustionEdgeTrackerTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 52776715..fb683ea5 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -98,6 +98,7 @@ internal sealed class LiveSessionRuntimeFactory private readonly LiveSessionWorldRuntime _world; private readonly LiveSessionCommandSurface _commands; private readonly Action _log; + private readonly StaminaExhaustionEdgeTracker _staminaExhaustion = new(); public LiveSessionRuntimeFactory( LiveSessionPlayerRuntime player, @@ -189,6 +190,7 @@ internal sealed class LiveSessionRuntimeFactory private void ResetPlayerPresentation() { + _staminaExhaustion.Reset(); _interaction.PlayerMode.ResetSession(); _world.SpawnClaims.Reset(); } @@ -289,13 +291,6 @@ internal sealed class LiveSessionRuntimeFactory OnMovementStatsUpdated: () => ApplyMovementStats("stats")); } - /// - /// Tracks the previous "stamina exhausted" state so - /// can fire retail's exhaustion - /// notification on the EDGE only. Null = no stamina reading applied yet. - /// - private bool? _lastStaminaExhausted; - /// /// Re-applies the current /// snapshot (skills/burden/stamina) to the live player controller. @@ -328,14 +323,8 @@ internal sealed class LiveSessionRuntimeFactory } RuntimeMovementSkillSnapshot snapshot = _domain.Character.MovementSkills.Snapshot; - bool exhausted = snapshot.CurrentStamina == 0; - if (_lastStaminaExhausted != exhausted) - { - bool isEdge = _lastStaminaExhausted is not null; - _lastStaminaExhausted = exhausted; - if (isEdge) - controller!.Motion.ReportExhaustion(); - } + if (_staminaExhaustion.Observe(snapshot.CurrentStamina)) + controller!.Motion.ReportExhaustion(); _log( $"player: applied server movement {reason} " diff --git a/src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs b/src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs new file mode 100644 index 00000000..0fce7ea1 --- /dev/null +++ b/src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs @@ -0,0 +1,32 @@ +namespace AcDream.App.Net; + +/// +/// Tracks retail's stamina-exhaustion notification edge for one live-session +/// generation. +/// +internal sealed class StaminaExhaustionEdgeTracker +{ + private bool? _wasExhausted; + + /// + /// Observes the current stamina value and returns + /// only when a previously initialized state changes between exhausted and + /// non-exhausted. + /// + public bool Observe(int currentStamina) + { + bool exhausted = currentStamina == 0; + if (_wasExhausted == exhausted) + return false; + + bool isEdge = _wasExhausted.HasValue; + _wasExhausted = exhausted; + return isEdge; + } + + /// + /// Forgets the retiring character/session sample. The first sample in the + /// next generation establishes a baseline and must not synthesize an edge. + /// + public void Reset() => _wasExhausted = null; +} diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 8ea2bffb..870e9d47 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -168,9 +168,6 @@ internal sealed class LiveEntityNetworkUpdateController System.Numerics.Vector3 worldPos, uint cellId) { - if (cellId == 0) - return; - var (radius, height) = _motionRuntime.GetSetupCylinder(serverGuid, entity); if (radius < 0.05f) { @@ -193,33 +190,20 @@ internal sealed class LiveEntityNetworkUpdateController // retail's first frame would (position snapped onto the floor, // contact plane + CONTACT/ON_WALKABLE committed below). A sweep that // finds no floor (true airborne spawn) leaves the body airborne. - AcDream.Core.Physics.ResolveResult settle = _physicsEngine.ResolveWithTransition( - worldPos, - worldPos + new System.Numerics.Vector3(0f, 0f, -0.5f), - cellId, - sphereRadius: radius, - sphereHeight: height, - stepUpHeight: 0.4f, - stepDownHeight: 0.4f, - isOnGround: false, - body: remote.Body, - moverFlags: moverFlags, - movingEntityId: entity.Id); - if (!settle.Ok || !settle.InContact) - return; // no floor within reach — stays airborne like retail's fall - - remote.Body.Position = settle.Position; - - AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition( + if (!RemoteSpawnPlacementSettler.TrySettle( + _physicsEngine, remote.Body, - settle.InContact, - settle.OnWalkable, - settle.CollisionNormalValid, - settle.CollisionNormal, - previousContact: false, - previousOnWalkable: false, + worldPos, + cellId, + radius, + height, + moverFlags, + entity.Id, remote.Movement.HitGround, - remote.Motion.LeaveGround); + remote.Motion.LeaveGround)) + { + return; // no floor within reach — stays airborne like retail's fall + } remote.Airborne = !remote.Body.OnWalkable; } diff --git a/src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs b/src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs new file mode 100644 index 00000000..6ea506d0 --- /dev/null +++ b/src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs @@ -0,0 +1,62 @@ +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.App.Physics; + +/// +/// Performs the compressed first-gravity-frame settle used to establish +/// retail Contact/OnWalkable state for a newly materialized remote body. +/// +internal static class RemoteSpawnPlacementSettler +{ + internal const float SettleDistance = 0.5f; + + public static bool TrySettle( + PhysicsEngine physicsEngine, + PhysicsBody body, + Vector3 worldPosition, + uint cellId, + float sphereRadius, + float sphereHeight, + ObjectInfoState moverFlags, + uint movingEntityId, + Action hitGround, + Action leaveGround) + { + ArgumentNullException.ThrowIfNull(physicsEngine); + ArgumentNullException.ThrowIfNull(body); + ArgumentNullException.ThrowIfNull(hitGround); + ArgumentNullException.ThrowIfNull(leaveGround); + + if (cellId == 0) + return false; + + ResolveResult settle = physicsEngine.ResolveWithTransition( + worldPosition, + worldPosition - new Vector3(0f, 0f, SettleDistance), + cellId, + sphereRadius, + sphereHeight, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: false, + body, + moverFlags, + movingEntityId); + if (!settle.Ok || !settle.InContact) + return false; + + body.Position = settle.Position; + PhysicsObjUpdate.CommitSetPositionTransition( + body, + settle.InContact, + settle.OnWalkable, + settle.CollisionNormalValid, + settle.CollisionNormal, + previousContact: false, + previousOnWalkable: false, + hitGround, + leaveGround); + return true; + } +} diff --git a/tests/AcDream.App.Tests/Net/StaminaExhaustionEdgeTrackerTests.cs b/tests/AcDream.App.Tests/Net/StaminaExhaustionEdgeTrackerTests.cs new file mode 100644 index 00000000..c05290cf --- /dev/null +++ b/tests/AcDream.App.Tests/Net/StaminaExhaustionEdgeTrackerTests.cs @@ -0,0 +1,52 @@ +using AcDream.App.Net; + +namespace AcDream.App.Tests.Net; + +public sealed class StaminaExhaustionEdgeTrackerTests +{ + [Theory] + [InlineData(100)] + [InlineData(0)] + public void FirstSample_EstablishesBaselineWithoutNotification(int stamina) + { + var tracker = new StaminaExhaustionEdgeTracker(); + + Assert.False(tracker.Observe(stamina)); + } + + [Fact] + public void RepeatedStatsTicks_DoNotRedispatchMovement() + { + var tracker = new StaminaExhaustionEdgeTracker(); + + Assert.False(tracker.Observe(100)); + Assert.False(tracker.Observe(99)); + Assert.False(tracker.Observe(50)); + Assert.False(tracker.Observe(1)); + } + + [Fact] + public void ExhaustedStateTransitions_ReportExactlyOncePerEdge() + { + var tracker = new StaminaExhaustionEdgeTracker(); + + Assert.False(tracker.Observe(50)); + Assert.True(tracker.Observe(0)); + Assert.False(tracker.Observe(0)); + Assert.True(tracker.Observe(1)); + Assert.False(tracker.Observe(80)); + } + + [Fact] + public void Reset_PreventsCrossGenerationSyntheticEdge() + { + var tracker = new StaminaExhaustionEdgeTracker(); + Assert.False(tracker.Observe(50)); + Assert.True(tracker.Observe(0)); + + tracker.Reset(); + + Assert.False(tracker.Observe(75)); + Assert.True(tracker.Observe(0)); + } +} diff --git a/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs b/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs new file mode 100644 index 00000000..dbd1b5d4 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs @@ -0,0 +1,66 @@ +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.Physics; + +public sealed class Issue270ProductionWiringTests +{ + [Fact] + public void MovementStats_UseOneEdgeTrackerAndResetItWithTheSession() + { + string source = ReadSource("Net", "LiveSessionRuntimeFactory.cs"); + + Assert.Contains( + "_staminaExhaustion.Observe(snapshot.CurrentStamina)", + source, + StringComparison.Ordinal); + Assert.Single( + Regex.Matches( + source, + @"controller!\.Motion\.ReportExhaustion\(\);") + .Cast()); + Assert.Contains( + "_staminaExhaustion.Reset();", + source, + StringComparison.Ordinal); + } + + [Fact] + public void RemoteSpawnSettle_IsRetriedAndCoversBothCreationRoutes() + { + string source = ReadSource( + "Physics", + "LiveEntityNetworkUpdateController.cs"); + + Assert.Contains( + "if (!remote.Body.InContact)", + source, + StringComparison.Ordinal); + Assert.Equal( + 3, + Regex.Matches(source, @"SeedRemoteSpawnPlacement\(").Count); + Assert.Contains( + "RemoteSpawnPlacementSettler.TrySettle(", + source, + StringComparison.Ordinal); + } + + private static string ReadSource(params string[] relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return File.ReadAllText(Path.Combine( + directory.FullName, + "src", + "AcDream.App", + Path.Combine(relativePath))); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs new file mode 100644 index 00000000..cbef8c97 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs @@ -0,0 +1,109 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.Physics; + +public sealed class RemoteSpawnPlacementSettlerTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = Landblock | 0x0001u; + private const float Radius = 0.48f; + private const float Height = 1.835f; + + [Fact] + public void FloorWithinFirstGravityFrame_CommitsGroundContactOnce() + { + PhysicsEngine engine = BuildFlatEngine(); + PhysicsBody body = AirborneBody(new Vector3(12f, 12f, 0.25f)); + int hitGround = 0; + int leaveGround = 0; + + bool settled = RemoteSpawnPlacementSettler.TrySettle( + engine, + body, + body.Position, + Cell, + Radius, + Height, + ObjectInfoState.EdgeSlide, + movingEntityId: 0x70000001u, + () => hitGround++, + () => leaveGround++); + + Assert.True(settled); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + Assert.Equal(1, hitGround); + Assert.Equal(0, leaveGround); + } + + [Fact] + public void MissingCell_CanRetryAfterHydrationWithoutReconstructingBody() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + PhysicsBody body = AirborneBody(new Vector3(12f, 12f, 0.25f)); + Vector3 originalPosition = body.Position; + + Assert.False(TrySettle(engine, body)); + Assert.False(body.InContact); + Assert.Equal(originalPosition, body.Position); + + AddFlatLandblock(engine); + + Assert.True(TrySettle(engine, body)); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + } + + [Fact] + public void FloorOutsideSettleDistance_LeavesGenuinelyAirborneBodyUnchanged() + { + PhysicsEngine engine = BuildFlatEngine(); + PhysicsBody body = AirborneBody(new Vector3(12f, 12f, 1.25f)); + Vector3 originalPosition = body.Position; + + Assert.False(TrySettle(engine, body)); + + Assert.False(body.InContact); + Assert.False(body.OnWalkable); + Assert.Equal(originalPosition, body.Position); + } + + private static bool TrySettle(PhysicsEngine engine, PhysicsBody body) => + RemoteSpawnPlacementSettler.TrySettle( + engine, + body, + body.Position, + Cell, + Radius, + Height, + ObjectInfoState.EdgeSlide, + movingEntityId: 0x70000001u, + static () => { }, + static () => { }); + + private static PhysicsBody AirborneBody(Vector3 position) => new() + { + Position = position, + Orientation = Quaternion.Identity, + State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.None, + }; + + private static PhysicsEngine BuildFlatEngine() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + AddFlatLandblock(engine); + return engine; + } + + private static void AddFlatLandblock(PhysicsEngine engine) => + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); +} From 461a1fb7b49895640abe7877e85c772a4fe0c052 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 08:08:23 +0200 Subject: [PATCH 02/73] feat(player): port retail augmentation stat chain --- docs/ISSUES.md | 50 +++++--- .../retail-divergence-register.md | 13 +- docs/plans/2026-04-11-roadmap.md | 6 + .../2026-07-29-physics-parity-campaign.md | 22 ++-- ...-07-30-stat-coupled-movement-pseudocode.md | 42 +++--- src/AcDream.App/UI/Layout/CharacterSheet.cs | 14 +- .../UI/Layout/CharacterSheetProvider.cs | 37 ++++-- .../UI/Layout/CharacterStatController.cs | 120 +++++++++++++++++- src/AcDream.App/UI/UiText.cs | 66 ++++++++++ src/AcDream.Core/Player/LocalPlayerState.cs | 89 +++++++++---- src/AcDream.Core/Player/PlayerSkillMath.cs | 108 ++++++++++++++++ src/AcDream.Core/Spells/EnchantmentMath.cs | 37 +++--- src/AcDream.Core/Spells/Spellbook.cs | 8 +- .../Gameplay/RuntimeCharacterState.cs | 45 +++++-- .../Session/LiveSessionEventRouter.cs | 37 ++++-- .../UI/Layout/CharacterLayoutImportProbe.cs | 26 ++++ .../UI/Layout/CharacterSheetProviderTests.cs | 60 +++++++++ .../UI/Layout/CharacterStatControllerTests.cs | 73 ++++++++++- .../GameEventWiringTests.cs | 61 ++++++++- .../Player/PlayerSkillMathTests.cs | 78 ++++++++++++ .../Gameplay/RuntimeCharacterStateTests.cs | 47 ++++++- .../Session/LiveSessionEventRouterTests.cs | 52 ++++++++ 22 files changed, 953 insertions(+), 138 deletions(-) create mode 100644 src/AcDream.Core/Player/PlayerSkillMath.cs create mode 100644 tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 49ecb19a..1bc96735 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -158,7 +158,7 @@ cos(10°) with base 0.2); the jump chain end-to-end (`GetJumpHeight` 0x006b09b0 exact incl. 1300/22.2/0.05/0.35, `InqJumpVelocity` 0x00592980 `vz=sqrt(h*19.6)`, powerbar charge 1.0 s / 0.8 s dual-wield) — the user's "we jump too high" hypothesis is REFUTED, jump height is retail-parity -(our eff skill is 5 lower pending #268's augmentation port). Retail has no +(the former five-point effective-skill gap was closed by #268). Retail has no Sledding auto-toggle (P2 finding re-confirmed; no `state |= 0x800000` writer exists). @@ -175,27 +175,29 @@ alignment. ## #268 — Character panel: vitae color, buff coloring, and augmentation bonuses -**Status:** OPEN (user direction 2026-07-30, follow-up to #267) +**Status:** IMPLEMENTED 2026-07-31 — closure pends the user visual/live gate. **Severity:** MEDIUM (presentation parity) **Component:** retained UI / character window -**User direction:** (a) the vitae parenthetical (text + number) renders in -light blue — VERIFY the exact retail color from the decomp/DAT before -hardcoding (the chat-color cdb recipe in -`claude-memory/reference_retail_chat_colors.md` is the fallback method); -(b) when a buff-like effect is active (including AUGMENTATIONS such as -+Acdream's +5-all-skills), affected skills AND attributes render green -(buff coloring) — #267's CurrentLevel/BaseLevel split made the green/red -coloring live, but augmentation bonuses are not part of the effective -value yet, so the color never triggers for them; (c) augmentation skill -bonuses must be included in the displayed effective values. +**Resolution:** the shared Core `PlayerSkillMath` now ports +`CACQualities::InqSkill @ 0x00592660` in retail order for both the +character panel and Runtime movement: intrinsic skill, positive +`LumAugAllSkills` (0x16D), the authored +10 melee/missile/magic category +augmentation, `EnchantSkill`, then +5 Jack of All Trades (0x146) and +`2 × LumAugSkilledSpec` (0x158) for specialized skills. Live player +PropertyInt updates recompute the Runtime snapshot, so the display and +run/jump prediction cannot drift. -**Register link:** this PROMOTES AP-127 — the "two minor unmodeled retail -bonus properties" (0x146/0x158 family — the all-skills augmentation -terms feeding `CACQualities::InqRunRate`/`InqJumpVelocity` BEFORE -`EnchantSkill`) are user-visible in both the panel and potentially -movement speed. Port them into the CACQualities chain (movement) and the -panel's effective-value computation together, decomp-first. +`AttributeInfoRegion::Update @ 0x004F1910`, +`Attribute2ndInfoRegion::Update @ 0x004F19E0`, and +`SkillInfoRegion::Update @ 0x004F1AE0` now drive exact value coloring: +green/red compare the non-vitae residual against the base, so a pure vitae +penalty remains white. The selected-skill footer uses one shared inline-run +text primitive matching retail `AppendTextWithFont`; its vitae fragment uses +the authored LayoutDesc 0x2100002E / FooterTitle 0x1000024E palette index 3 +(#7FFFFF), while positive/negative buff fragments use palette indices 1/2 +(#00FF00/#FF0000). Attributes, secondary attributes, and skills share those +exact colors. AP-127 and TS-8 are retired by the same stat-chain package. --- @@ -11080,7 +11082,13 @@ The remaining trailer sections (options / shortcuts / hotbars / inventory / equi **Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow` **Resolution:** Closed alongside #7 in the same commit. `ActiveEnchantmentRecord` extended with optional `StatModType`, `StatModKey`, `StatModValue`, `Bucket` fields. `Spellbook` got an `OnEnchantmentAdded(ActiveEnchantmentRecord)` overload that accepts the full record. `EnchantmentMath.GetMod` aggregator now consumes the StatMod data: multiplicative bucket (1) → multiplier ×= val; additive bucket (2) → additive += val; vitae bucket (8) → multiplier ×= val (applied last, matching retail `CEnchantmentRegistry::EnchantAttribute` semantics). 5 new EnchantmentMath StatMod-aware tests cover: multiplicative buffs aggregate, additive buffs sum, stat-key mismatch is filtered out, vitae applies multiplicatively, family-stacking picks the higher spell-id buff. -`ParseMagicUpdateEnchantment` (the live-update opcode 0x02C2) is **not** yet extended — it still uses the 4-field summary. That's a separate refactor; PlayerDescription's enchantment block is the load-bearing path for issue #6, and that's now flowing. +**2026-07-31 live-update closeout:** `ParseMagicUpdateEnchantment` +(0x02C2) now parses the complete record, including start time, DegradeModifier, +degrade limit, last time degraded, StatMod type/key/value, and bucket +classification. `GameEventWiring` maps that immutable wire record into the +same `ActiveEnchantmentRecord` shape as PlayerDescription. The end-to-end +test sends an actual 0x02C2 payload through dispatch and proves the resulting +StatMod changes the local player's effective skill without relogging. --- @@ -11090,7 +11098,9 @@ The remaining trailer sections (options / shortcuts / hotbars / inventory / equi **Commit:** `feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath` **Resolution:** Ported `CEnchantmentRegistry::EnchantAttribute` (PDB `0x00594570`) as `EnchantmentMath.GetMod(IEnumerable, SpellTable, statKey)` returning `(Multiplier, Additive)`. Family-stacking dedup via `SpellTable.Family` (only one buff per family bucket wins, by highest spell-id as a generation proxy). `Spellbook.GetVitalMod(statKey)` delegates. `LocalPlayerState.GetMaxApprox` reworked to apply `(unbuffed × mult) + add` with retail's min-vital clamp (`>= 5` if base ≥ 5 else `>= 1`, matches `CreatureVital::GetMaxValue` at PDB `0x0058F2DD`). Stat-key constants (`MaxHealth=1`, `MaxStamina=3`, `MaxMana=5`) verified against `docs/research/named-retail/acclient.h` line 37287-37301. -**Architecture in place; data still flat.** Until ISSUES.md #12 lands the wire-format extension that captures `StatMod (type/key/val)` on `ActiveEnchantmentRecord`, the per-enchantment modifier value isn't aggregated yet — `EnchantmentMath.GetMod` returns `Identity (1.0, 0.0)` for every stat key. Once #12 wires the data, the existing aggregator + formula light up automatically. Live `+Acdream` Stam/Mana percent will continue to read ~95% until #12 lands. +**Data path complete:** both PlayerDescription and live 0x02C2 updates carry +the full StatMod into `ActiveEnchantmentRecord`; the shared aggregator applies +it immediately to attributes, vitals, skills, movement, and retained UI. 6 new EnchantmentMathTests cover: empty list returns Identity, no-table-entries returns Identity, stat-key constants match ACE enum, Identity is `(1, 0)`, family-stacking dedup, family=0 (no-bucket) treated as separate. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a4d7306b..f68e1b5e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -118,7 +118,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 91 active rows (AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-127 filed same slice for the two minor unmodeled bonus properties; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 90 active rows (AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -233,10 +233,10 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-124 | Local ACE omits the new object's CreateObject to the initiating session after `StackableSplitTo3D`, sending only F748 Position for the previously unknown GUID. acdream retains retail's one pending split source/count/time identity for ten seconds and, only for that otherwise-impossible unknown Position, hydrates a canonical clone of the source description with the new GUID and authoritative world placement. | `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`; `src/AcDream.App/UI/ItemInteractionController.cs`; `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` | Nearby/reconnecting clients receive the ordinary CreateObject, while the initiator otherwise cannot render the authoritative object until relog. A server that sends CreateObject never enters this path; the pending identity is consumed before normal hydration and all other unknown Positions remain rejected. | ACE's F748 does not carry WCID or stack size, so an unrelated unknown Position arriving during the exact pending ten-second window could be associated with the split. Retail confirms WCID/count from CreateObject; removing the approximation requires ACE to send that packet to the initiator. | `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850`; `ACCWeenieObject::DeclareValid @ 0x0058E340`; ACE `Player.HandleActionStackableSplitTo3D` / `TryDropItem`; `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` | | AP-125 | Transport control packets (the 2.0 s cumulative AckSequence and the 0.6 s RequestRetransmit) are emitted STANDALONE; retail piggybacks optional headers onto queued outbound packets first-fit (`FlowQueue::CoalesceData @ 0x00547740`, invoked at `TransmitNewPackets @ 0x00547A6E`), and `EnqueueNaks` hands the NAK to `PacketController::EnqueueOptionalHeader @ 0x00543C84` rather than emitting directly. | `src/AcDream.Core.Net/Transport/AckNakScheduler.cs` (`EmitCumulativeAck`, `EmitNakRequest`) | ACE honours a RequestRetransmit ONLY when EncryptedChecksum is absent (NetworkSession.cs:283-284) — a retail-style piggyback onto a sequenced packet encrypts the NAK and ACE silently ignores it, making S2C loss unrecoverable; ACE likewise advances its client-sequence watermark on any packet whose flags are not exactly AckSequence (:474-476), so coalesced control content on a borrowed sequence risks skipping a real packet. Standalone exact-flag emission is the only ACE-safe shape; it also keeps reliable packets free of optional headers, making the resend cache strip provably a no-op. | Slightly higher C2S datagram count than retail (one extra small packet per 2.0 s / per NAK window); marginally more loss exposure for the control packets themselves on a metered path. | `FlowQueue::CoalesceData @ 0x00547740`; `SharedNet::EnqueuePak @ 0x00543B10`; `SharedNet::EnqueueNaks @ 0x00543BD0`; ACE `NetworkSession.cs:283-284,:342-343,:474-476` | | AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) | -| AP-127 | Campaign P Slice P1's run/jump base-skill chain omits two minor retail additive/multiplier terms feeding `CACQualities::InqRunRate`/`InqJumpVelocity` BEFORE `EnchantSkill` runs (property `0x146` "> 0 → +5" bonus; property `0x158` "specialized skill" doubling of a PP-derived term), and reads the raw wire current-stamina value for the zero-skill gate rather than the retail-adjusted local copy (`EnchantAttribute2nd(ATTR2ND_STAMINA)` can apply a Stamina-buff to that check's own copy without changing the displayed vital) | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`ApplySkillEnchantments`); `src/AcDream.Core/Physics/PlayerWeenie.cs` (`InqRunRate`/`InqJumpVelocity` stamina==0 gate) | Bounded per the P1 plan's explicit scope ("port only what the run/jump query path needs... not a general effective-skill engine"); both terms are rare/small relative to the dominant formulaBonus+init+ranks+vitae chain, which IS fully ported | A character with the specific rare property set (0x146/0x158) or an active Stamina-buff at exactly 0 raw stamina predicts a slightly different run/jump skill than retail; low practical impact | `CACQualities::InqRunRate` 0x00592800 pc 413824 (0x146/0x158 reads); `CEnchantmentRegistry::EnchantAttribute2nd` 0x00594670 pc 416169 | +| ~~AP-127~~ | **RETIRED 2026-07-31 (#268).** `PlayerSkillMath` now owns retail `CACQualities::InqSkill` ordering for both panel values and Runtime run/jump prediction: intrinsic + positive 0x16D all-skills + the exact +10 category switch, then `EnchantSkill`, then 0x146 Jack of All Trades +5 and specialized-only `2 × 0x158`. Live player PropertyInt changes refresh the immutable Runtime augmentation snapshot. The separately described current-stamina local-copy nuance was re-audited: the query reads current stamina, but ordinary max-vital buffs target the max-secondary key and do not create stamina when current is zero; no independently observable residual remains. | `src/AcDream.Core/Player/PlayerSkillMath.cs`; `src/AcDream.Core/Player/LocalPlayerState.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs`; `src/AcDream.Runtime/Session/LiveSessionEventRouter.cs` | — | — | `CACQualities::InqSkill @ 0x00592660`; `CACQualities::InqRunRate @ 0x00592800`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0` | | AP-128 | **PK-timer jump-cost clock basis unconfirmed** (filed at the P3 Opus review, 2026-07-30): `PlayerWeenie.JumpStaminaCost` evaluates retail's 20-second PK-recency window (`LastPkAttackTimestamp` PropertyFloat 0x91 + 20.0 >= now) against `Environment.TickCount64` process-uptime seconds. The magnitude argument is sound (a 32-bit float cannot hold a Unix epoch with sub-second precision — a conformance test caught the ±128 s swallow), but the wire timestamp's own basis is the SERVER's, so a cross-base compare is latent. INERT today: ACE models neither property, so `_lastPkAttackTimestamp` is never pushed and the branch never fires. | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost` remarks) | Branch unreachable against every ACE-family server; non-PK cost is bit-identical to pre-P3. The basis question is cdb-answerable (`Timer::cur_time` epoch) if a PK server is ever targeted. | Against a hypothetical server that sends PropertyFloat 0x91, the PK cost bump fires arbitrarily (always/never) instead of on the 20-second window. | `CACQualities::JumpStaminaCost 0x00591b90` pc 412934-412968; `Timer::cur_time`; stat-coupled pseudocode doc §12b | -## 4. Temporary stopgap (TS) — 35 active rows (Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-4/TS-5/TS-23/TS-46 retired by ports — ZERO goal-enumerated physics stopgaps remain; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 34 active rows (TS-8 retired 2026-07-31 — live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately; Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-4/TS-5/TS-23/TS-46 retired by ports — ZERO goal-enumerated physics stopgaps remain; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -244,7 +244,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-4 | **RE-OPENED 2026-07-30 after the matrix live gate**: the fixture-gated removal shipped and the user immediately hit the wedge live ("stuck sliding on an edge") plus a non-retail uphill-jump bounce and lost roof slides — the horizontal-velocity convergence claim under-modeled real trajectories. Removal reverted; the oracle plan §7 degenerate analysis needs live-capture-driven rework before any retry. Original row: Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | -| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs | +| ~~TS-8~~ | **RETIRED 2026-07-31 (#268 stat-chain closeout).** `EnchantmentWireReader` parses the complete 0x02C2 payload and `GameEventWiring` publishes its StatMod type/key/value and bucket through the same `ActiveEnchantmentRecord` used at login. An end-to-end dispatch test proves a mid-session skill modifier changes `LocalPlayerState.GetEffectiveSkill` immediately. | `src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` | — | — | `CEnchantmentRegistry::EnchantAttribute @ 0x00594570`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0`; holtburger `messages/magic/types.rs` | | TS-9 | MP3 (0x55) and MS-ADPCM (0x02) waves undecoded — affected sounds skipped; retail decoded both via winmm ACM | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Managed decoder (NAudio or similar) deferred; PCM covers the vast majority of ~3500 waves | Any MP3 (common for music-ish clips) or ADPCM cue plays as silence where retail plays it | winmm ACM path (r05 §2.1) | | TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition | | TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 | @@ -310,9 +310,8 @@ WITH that phase, not before. 3. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). 4. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. 5. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. -6. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. -7. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together. -8. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. +6. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together. +7. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** M2 combat must land TS-25 diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 7cdfb1b2..d9b4954b 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -46,6 +46,12 @@ a ledger pass. Goal: zero physics TS rows, no unargued feel-affecting AP rows, one final batched connected visual matrix. Sonnet implements, Opus reviews. The plan is [`2026-07-29-physics-parity-campaign.md`](2026-07-29-physics-parity-campaign.md). +The 2026-07-31 #268 stat-chain package is implemented pending its live visual +gate: panel and Runtime movement share retail's complete augmentation ordering, +the authored per-fragment vitae/buff/debuff colors are live, and AP-127 plus +TS-8 are retired by focused and end-to-end packet tests. The remaining +implementation target is #269's capture-driven slope-slide feel residual, +followed by the unfinished live matrix rows. --- diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index 6c9fd421..814ef6df 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -54,8 +54,9 @@ PK-timer jump-cost decode for P3). Full Release suite 9,880/0/5 at the slice gate. Retail's vitae/enchant chain reuses the M3 bucket-4 representation; `JumpStaminaCost` never refuses (weak-jump only) — the plan's formula shorthand had the `+0.5` operand wrong and the -implementation follows the decomp's `(load+0.5)*power*8+2`. AP-127 filed -(two minor bonus properties, out of bounded scope). +implementation follows the decomp's `(load+0.5)*power*8+2`. AP-127 was +filed for the then-bounded bonus properties and retired by #268 on +2026-07-31. Today `PlayerWeenie.SetBurden` has zero callers, `CanJump` is always true, `JumpStaminaCost` is 0, and pushed run/jump skill is @@ -296,7 +297,8 @@ waits on the single user gate below. TS-4 (+ its FlatBspQuery twin), TS-5, TS-23, TS-35, TS-46 retired by ports; TS-25 retired on #219 evidence; TS-24→AD-57, TS-40→AD-58 re-argued. AP-7, AP-10, AP-25, AP-71 retired; UN-8 and AD-55 retired by -raw-byte proof; AD-25 retired. New argued rows: AP-127/128/129, +raw-byte proof; AD-25 retired. AP-127 was subsequently retired by #268; +the remaining new argued rows are AP-128/129, AD-53/54/55(retired)/56/57/58. **Issues:** #72, #153, #167, #255 closed; #116 shape-2 closed /shape-1 @@ -369,11 +371,15 @@ root-caused, retail-ported, and user-accepted in the same session: Downhill bounce chain, flat-ground pop, and clean uphill landings all user-accepted ("almost pass with merits"). Investigation + byte-decode record: `docs/research/2026-07-30-landing-bounce-family.md`. -- **#267 shipped** (vitae/buff panel values; attributes vitae-immune) — - visual pass; **#268 filed** (light-blue #7FFFFF vitae parenthetical - from the authored 0x1B palette, green/red buff coloring, augmentation - properties 0x146/0x158/0x16d — promotes AP-127; retail color indices - and the aug chain fully decoded, implementation pending). +- **#267 shipped** (vitae/buff panel values; attributes vitae-immune). + **#268 implemented 2026-07-31; live visual gate pending**: the complete + augmentation chain is shared by panel and Runtime movement; AP-127 is + retired. Attributes, secondary attributes, and skills use retail's + vitae-excluded green/red comparison. The selected-skill footer now renders + per-fragment colors through the shared retained text primitive, using the + authored 0x1B palette exactly: #7FFFFF vitae, #00FF00 buff, #FF0000 + debuff. TS-8 is also retired: a real live 0x02C2 payload carries its full + StatMod through dispatch and changes the effective skill immediately. - **#269 filed** — slope-stop slide sometimes runs long. `calc_friction` (0x0050ee70) and the complete jump chain (`GetJumpHeight` 0x006b09b0, `InqJumpVelocity` vz=sqrt(h·19.6), 1.0 s/0.8 s powerbar charge) are diff --git a/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md b/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md index 1e0496c0..6d8ff3ee 100644 --- a/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md +++ b/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md @@ -141,10 +141,13 @@ InqRunRate(this, &rateOut): EnchantAttribute2nd(this, 4, ¤tStamina) // vital-buff adjusts the LOCAL COPY only // (not the wire "current stamina" state) - skill = InqSkillBaseLevel(this, SKILL_RUN=0x18) // base: formula-bonus + init + ranks - // (+ two minor bonus properties 0x146, - // 0x158 — NOT ported, see §6 AP-127) - EnchantSkill(this, 0x18, &skill) // vitae * skill-enchantments, floor@0.5, round + skill = InqSkillBaseLevel(this, SKILL_RUN=0x18) // formula-bonus + init + ranks + skill += max(PropertyInt 0x16D, 0) // LumAugAllSkills + skill += matching category augmentation ? 10 : 0 // 0x12C melee / 0x12D missile / + // 0x12E magic; exact skill-id switch + EnchantSkill(this, 0x18, &skill) // vitae * skill-enchantments, floor@0.5, truncate + if (PropertyInt 0x146 > 0) skill += 5 // Jack of All Trades + if (skill is specialized) skill += 2 * max(PropertyInt 0x158, 0) if (currentStamina == 0) skill = 0 // THE stamina-gates-movement mechanism @@ -189,16 +192,21 @@ skill... reading vitae + relevant skill enchantments from the M3 active-effect state" without a general effective-skill engine — the only new code is the type-flag filter and the skill-id key. -**Two things P1 deliberately does NOT port** (bounded scope, register row -AP-127): -1. Two minor additive skill-bonus properties inside `InqSkillBaseLevel`'s - surrounding block (property `0x146` "> 0 → +5", property `0x158` - "specialized-skill → double a PP-derived term") — small, rare bonuses - unrelated to burden/stamina/vitae. -2. `EnchantAttribute2nd`'s buff-adjustment of the LOCAL stamina-current copy - used only for the `== 0` gate (i.e. a Stamina-boosting buff could - theoretically keep that local copy above 0 even at true-zero wire - stamina). We gate on the raw wire "current stamina" value directly. +**2026-07-31 #268 closeout:** the previously bounded augmentation terms are +now ported in shared `PlayerSkillMath`, after a complete read of +`CACQualities::InqSkill @ 0x00592660`. The exact order matters: + +1. intrinsic formula/init/ranks; +2. positive property 0x16D plus the exact category +10 switch; +3. `EnchantSkill`; +4. property 0x146 contributes +5 when positive; +5. specialized skills receive `2 × max(property 0x158, 0)`. + +The character panel and Runtime movement both consume this one Core +calculation. AP-127 is retired. The apparent current-stamina-copy residual +does not create an independently reachable effect for ordinary stat +enchantments: current and maximum stamina use distinct secondary-attribute +keys, and a max-stamina enchantment cannot turn zero current stamina nonzero. ## 6. GetRunRate / GetJumpHeight / JumpStaminaCost formula bodies (MovementSystem, pc 695958+) @@ -365,10 +373,8 @@ every pre-P1 `PlayerWeenieTests.cs` expectation unchanged. - **TS-23 extended** (not a new row) — its "PlayerKillerStatus not parsed" scope now also covers the new `MovementSystem.JumpStaminaCost` `pk` parameter, hardcoded `false` at the `PlayerWeenie` call site pending P3. -- **New AP-127** — two minor retail skill-bonus properties (0x146, 0x158) - and the stamina-buff-adjusts-local-copy nuance are not ported (§5, §9 - bullet list) — bounded, deliberate, low-risk (rare bonus terms, not - burden/stamina/vitae). +- **AP-127 retired 2026-07-31 (#268)** — the complete 0x16D/category/ + 0x146/0x158 chain is shared by panel and movement (§5 closeout). - **New UN-8** — `CACQualities::CanJump`'s x87 comparison polarity resolved by domain plausibility rather than a literal BN read (§3); Ghidra MCP confirmation is the retire path. diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index 58711305..fd7ae395 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -86,6 +86,17 @@ public sealed class CharacterSheet public int ManaCurrent { get; init; } public int ManaMax { get; init; } + /// + /// Unenchanted max Health/Stamina/Mana in that order. + /// + public int[] VitalBaseMaxValues { get; init; } = Array.Empty(); + + /// + /// Isolated vitae contribution to max Health/Stamina/Mana, always + /// non-positive and ordered like . + /// + public int[] VitalVitaeModifiers { get; init; } = Array.Empty(); + // ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ──────────── // InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self. @@ -202,7 +213,8 @@ public sealed record CharacterSkill( uint IconDid, CharacterSkillAdvancementClass AdvancementClass, int BaseLevel, - // Issue #267: CurrentLevel is now the EFFECTIVE (vitae + buff) level — + // BaseLevel is retail's pre-EnchantSkill value, including augmentation + // terms. CurrentLevel is the EFFECTIVE (vitae + buff) level — // retail CACQualities::EnchantSkill (0x005947b0). Previously an alias of // BaseLevel; this activates the existing CharacterStatController. // SkillValueColor buffed/debuffed row coloring. diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 51ac37ef..69cddd55 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -140,6 +140,18 @@ public sealed class CharacterSheetProvider StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina), ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana), ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana), + VitalBaseMaxValues = + [ + VitalBaseMax(LocalPlayerState.VitalKind.Health), + VitalBaseMax(LocalPlayerState.VitalKind.Stamina), + VitalBaseMax(LocalPlayerState.VitalKind.Mana), + ], + VitalVitaeModifiers = + [ + _localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Health), + _localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Stamina), + _localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Mana), + ], // Issue #267: the panel's main attribute values are EFFECTIVE // (post-buff) — retail CACQualities::EnchantAttribute. Base values @@ -176,7 +188,7 @@ public sealed class CharacterSheetProvider AttrCurrent(LocalPlayerState.AttributeKind.Focus), AttrCurrent(LocalPlayerState.AttributeKind.Self), }, - Skills = BuildLiveCharacterSkills(), + Skills = BuildLiveCharacterSkills(props), BurdenCurrent = props.GetInt(5u), BurdenMax = props.GetInt(96u), EncumbranceAugmentations = props.GetInt(0xE6u), @@ -344,7 +356,8 @@ public sealed class CharacterSheetProvider } } - private IReadOnlyList BuildLiveCharacterSkills() + private IReadOnlyList BuildLiveCharacterSkills( + PropertyBundle properties) { var result = new List(); var skillTable = SkillTable; @@ -374,23 +387,26 @@ public sealed class CharacterSheetProvider // retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier // isolates vitae's own contribution for the footer's separate // vitae parenthetical (SkillInfoRegion::GetVitaeModifier 0x004f0fa0). - int effectiveLevel = _localPlayer.GetEffectiveSkill(snapshot.SkillId) - ?? checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)); - int vitaeModifier = _localPlayer.GetSkillVitaeModifier(snapshot.SkillId); + PlayerSkillMath.Value values = + _localPlayer.GetSkillValue(snapshot.SkillId, properties) + ?? new PlayerSkillMath.Value( + checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)), + checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)), + 0); result.Add(new CharacterSkill( snapshot.SkillId, name, icon, advancement, - checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)), - effectiveLevel, + values.UnenchantedLevel, + values.EffectiveLevel, IsUsableUntrained(snapshot.SkillId), trainedCost, specializedCost, raiseCost, raise10Cost, - vitaeModifier)); + values.VitaeModifier)); } return result; @@ -467,6 +483,11 @@ public sealed class CharacterSheetProvider private int VitalMax(LocalPlayerState.VitalKind kind) => _localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0; + private int VitalBaseMax(LocalPlayerState.VitalKind kind) => + _localPlayer.GetBaseMaxApprox(kind) is { } max + ? checked((int)Math.Min(int.MaxValue, max)) + : 0; + // ── Raise-request flow ───────────────────────────────────────────────── /// diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index fa60c650..d8b56b46 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -110,7 +110,11 @@ public static class CharacterStatController /// Row highlight color — semi-translucent gold, matches retail /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent. private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f); - private static readonly Vector4 BuffedSkillGreen = new(0.55f, 1f, 0.55f, 1f); + // LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B: + // [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF). + private static readonly Vector4 RetailBuffGreen = new(0f, 1f, 0f, 1f); + private static readonly Vector4 RetailDebuffRed = new(1f, 0f, 0f, 1f); + private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f); // ── Row layout constants ───────────────────────────────────────────────── // RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height @@ -688,7 +692,8 @@ public static class CharacterStatController _ => 0, }; return v.ToString(); - }); + }, + valueColorProvider: () => AttributeValueColor(data(), rowIndex)); row.OnClick = () => { @@ -719,7 +724,8 @@ public static class CharacterStatController 2 => $"{s.ManaCurrent}/{s.ManaMax}", _ => string.Empty, }; - }); + }, + valueColorProvider: () => VitalValueColor(data(), rowIndex)); row.OnClick = () => { @@ -872,10 +878,48 @@ public static class CharacterStatController return null; } - private static Vector4 SkillValueColor(CharacterSkill skill) - => skill.CurrentLevel > skill.BaseLevel ? BuffedSkillGreen - : skill.CurrentLevel < skill.BaseLevel ? new Vector4(1f, 0.45f, 0.45f, 1f) + internal static Vector4 SkillValueColor(CharacterSkill skill) + { + int withoutVitae = skill.CurrentLevel - skill.VitaeModifier; + return withoutVitae > skill.BaseLevel ? RetailBuffGreen + : withoutVitae < skill.BaseLevel ? RetailDebuffRed : Vector4.One; + } + + internal static Vector4 AttributeValueColor( + CharacterSheet sheet, + int rowIndex) + { + int delta = GetAttributeDelta(sheet, rowIndex); + return delta > 0 ? RetailBuffGreen + : delta < 0 ? RetailDebuffRed + : Vector4.One; + } + + internal static Vector4 VitalValueColor( + CharacterSheet sheet, + int vitalIndex) + { + if ((uint)vitalIndex >= 3u + || vitalIndex >= sheet.VitalBaseMaxValues.Length + || vitalIndex >= sheet.VitalVitaeModifiers.Length) + { + return Vector4.One; + } + + int effective = vitalIndex switch + { + 0 => sheet.HealthMax, + 1 => sheet.StaminaMax, + 2 => sheet.ManaMax, + _ => 0, + }; + int withoutVitae = effective - sheet.VitalVitaeModifiers[vitalIndex]; + int baseline = sheet.VitalBaseMaxValues[vitalIndex]; + return withoutVitae > baseline ? RetailBuffGreen + : withoutVitae < baseline ? RetailDebuffRed + : Vector4.One; + } /// /// Handles a row click: toggle (same row → deselect), else select new row. @@ -1315,6 +1359,64 @@ public static class CharacterStatController return $"{name}: {value}{delta}"; } + private static IReadOnlyList BuildSelectedTitleRuns( + UiText target, + CharacterStatTab tab, + Func data, + int[] attrSel, + int[] skillSel) + { + Vector4 Color(int index) => + index >= 0 && index < target.FontColorPalette.Count + ? target.FontColorPalette[index] + : index switch + { + 1 => RetailBuffGreen, + 2 => RetailDebuffRed, + 3 => RetailVitaeBlue, + _ => Vector4.One, + }; + + if (tab == CharacterStatTab.Skills) + { + CharacterSkill? skill = SkillAtDisplayIndex(data(), skillSel[0]); + if (skill is null) + return [new("Select a Skill to Improve", Body)]; + if (skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) + return [new(skill.Name, Color(0))]; + + var runs = new List + { + new($"{skill.Name}: {skill.CurrentLevel}", Color(0)), + }; + if (skill.VitaeModifier < 0) + runs.Add(new(FormatVitaeDelta(skill.VitaeModifier), Color(3))); + int buffDelta = GetSkillBuffOnlyDelta(skill); + if (buffDelta != 0) + runs.Add(new( + FormatBuffDelta(buffDelta), + Color(buffDelta > 0 ? 1 : 2))); + return runs; + } + + if (attrSel[0] < 0) + return [new("Select an Attribute to Improve", Body)]; + + CharacterSheet sheet = data(); + var attributeRuns = new List + { + new( + $"{GetRowName(attrSel[0])}: {GetRowValueString(sheet, attrSel[0])}", + Color(0)), + }; + int delta = GetAttributeDelta(sheet, attrSel[0]); + if (delta != 0) + attributeRuns.Add(new( + FormatBuffDelta(delta), + Color(delta > 0 ? 1 : 2))); + return attributeRuns; + } + /// /// Add a single attribute/vital row to as a /// containing icon + name + value children. @@ -1512,6 +1614,12 @@ public static class CharacterStatController // Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here. // RightAligned stays false (BuildText default for a Center element). titleEl.ClickThrough = true; + titleEl.RunsProvider = () => BuildSelectedTitleRuns( + titleEl, + activeTab[0], + data, + attrSel, + skillSel); titleEl.LinesProvider = () => { string title = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel); diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 73d57334..9057e95b 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -32,6 +32,11 @@ public sealed class UiText : UiElement, IUiDatStateful /// One display line: pre-formatted text + its colour. public readonly record struct Line(string Text, Vector4 Color); + /// + /// One inline fragment in a retail AppendTextWithFont line. + /// + public readonly record struct TextRun(string Text, Vector4 Color); + /// A caret position: a line index into the cached line list plus a /// character index (0..line.Text.Length, i.e. a caret slot between glyphs). public readonly record struct Pos(int Line, int Col); @@ -39,6 +44,13 @@ public sealed class UiText : UiElement, IUiDatStateful /// Provider of the lines to show, oldest-first. Polled each frame. public Func> LinesProvider { get; set; } = static () => Array.Empty(); + /// + /// Optional inline fragments for a static one-line element. When present + /// this reproduces retail's per-append font-state colors while preserving + /// the element's authored alignment as one composed line. + /// + public Func>? RunsProvider { get; set; } + /// Font for the transcript; falls back to the context default. public BitmapFont? Font { get; set; } @@ -381,6 +393,12 @@ public sealed class UiText : UiElement, IUiDatStateful private void DrawClippedText(UiRenderContext ctx) { + if (OneLine && RunsProvider is { } runsProvider) + { + DrawSingleLineRuns(ctx, runsProvider()); + return; + } + // Static centered single-line mode (vitals cur/max numbers etc.): draw the first // line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula // UIElement_Meter used for its label, then skip the scroll/selection machinery entirely. @@ -533,6 +551,54 @@ public sealed class UiText : UiElement, IUiDatStateful } } + private void DrawSingleLineRuns( + UiRenderContext ctx, + IReadOnlyList runs) + { + if (runs.Count == 0) return; + + UiDatFont? datFont = DatFont; + BitmapFont? bitmapFont = datFont is null + ? Font ?? ctx.DefaultFont + : null; + if (datFont is null && bitmapFont is null) return; + + float totalWidth = 0f; + foreach (TextRun run in runs) + { + totalWidth += datFont is not null + ? datFont.MeasureWidth(run.Text) + : bitmapFont!.MeasureWidth(run.Text); + } + + float x = Centered + ? Math.Max(Padding, (Width - totalWidth) * 0.5f) + : RightAligned + ? Math.Max(Padding, Width - Padding - totalWidth) + : Padding; + float lineHeight = datFont?.LineHeight ?? bitmapFont!.LineHeight; + float y = VOffset( + Height, + lineHeight, + Padding, + VerticalJustify); + + foreach (TextRun run in runs) + { + if (run.Text.Length == 0) continue; + if (datFont is not null) + { + ctx.DrawStringDat(datFont, run.Text, x, y, run.Color); + x += datFont.MeasureWidth(run.Text); + } + else + { + ctx.DrawString(run.Text, x, y, run.Color, bitmapFont); + x += bitmapFont!.MeasureWidth(run.Text); + } + } + } + /// /// True when any vertical portion of a line intersects a text viewport. Retail /// clips the glyphs at the viewport edge; it does not require the full line box to fit. diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index 51e574d6..6941bbaa 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -47,12 +47,10 @@ namespace AcDream.Core.Player; /// /// /// -/// Enchantment buffs (multiplicative + additive) and the -/// 5-min-vital clamp are not yet applied — adding those -/// requires the 's active -/// enchantment list. The unenchanted max is correct for clean -/// characters; buffed players will read percent slightly higher than -/// retail until enchantment integration lands. +/// Enchantment buffs (multiplicative + additive), vitae, and the +/// retail five-point minimum are applied through the attached +/// . Base-value accessors retain +/// the unenchanted values needed by the character-panel comparison logic. /// /// public sealed class LocalPlayerState @@ -226,13 +224,37 @@ public sealed class LocalPlayerState /// skill hasn't arrived yet. /// public int? GetEffectiveSkill(uint skillId) + => GetSkillValue(skillId)?.EffectiveLevel; + + /// + /// Full retail CACQualities::InqSkill projection, including the + /// augmentation terms on both sides of EnchantSkill. Callers with a + /// fresher player-object property bundle may supply it; otherwise the + /// PlayerDescription snapshot is used. + /// + public PlayerSkillMath.Value? GetSkillValue( + uint skillId, + PropertyBundle? properties = null) { SkillSnapshot? skill = GetSkill(skillId); if (skill is null) return null; - uint baseValue = skill.Value.CurrentLevel; - if (_spellbook is null) return (int)baseValue; - EnchantmentMath.VitalMod mod = _spellbook.GetSkillMod(skillId); - return EnchantmentMath.EnchantSkill(mod, baseValue); + + PlayerSkillMath.AugmentationBonuses augmentations = + PlayerSkillMath.AugmentationBonuses.FromProperties( + properties ?? _properties); + EnchantmentMath.VitalMod mod = _spellbook?.GetSkillMod(skillId) + ?? EnchantmentMath.VitalMod.Identity; + float vitae = _spellbook is null + ? 1f + : EnchantmentMath.GetVitaeMultiplier( + _spellbook.ActiveEnchantments); + return PlayerSkillMath.Calculate( + checked((int)Math.Min(int.MaxValue, skill.Value.CurrentLevel)), + skillId, + skill.Value.Status, + augmentations, + mod, + vitae); } /// @@ -242,14 +264,7 @@ public sealed class LocalPlayerState /// is wired, no vitae is active, or the skill hasn't arrived yet. /// public int GetSkillVitaeModifier(uint skillId) - { - if (_spellbook is null) return 0; - SkillSnapshot? skill = GetSkill(skillId); - if (skill is null) return 0; - return EnchantmentMath.SkillVitaeModifier( - _spellbook.ActiveEnchantments, - skill.Value.CurrentLevel); - } + => GetSkillValue(skillId)?.VitaeModifier ?? 0; /// Snapshot of the local player's current property bundle. public PropertyBundle Properties => _properties; @@ -290,11 +305,8 @@ public sealed class LocalPlayerState /// public uint? GetMaxApprox(VitalKind kind) { - var v = Get(kind); - if (v is null) return null; - uint baseMax = v.Value.Ranks + v.Value.Start; - uint contrib = AttributeContribution(kind); - uint unbuffed = baseMax + contrib; + uint? baseValue = GetBaseMaxApprox(kind); + if (baseValue is not uint unbuffed) return null; // Preserve the "no data" sentinel — when the unbuffed max is 0 // we lack the inputs to compute anything reasonable. The retail // min-vital floor only kicks in once we know the base. @@ -311,6 +323,37 @@ public sealed class LocalPlayerState return (uint)System.Math.Round(buffed); } + /// + /// Unenchanted secondary-attribute maximum used by retail's + /// Attribute2ndInfoRegion::Update @ 0x004F19E0 comparison. + /// + public uint? GetBaseMaxApprox(VitalKind kind) + { + VitalSnapshot? vital = Get(kind); + if (vital is null) return null; + return vital.Value.Ranks + + vital.Value.Start + + AttributeContribution(kind); + } + + /// + /// Isolated vitae contribution to a secondary attribute, matching + /// Attribute2ndInfoRegion::GetVitaeModifier @ 0x004F1130. + /// + public int GetVitalVitaeModifier(VitalKind kind) + { + if (_spellbook is null + || GetBaseMaxApprox(kind) is not uint baseValue) + { + return 0; + } + + return EnchantmentMath.SkillVitaeModifier( + EnchantmentMath.GetVitaeMultiplier( + _spellbook.ActiveEnchantments), + baseValue); + } + private static uint StatKeyForKind(VitalKind kind) => kind switch { VitalKind.Health => EnchantmentMath.StatKey.MaxHealth, diff --git a/src/AcDream.Core/Player/PlayerSkillMath.cs b/src/AcDream.Core/Player/PlayerSkillMath.cs new file mode 100644 index 00000000..601252fc --- /dev/null +++ b/src/AcDream.Core/Player/PlayerSkillMath.cs @@ -0,0 +1,108 @@ +using AcDream.Core.Items; +using AcDream.Core.Properties; +using AcDream.Core.Spells; + +namespace AcDream.Core.Player; + +/// +/// Retail CACQualities::InqSkill @ 0x00592660 composition. +/// Keeps the augmentation/enchantment ordering in one presentation-independent +/// place so character UI and movement consume the same effective skill. +/// +public static class PlayerSkillMath +{ + public readonly record struct AugmentationBonuses( + int AllSkills, + bool JackOfAllTrades, + int SkilledSpecialized, + bool SkilledMelee, + bool SkilledMissile, + bool SkilledMagic) + { + public static AugmentationBonuses FromProperties(PropertyBundle properties) + { + ArgumentNullException.ThrowIfNull(properties); + return new( + Positive(properties.GetInt((uint)PropertyInt.LumAugAllSkills)), + properties.GetInt((uint)PropertyInt.AugmentationJackOfAllTrades) > 0, + Positive(properties.GetInt((uint)PropertyInt.LumAugSkilledSpec)), + properties.GetInt((uint)PropertyInt.AugmentationSkilledMelee) > 0, + properties.GetInt((uint)PropertyInt.AugmentationSkilledMissile) > 0, + properties.GetInt((uint)PropertyInt.AugmentationSkilledMagic) > 0); + } + + public int BeforeEnchantments(uint skillId) + { + int category = skillId switch + { + // Retail switch at pc 0x005926F6. A positive category + // augmentation adds ten once; its stored rank is not multiplied. + 0x29u or 0x2Cu or 0x2Du or 0x2Eu or 0x31u when SkilledMelee => 10, + 0x2Fu when SkilledMissile => 10, + 0x1Fu or 0x20u or 0x21u or 0x22u or 0x2Bu when SkilledMagic => 10, + _ => 0, + }; + return SaturatingAdd(AllSkills, category); + } + + public int AfterEnchantments(uint advancementClass) + { + int result = JackOfAllTrades ? 5 : 0; + if (advancementClass == 3u) + result = SaturatingAdd(result, SaturatingMultiply(SkilledSpecialized, 2)); + return result; + } + } + + public readonly record struct Value( + int UnenchantedLevel, + int EffectiveLevel, + int VitaeModifier); + + /// + /// Compose one skill exactly in retail order: + /// intrinsic + LumAugAllSkills/category bonus; EnchantSkill; then + /// Jack of All Trades and the specialized luminance bonus. + /// + public static Value Calculate( + int intrinsicLevel, + uint skillId, + uint advancementClass, + AugmentationBonuses augmentations, + EnchantmentMath.VitalMod enchantment, + float vitaeMultiplier) + { + int intrinsic = Math.Max(0, intrinsicLevel); + int unenchanted = SaturatingAdd( + intrinsic, + augmentations.BeforeEnchantments(skillId)); + int enchanted = EnchantmentMath.EnchantSkill( + enchantment, + (uint)unenchanted); + int effective = SaturatingAdd( + enchanted, + augmentations.AfterEnchantments(advancementClass)); + int vitaeModifier = EnchantmentMath.SkillVitaeModifier( + vitaeMultiplier, + (uint)unenchanted); + return new Value(unenchanted, effective, vitaeModifier); + } + + private static int Positive(int value) => value > 0 ? value : 0; + + private static int SaturatingAdd(int left, int right) + { + long result = (long)left + right; + return result > int.MaxValue + ? int.MaxValue + : result < int.MinValue ? int.MinValue : (int)result; + } + + private static int SaturatingMultiply(int left, int right) + { + long result = (long)left * right; + return result > int.MaxValue + ? int.MaxValue + : result < int.MinValue ? int.MinValue : (int)result; + } +} diff --git a/src/AcDream.Core/Spells/EnchantmentMath.cs b/src/AcDream.Core/Spells/EnchantmentMath.cs index e5f76b48..61014eb4 100644 --- a/src/AcDream.Core/Spells/EnchantmentMath.cs +++ b/src/AcDream.Core/Spells/EnchantmentMath.cs @@ -28,22 +28,15 @@ namespace AcDream.Core.Spells; /// /// Vitae (death penalty) is a singleton on /// CEnchantmentRegistry._vitae, applied multiplicatively after -/// the buff lists. We don't yet wire it through. +/// the buff lists. /// /// /// -/// Current implementation status: the aggregator iterates -/// and applies -/// family-stacking deduplication, but -/// **returns identity (1.0, 0.0) for stat modifiers** because our -/// doesn't yet carry the -/// StatMod (type/key/val) triad — that requires extending -/// ParseMagicUpdateEnchantment to read the full Enchantment -/// payload (60-64 bytes per holtburger -/// messages/magic/types.rs) and storing it on the record. -/// Filed as ISSUES.md #12. Once that lands, the aggregator's -/// `effectiveMult * mod.Val` and `additive + mod.Val` paths fire and -/// the Vitals HUD percent gap closes. +/// Current implementation status: the aggregator consumes the same +/// complete StatMod (type/key/value) record shape from both the +/// PlayerDescription snapshot and live MagicUpdateEnchantment +/// (0x02C2), applies retail family stacking, then evaluates the selected +/// attribute, secondary attribute, or skill domain. /// /// /// @@ -167,9 +160,8 @@ public static class EnchantmentMath // Bucket 2 (Additive): additive += ench.StatModValue // Bucket 4 (Vitae): multiplier *= ench.StatModValue (post-pass) // Bucket 8 (Cooldown): skipped (doesn't affect vital max) - // Records without StatMod data (StatModKey == null) — e.g. - // those from older MagicUpdateEnchantment events that don't - // yet parse the full payload — contribute nothing. + // Records without StatMod data (StatModKey == null) are valid for + // non-stat enchantment classes and contribute nothing here. float multiplier = 1.0f; float additive = 0.0f; float vitae = 1.0f; @@ -283,10 +275,17 @@ public static class EnchantmentMath public static int SkillVitaeModifier( IEnumerable enchantments, uint baseValue) + => SkillVitaeModifier(GetVitaeMultiplier(enchantments), baseValue); + + /// + /// Value overload for callers that already captured the registry's vitae + /// singleton. Keeping the truncation here prevents the movement and UI + /// paths from growing subtly different copies of retail's calculation. + /// + public static int SkillVitaeModifier(float vitaeMultiplier, uint baseValue) { - float vitae = GetVitaeMultiplier(enchantments); - if (vitae == 1.0f) return 0; - return (int)(baseValue * vitae) - (int)baseValue; + if (vitaeMultiplier == 1.0f) return 0; + return (int)(baseValue * vitaeMultiplier) - (int)baseValue; } /// diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs index b852ff69..7251d9cb 100644 --- a/src/AcDream.Core/Spells/Spellbook.cs +++ b/src/AcDream.Core/Spells/Spellbook.cs @@ -267,11 +267,9 @@ public sealed class Spellbook } /// - /// Issue #7 / #12 — accept a fully-populated record from - /// PlayerDescription's enchantment block (which carries - /// the StatMod triad + bucket). Used when the wire-format extension - /// gives us the full per-enchantment payload, rather than the - /// 4-field summary from MagicUpdateEnchantment. + /// Accept the canonical fully-populated enchantment record shared by + /// PlayerDescription and live MagicUpdateEnchantment + /// (0x02C2), including the StatMod triad and bucket classification. /// public void OnEnchantmentAdded(ActiveEnchantmentRecord record) { diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index 61a76c4e..f9acaf5a 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -64,6 +64,7 @@ public sealed class RuntimeCharacterState : IDisposable /// private int _runSkillBase = -1; private int _jumpSkillBase = -1; + private PlayerSkillMath.AugmentationBonuses _movementSkillAugmentations; public RuntimeCharacterState(SpellTable? spellTable = null) { @@ -142,13 +143,14 @@ public sealed class RuntimeCharacterState : IDisposable && MovementSkills.PlayerKillerStatus == -1 && MovementSkills.LastPkAttackTimestamp is null && _runSkillBase == -1 - && _jumpSkillBase == -1); + && _jumpSkillBase == -1 + && _movementSkillAugmentations == default); } /// /// Campaign P Slice P1 (2026-07-30): stores the pre-EnchantSkill /// base run/jump skill (PlayerDescription's formulaBonus+init+ranks) - /// and pushes the vitae/enchantment-adjusted result into + /// and pushes the augmentation/vitae/enchantment-adjusted result into /// — the SAME call shape /// LiveSessionEventRouter's pre-P1 onSkillsUpdated callback /// already used (MovementSkills.Update(runSkill, jumpSkill)), now @@ -165,6 +167,22 @@ public sealed class RuntimeCharacterState : IDisposable RecomputeMovementSkills(); } + /// + /// Installs the player-quality augmentation terms consumed by retail + /// CACQualities::InqRunRate/InqJumpVelocity. The object + /// table remains authoritative for live PropertyInt updates; Runtime + /// retains only this immutable derived snapshot. + /// + public void UpdateMovementSkillAugmentations( + PlayerSkillMath.AugmentationBonuses augmentations) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_movementSkillAugmentations == augmentations) + return; + _movementSkillAugmentations = augmentations; + RecomputeMovementSkills(); + } + /// /// Re-derives the adjusted run/jump skill from the stored base plus the /// CURRENT spellbook state (vitae + skill enchantments) — matching @@ -178,10 +196,10 @@ public sealed class RuntimeCharacterState : IDisposable private void RecomputeMovementSkills() { int run = _runSkillBase >= 0 - ? ApplySkillEnchantments(_runSkillBase, RunSkillId) + ? CalculateMovementSkill(_runSkillBase, RunSkillId) : -1; int jump = _jumpSkillBase >= 0 - ? ApplySkillEnchantments(_jumpSkillBase, JumpSkillId) + ? CalculateMovementSkill(_jumpSkillBase, JumpSkillId) : -1; // #266 apparatus (permanent, low-volume — fires only on skill-base or // enchantment changes, the [snap] class): the full stat-chain state at @@ -196,14 +214,19 @@ public sealed class RuntimeCharacterState : IDisposable MovementSkills.Update(run, jump); } - private int ApplySkillEnchantments(int baseSkill, uint skillId) + private int CalculateMovementSkill(int baseSkill, uint skillId) { EnchantmentMath.VitalMod mod = Spellbook.GetSkillMod(skillId); - float adjusted = baseSkill * mod.Multiplier + mod.Additive; - // CEnchantmentRegistry::EnchantSkill pc 416240: floor to 0 below - // 0.5, then truncate (retail _ftol2, a C-style cast). - if (adjusted < 0.5f) adjusted = 0f; - return (int)adjusted; + float vitae = EnchantmentMath.GetVitaeMultiplier( + Spellbook.ActiveEnchantments); + uint advancementClass = LocalPlayer.GetSkill(skillId)?.Status ?? 0u; + return PlayerSkillMath.Calculate( + baseSkill, + skillId, + advancementClass, + _movementSkillAugmentations, + mod, + vitae).EffectiveLevel; } private void OnEnchantmentsChangedForMovement() => RecomputeMovementSkills(); @@ -331,6 +354,7 @@ public sealed class RuntimeCharacterState : IDisposable Try(Options.ResetSession, ref failures); _runSkillBase = -1; _jumpSkillBase = -1; + _movementSkillAugmentations = default; Try(MovementSkills.ResetSession, ref failures); if (failures is not null) { @@ -355,6 +379,7 @@ public sealed class RuntimeCharacterState : IDisposable Try(Options.ResetSession, ref failures); _runSkillBase = -1; _jumpSkillBase = -1; + _movementSkillAugmentations = default; Try(MovementSkills.ResetSession, ref failures); } finally diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index a0fafa79..704fa486 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -200,27 +200,27 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting SubscribeToRecompute( h => inventory.Objects.ObjectAdded += h, h => inventory.Objects.ObjectAdded -= h, - () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); + () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ObjectUpdated += h, h => inventory.Objects.ObjectUpdated -= h, - () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); + () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ObjectRemoved += h, h => inventory.Objects.ObjectRemoved -= h, - () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); + () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ObjectMoved += h, h => inventory.Objects.ObjectMoved -= h, - () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); + () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ContainerContentsReplaced += h, h => inventory.Objects.ContainerContentsReplaced -= h, - () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); + () => RecomputePlayerQualities(inventory, character)); SubscribeParameterless( h => inventory.Objects.Cleared += h, h => inventory.Objects.Cleared -= h, - () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); + () => RecomputePlayerQualities(inventory, character)); Subscribe( h => character.Character.LocalPlayer.AttributeChanged += h, h => character.Character.LocalPlayer.AttributeChanged -= h, @@ -365,7 +365,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting /// private static void RecomputeBurden( LiveInventorySessionBindings inventory, - LiveCharacterSessionBindings character) + LiveCharacterSessionBindings character, + bool notify = true) { uint player = inventory.PlayerGuid(); ClientObject? playerObject = inventory.Objects.Get(player); @@ -381,6 +382,22 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting : inventory.Objects.SumCarriedBurden(player); float load = EncumbranceSystem.Load(capacity, burden); character.Character.MovementSkills.UpdateBurden(load); + if (notify) + character.OnMovementStatsUpdated?.Invoke(); + } + + private static void RecomputePlayerQualities( + LiveInventorySessionBindings inventory, + LiveCharacterSessionBindings character) + { + RecomputeBurden(inventory, character, notify: false); + RecomputePvpStatus(inventory, character, notify: false); + + uint player = inventory.PlayerGuid(); + PropertyBundle properties = inventory.Objects.Get(player)?.Properties + ?? character.Character.LocalPlayer.Properties; + character.Character.UpdateMovementSkillAugmentations( + PlayerSkillMath.AugmentationBonuses.FromProperties(properties)); character.OnMovementStatsUpdated?.Invoke(); } @@ -397,7 +414,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting /// private static void RecomputePvpStatus( LiveInventorySessionBindings inventory, - LiveCharacterSessionBindings character) + LiveCharacterSessionBindings character, + bool notify = true) { uint player = inventory.PlayerGuid(); ClientObject? playerObject = inventory.Objects.Get(player); @@ -415,7 +433,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting character.Character.MovementSkills.UpdatePlayerKillerStatus( pkStatus, lastPkAttackTimestamp); - character.OnMovementStatsUpdated?.Invoke(); + if (notify) + character.OnMovementStatsUpdated?.Invoke(); } /// diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs index 712d516f..73d3d0c7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using DatReaderWriter; @@ -75,6 +76,31 @@ public sealed class CharacterLayoutImportProbe Assert.Equal(1, closes); } + [Fact] + public void Footer_title_exposes_retail_append_text_palette() + { + string? datDir = DatDir(); + if (datDir is null) return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + ImportedLayout? layout = LayoutImporter.Import( + dats, + CharacterLayout, + _ => (1u, 30, 26), + null); + + UiText title = Assert.IsType( + layout!.FindElement(CharacterStatController.FooterTitleId)); + Assert.Equal( + [ + Vector4.One, + new Vector4(0f, 1f, 0f, 1f), + new Vector4(1f, 0f, 0f, 1f), + new Vector4(127f / 255f, 1f, 1f, 1f), + ], + title.FontColorPalette); + } + private static void CollectRows(UiElement node, List result) { if (node is UiClickablePanel row && row.Height is >= 20f and <= 22f && row.OnClick is not null) diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs index c69004dc..ad99c683 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -3,6 +3,7 @@ using System.Linq; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Player; +using AcDream.Core.Properties; using AcDream.Core.Spells; using Xunit; @@ -285,6 +286,65 @@ public sealed class CharacterSheetProviderTests Assert.Equal(-100, skill.VitaeModifier); // the exact user-reported oracle example } + [Fact] + public void BuildSheet_SkillAugmentations_UseRetailBeforeAndAfterOrdering() + { + var h = new VitaeHarness(); + var properties = new PropertyBundle(); + properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 3; + properties.Ints[(uint)PropertyInt.AugmentationSkilledMagic] = 1; + properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1; + properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 4; + h.Player.OnProperties(properties); + h.Player.OnSkillUpdate( + skillId: 0x1Fu, + ranks: 100u, + status: 3u, + xp: 0u, + init: 0u, + resistance: 0u, + lastUsed: 0d, + formulaBonus: 0u); + + CharacterSkill skill = Assert.Single(h.Provider.BuildSheet().Skills); + + Assert.Equal(113, skill.BaseLevel); + Assert.Equal(126, skill.CurrentLevel); + Assert.Equal(0, skill.VitaeModifier); + } + + [Fact] + public void BuildSheet_VitalPairsCarryBaseAndVitaeContribution() + { + var h = new VitaeHarness(); + h.Player.OnAttributeUpdate( + atType: 2u, + ranks: 100u, + start: 100u, + xp: 0u); + h.Player.OnVitalUpdate( + vitalId: 7u, + ranks: 0u, + start: 100u, + xp: 0u, + current: 150u); + h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord( + SpellId: 1u, + LayerId: 1u, + Duration: -1d, + CasterGuid: 0u, + StatModType: 0u, + StatModKey: 0u, + StatModValue: 0.8f, + Bucket: 4u)); + + CharacterSheet sheet = h.Provider.BuildSheet(); + + Assert.Equal(200, sheet.VitalBaseMaxValues[0]); + Assert.Equal(-40, sheet.VitalVitaeModifiers[0]); + Assert.Equal(160, sheet.HealthMax); + } + [Fact] public void SubscribeChanged_FiresOnEnchantmentsChanged_AndRebuildReflectsNewValue() { diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 4bab2215..212b0af4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -876,7 +876,7 @@ public class CharacterStatControllerTests var meleeTexts = rows[0].Children.OfType().ToList(); Assert.Equal(Vector4.One, meleeTexts[1].LinesProvider()[0].Color); - Assert.Equal(new Vector4(0.55f, 1f, 0.55f, 1f), meleeTexts[2].LinesProvider()[0].Color); + Assert.Equal(new Vector4(0f, 1f, 0f, 1f), meleeTexts[2].LinesProvider()[0].Color); var healingTexts = rows[5].Children.OfType().ToList(); Assert.Equal(Vector4.One, healingTexts[1].LinesProvider()[0].Color); @@ -1314,6 +1314,42 @@ public class CharacterStatControllerTests Assert.Equal(50, CharacterStatController.GetSkillBuffOnlyDelta(skill)); } + [Fact] + public void SkillValueColor_SubtractsVitaeBeforeChoosingFontState() + { + var vitaeOnly = new CharacterSkill( + 1u, "S", 0u, CharacterSkillAdvancementClass.Trained, + BaseLevel: 300, CurrentLevel: 201, UsableUntrained: true, + TrainedCost: 0, SpecializedCost: 0, RaiseCost: 0, + VitaeModifier: -99); + var vitaeAndBuff = vitaeOnly with { CurrentLevel = 211 }; + + Assert.Equal(Vector4.One, CharacterStatController.SkillValueColor(vitaeOnly)); + Assert.Equal( + new Vector4(0f, 1f, 0f, 1f), + CharacterStatController.SkillValueColor(vitaeAndBuff)); + } + + [Fact] + public void AttributeAndVitalValueColors_FollowRetailResidualComparison() + { + var sheet = new CharacterSheet + { + Strength = 220, + AttributeBaseValues = [200, 0, 0, 0, 0, 0], + HealthMax = 170, + VitalBaseMaxValues = [200, 0, 0], + VitalVitaeModifiers = [-40, 0, 0], + }; + + Assert.Equal( + new Vector4(0f, 1f, 0f, 1f), + CharacterStatController.AttributeValueColor(sheet, 0)); + Assert.Equal( + new Vector4(0f, 1f, 0f, 1f), + CharacterStatController.VitalValueColor(sheet, 0)); + } + [Fact] public void RowClick_AttributeWithBuff_FooterTitleShowsPositiveDelta() { @@ -1405,6 +1441,41 @@ public class CharacterStatControllerTests Assert.Equal("Test Skill: 251 (-99) (+50)", title.LinesProvider()[0].Text); } + [Fact] + public void SkillFooter_UsesAuthoredPaletteForVitaeAndBuffRuns() + { + Vector4 normal = new(0.1f, 0.1f, 0.1f, 1f); + Vector4 buff = new(0.2f, 0.3f, 0.4f, 1f); + Vector4 debuff = new(0.5f, 0.6f, 0.7f, 1f); + Vector4 vitae = new(127f / 255f, 1f, 1f, 1f); + var list = new UiPanel { Width = 300 }; + var title = new UiText + { + FontColorPalette = [normal, buff, debuff, vitae], + }; + var layout = Fake( + (CharacterStatController.ListBoxId, list), + (CharacterStatController.FooterTitleId, title)); + + CharacterSheet Sheet() => VitaeSkillSheet( + currentLevel: 251, + baseLevel: 300, + vitaeModifier: -99); + CharacterStatController.Bind( + layout, + Sheet, + spriteResolve: id => (id, 16, 16)); + + ClickTab(layout, left: 92f); + SkillRows(list)[0].OnClick!(); + + IReadOnlyList runs = title.RunsProvider!(); + Assert.Equal(3, runs.Count); + Assert.Equal(("Test Skill: 251", normal), (runs[0].Text, runs[0].Color)); + Assert.Equal((" (-99)", vitae), (runs[1].Text, runs[1].Color)); + Assert.Equal((" (+50)", buff), (runs[2].Text, runs[2].Color)); + } + [Fact] public void SkillClick_ZeroDelta_NoParentheticals() { diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index ad170c5c..96007581 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -639,6 +639,56 @@ public sealed class GameEventWiringTests Assert.Equal(60d, record.Duration); } + [Fact] + public void WireAll_MagicUpdateEnchantment_PropagatesFullStatModMidSession() + { + var dispatcher = new GameEventDispatcher(); + var book = new Spellbook(SpellTable.Create([TestSpell(42u)])); + var player = new LocalPlayerState(book); + player.OnSkillUpdate( + skillId: 7u, + ranks: 100u, + status: 2u, + xp: 0u, + init: 0u, + resistance: 0u, + lastUsed: 0d, + formulaBonus: 0u); + int changed = 0; + book.EnchantmentsChanged += () => changed++; + GameEventWiring.WireAll( + dispatcher, + new ClientObjectTable(), + new CombatState(), + book, + new ChatLog(), + localPlayer: player, + clientTime: () => 100d); + + byte[] payload = BuildEnchantment( + spellId: 42, + layer: 3, + duration: 60, + caster: 0xBEEF, + startTime: 10, + lastDegraded: 2, + statModType: 0x00008010u, + statModKey: 7u, + statModValue: 25f); + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope( + GameEventType.MagicUpdateEnchantment, + payload))!.Value); + + ActiveEnchantmentRecord record = + Assert.Single(book.ActiveEnchantmentSnapshot); + Assert.Equal(0x00008010u, record.StatModType); + Assert.Equal(7u, record.StatModKey); + Assert.Equal(25f, record.StatModValue); + Assert.Equal(125, player.GetEffectiveSkill(7u)); + Assert.Equal(1, changed); + } + [Fact] public void WireAll_WeenieError_RoutesToChatLog() { @@ -1294,14 +1344,16 @@ public sealed class GameEventWiringTests uint caster, double startTime, double lastDegraded, - uint statModType) + uint statModType, + uint statModKey = 7u, + float statModValue = 1.25f) { byte[] payload = new byte[60]; int offset = 0; WriteU16(spellId); WriteU16(layer); WriteU16(3); WriteU16(0); WriteU32(8); WriteF64(startTime); WriteF64(duration); WriteU32(caster); WriteF32(0.1f); WriteF32(-1f); WriteF64(lastDegraded); - WriteU32(statModType); WriteU32(7); WriteF32(1.25f); + WriteU32(statModType); WriteU32(statModKey); WriteF32(statModValue); return payload; void WriteU16(ushort value) { BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(offset), value); offset += 2; } @@ -1310,4 +1362,9 @@ public sealed class GameEventWiringTests void WriteF64(double value) { BinaryPrimitives.WriteDoubleLittleEndian(payload.AsSpan(offset), value); offset += 8; } } + private static SpellMetadata TestSpell(uint spellId) => new( + spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0, + false, false, "", 0, 0, 0u, 0, false, false, true, + 0f, 0u, 0u, 0u, 0); + } diff --git a/tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs b/tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs new file mode 100644 index 00000000..dfad768f --- /dev/null +++ b/tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs @@ -0,0 +1,78 @@ +using AcDream.Core.Items; +using AcDream.Core.Player; +using AcDream.Core.Properties; +using AcDream.Core.Spells; + +namespace AcDream.Core.Tests.Player; + +public sealed class PlayerSkillMathTests +{ + [Fact] + public void Calculate_PreservesRetailAugmentationOrdering() + { + var augmentations = new PlayerSkillMath.AugmentationBonuses( + AllSkills: 3, + JackOfAllTrades: true, + SkilledSpecialized: 4, + SkilledMelee: false, + SkilledMissile: false, + SkilledMagic: true); + + PlayerSkillMath.Value value = PlayerSkillMath.Calculate( + intrinsicLevel: 100, + skillId: 0x1Fu, + advancementClass: 3u, + augmentations, + enchantment: new EnchantmentMath.VitalMod(0.5f, 0f), + vitaeMultiplier: 0.8f); + + Assert.Equal(113, value.UnenchantedLevel); // 100 + all 3 + magic 10 + Assert.Equal(69, value.EffectiveLevel); // trunc(113 * .5) + JOAT 5 + spec 8 + Assert.Equal(-23, value.VitaeModifier); // trunc(113 * .8) - 113 + } + + [Theory] + [InlineData(0x29u, true, false, false, 10)] + [InlineData(0x2Fu, false, true, false, 10)] + [InlineData(0x22u, false, false, true, 10)] + [InlineData(0x18u, true, true, true, 0)] + public void BeforeEnchantments_UsesRetailSkillCategorySwitch( + uint skillId, + bool melee, + bool missile, + bool magic, + int expectedCategoryBonus) + { + var augmentations = new PlayerSkillMath.AugmentationBonuses( + AllSkills: 2, + JackOfAllTrades: false, + SkilledSpecialized: 0, + SkilledMelee: melee, + SkilledMissile: missile, + SkilledMagic: magic); + + Assert.Equal( + 2 + expectedCategoryBonus, + augmentations.BeforeEnchantments(skillId)); + } + + [Fact] + public void FromProperties_MapsNamedRetailQualities() + { + var properties = new PropertyBundle(); + properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 7; + properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1; + properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 3; + properties.Ints[(uint)PropertyInt.AugmentationSkilledMissile] = 2; + + PlayerSkillMath.AugmentationBonuses value = + PlayerSkillMath.AugmentationBonuses.FromProperties(properties); + + Assert.Equal(7, value.AllSkills); + Assert.True(value.JackOfAllTrades); + Assert.Equal(3, value.SkilledSpecialized); + Assert.True(value.SkilledMissile); + Assert.False(value.SkilledMelee); + Assert.False(value.SkilledMagic); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index 3f2dd06f..eee36fca 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -257,11 +257,56 @@ public sealed class RuntimeCharacterStateTests Assert.Equal(100, state.MovementSkills.JumpSkill); // untouched } + [Fact] + public void MovementSkillAugmentations_UseSameRetailChainAsCharacterSheet() + { + using var state = new RuntimeCharacterState(); + state.LocalPlayer.OnSkillUpdate( + RuntimeCharacterState.RunSkillId, + ranks: 0u, + status: 3u, + xp: 0u, + init: 0u, + resistance: 0u, + lastUsed: 0d, + formulaBonus: 200u); + state.LocalPlayer.OnSkillUpdate( + RuntimeCharacterState.JumpSkillId, + ranks: 0u, + status: 2u, + xp: 0u, + init: 0u, + resistance: 0u, + lastUsed: 0d, + formulaBonus: 100u); + state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100); + + state.UpdateMovementSkillAugmentations( + new PlayerSkillMath.AugmentationBonuses( + AllSkills: 2, + JackOfAllTrades: true, + SkilledSpecialized: 3, + SkilledMelee: false, + SkilledMissile: false, + SkilledMagic: false)); + + Assert.Equal(213, state.MovementSkills.RunSkill); + Assert.Equal(107, state.MovementSkills.JumpSkill); + } + [Fact] public void ResetSession_ClearsBurdenStaminaAndSkillBase() { using var state = new RuntimeCharacterState(); state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100); + state.UpdateMovementSkillAugmentations( + new PlayerSkillMath.AugmentationBonuses( + AllSkills: 2, + JackOfAllTrades: true, + SkilledSpecialized: 3, + SkilledMelee: false, + SkilledMissile: false, + SkilledMagic: false)); state.MovementSkills.UpdateBurden(1.5f); state.MovementSkills.UpdateStamina(0); @@ -274,7 +319,7 @@ public sealed class RuntimeCharacterStateTests Assert.True(state.CaptureOwnership().MovementSkillsAreReset); // A fresh base push after reset must not still carry the pre-reset - // vitae/enchantment adjustment (spellbook was cleared too). + // augmentation/vitae/enchantment adjustment (spellbook was cleared too). state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100); Assert.Equal(200, state.MovementSkills.RunSkill); } diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index 38090629..fc3d2fe3 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -233,6 +233,58 @@ public sealed class LiveSessionEventRouterTests router.Dispose(); } + [Fact] + public void ObjectTablePropertyChange_RecomputesMovementSkillAugmentations() + { + using var session = NewSession(); + const uint playerGuid = 0x50000001u; + var objects = new ClientObjectTable(); + var character = new RuntimeCharacterState(); + character.LocalPlayer.OnSkillUpdate( + RuntimeCharacterState.RunSkillId, + ranks: 0u, + status: 3u, + xp: 0u, + init: 0u, + resistance: 0u, + lastUsed: 0d, + formulaBonus: 200u); + character.UpdateMovementSkillBase( + runSkillBase: 200, + jumpSkillBase: -1); + + var router = new LiveSessionEventRouter( + session, + NoOpEntitySink(), + NoOpEnvironmentSink(), + new LiveInventorySessionBindings( + objects, + PlayerGuid: () => playerGuid, + OnShortcuts: null, + OnUseDone: null, + ItemMana: new ItemManaState(), + ExternalContainers: new ExternalContainerState()), + new LiveCharacterSessionBindings( + new CombatState(), + character, + ResolveSkillFormulaBonus: null, + OnSkillsUpdated: null, + OnConfirmationRequest: null, + OnConfirmationDone: null, + ClientTime: () => 0d), + NewSocialBindings()); + router.Attach(); + + var properties = new PropertyBundle(); + properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 2; + properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1; + properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 3; + objects.UpsertProperties(playerGuid, properties); + + Assert.Equal(213, character.MovementSkills.RunSkill); + router.Dispose(); + } + [Fact] public void StaminaVitalChange_PushesCurrentStamina() { From 1d8371dbe5d56fb1cc26ae361dac975d73685aa8 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 08:22:46 +0200 Subject: [PATCH 03/73] fix(ui): refresh live skill rows --- .../UI/Layout/CharacterStatController.cs | 18 ++++++++++-- src/AcDream.App/UI/RetailUiRuntime.cs | 12 ++++++-- .../UI/Layout/CharacterStatControllerTests.cs | 29 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index d8b56b46..63c250d2 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -776,12 +776,14 @@ public static class CharacterStatController foreach (var skill in skills) { int rowIndex = bindings.Count; + CharacterSkill LiveSkill() => + FindSkill(data(), skill.Id) ?? skill; var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: SkillRowHeight, iconDid: skill.IconDid, nameText: skill.Name, - valueProvider: () => skill.CurrentLevel.ToString(), - valueColorProvider: () => SkillValueColor(skill), + valueProvider: () => LiveSkill().CurrentLevel.ToString(), + valueColorProvider: () => SkillValueColor(LiveSkill()), nameColor: Vector4.One); row.OnClick = () => { @@ -857,6 +859,18 @@ public static class CharacterStatController return result; } + private static CharacterSkill? FindSkill(CharacterSheet sheet, uint skillId) + { + IReadOnlyList skills = sheet.Skills; + for (int i = 0; i < skills.Count; i++) + { + CharacterSkill skill = skills[i]; + if (skill.Id == skillId) + return skill; + } + return null; + } + private static CharacterSkill? SkillAtDisplayIndex(CharacterSheet sheet, int index) { if (index < 0) return null; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 4d436089..cec6f563 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -209,6 +209,7 @@ public sealed class RetailUiRuntime : IDisposable private UiShortcutDigitGraphics? _shortcutDigitGraphics; private ItemCooldownUiController? _itemCooldownController; private VividTargetIndicatorController? _vividTargetIndicator; + private IDisposable? _characterSheetSubscription; private ResourceShutdownTransaction? _shutdown; private bool _disposed; @@ -1676,9 +1677,12 @@ public sealed class RetailUiRuntime : IDisposable return; } CharacterSheetProvider provider = _bindings.Character.Provider; + CharacterSheet currentSheet = provider.BuildSheet(); + _characterSheetSubscription = provider.SubscribeChanged( + () => currentSheet = provider.BuildSheet()); CharacterStatController.Bind( layout, - provider.BuildSheet, + () => currentSheet, _bindings.Assets.DefaultFont, _bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont, _bindings.Assets.ResolveSprite, @@ -1922,7 +1926,11 @@ public sealed class RetailUiRuntime : IDisposable _shutdown ??= CreateShutdownTransaction( () => _automation?.Dispose(), () => _persistence?.Dispose(), - () => Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged, + () => + { + _characterSheetSubscription?.Dispose(); + Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; + }, () => _itemConfirmationController?.Dispose(), () => _gameplayConfirmationController?.Dispose(), () => DialogFactory?.Dispose(), diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 212b0af4..430452f6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -1476,6 +1476,35 @@ public class CharacterStatControllerTests Assert.Equal((" (+50)", buff), (runs[2].Text, runs[2].Color)); } + [Fact] + public void SkillRow_ValueAndColorFollowLiveSheetWithoutTabRebuild() + { + var list = new UiPanel { Width = 300 }; + var layout = Fake((CharacterStatController.ListBoxId, list)); + CharacterSheet sheet = VitaeSkillSheet( + currentLevel: 300, + baseLevel: 300, + vitaeModifier: 0); + + CharacterStatController.Bind( + layout, + () => sheet, + spriteResolve: id => (id, 16, 16)); + ClickTab(layout, left: 92f); + + UiText value = SkillRows(list)[0].Children.OfType().ToList()[2]; + Assert.Equal("300", value.LinesProvider()[0].Text); + Assert.Equal(Vector4.One, value.LinesProvider()[0].Color); + + sheet = VitaeSkillSheet( + currentLevel: 350, + baseLevel: 300, + vitaeModifier: 0); + + Assert.Equal("350", value.LinesProvider()[0].Text); + Assert.Equal(new Vector4(0f, 1f, 0f, 1f), value.LinesProvider()[0].Color); + } + [Fact] public void SkillClick_ZeroDelta_NoParentheticals() { From 5a0f9868a6ddb8aaad317951aaca7ed2f6f1378d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 09:10:53 +0200 Subject: [PATCH 04/73] fix(physics): port retail slope landing stop --- docs/ISSUES.md | 28 ++- docs/plans/2026-04-11-roadmap.md | 12 +- .../2026-07-29-physics-parity-campaign.md | 22 +- .../2026-07-31-269-slope-stop-capture.md | 104 ++++++++++ .../project_movement_collision_conformance.md | 13 +- src/AcDream.Core/Physics/TransitionTypes.cs | 109 +++------- .../Gameplay/PlayerMovementController.cs | 35 ++++ .../Gameplay/PlayerPhysicsQuantumCapture.cs | 191 ++++++++++++++++++ .../Physics/BSPStepUpTests.cs | 48 +++++ .../PlayerPhysicsQuantumCaptureTests.cs | 164 +++++++++++++++ tools/analyze_269_slope_stop_capture.py | 125 ++++++++++++ tools/cdb/issue269-slope-stop.cdb | 48 +++++ tools/cdb/run-issue269-slope-stop.ps1 | 74 +++++++ 13 files changed, 870 insertions(+), 103 deletions(-) create mode 100644 docs/research/2026-07-31-269-slope-stop-capture.md create mode 100644 src/AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/PlayerPhysicsQuantumCaptureTests.cs create mode 100644 tools/analyze_269_slope_stop_capture.py create mode 100644 tools/cdb/issue269-slope-stop.cdb create mode 100644 tools/cdb/run-issue269-slope-stop.ps1 diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 1bc96735..c64ad444 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -142,7 +142,7 @@ remain in place (ride ACDREAM_DUMP_MOTION=1) until the user gate passes. ## #269 — Slope-stop slide runs too far (post-bounce-rework residual) -**Status:** OPEN (user live gate 2026-07-30 — "almost pass with merits") +**Status:** DONE — 2026-07-31 (implementation + user live gate) **Severity:** LOW-MEDIUM (feel residual; bounce family otherwise accepted) **Component:** physics / landing slide decay @@ -162,14 +162,24 @@ cos(10°) with base 0.2); the jump chain end-to-end (`GetJumpHeight` Sledding auto-toggle (P2 finding re-confirmed; no `state |= 0x800000` writer exists). -**Next step (behavior question, not code):** live A/B per the cdb -workflow — breakpoints on `calc_friction`/`handle_all_collisions` dumping -velocity per tick while the retail client lands + slides on a slope, vs an -`ACDREAM_CAPTURE_RESOLVE` capture of the same maneuver on the same slope. -Compare the decay curves; the first diverging tick names the mechanism. -Candidates the trace discriminates: hop-chain cadence (friction skips -airborne micro-hop ticks), gravity-tangential rebuild timing, quantum -alignment. +**Resolution (2026-07-31):** a 2,184-quantum live capture isolated the +first divergence. The landing tick produced retail's correct 5% reflect, +but the following quanta repeatedly restored +`LastKnownContactPlane` while retaining the reflected velocity. The body +therefore remained Contact + OnWalkable with `v·n > 0.25`, where retail +`calc_friction` intentionally does no work, and slid at full speed. + +Named-retail `CTransition::validate_transition @ 0x0050AA70` revealed the +omission: its non-OK remembered-plane recovery calls +`OBJECTINFO::kill_velocity @ 0x0050CFE0` *before* the proximity test and +plane restore (`0x0050AAED–0x0050AB42`). It also consumes the remembered +plane only in that non-OK branch and overwrites last-known validity from +the final contact plane at `0x0050ACFF`. ACDream now follows that exact +ordering. Focused collision-recovery and clean-advance pins pass; full +Core (4,107/2 skips) and Runtime (439/0) suites pass; the user repeated the +slope-jump test and accepted the result (“Perfect! Works great!”). +Capture and decode: +`docs/research/2026-07-31-269-slope-stop-capture.md`. --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index d9b4954b..4aa27c21 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -46,12 +46,14 @@ a ledger pass. Goal: zero physics TS rows, no unargued feel-affecting AP rows, one final batched connected visual matrix. Sonnet implements, Opus reviews. The plan is [`2026-07-29-physics-parity-campaign.md`](2026-07-29-physics-parity-campaign.md). -The 2026-07-31 #268 stat-chain package is implemented pending its live visual -gate: panel and Runtime movement share retail's complete augmentation ordering, +The 2026-07-31 #268 stat-chain package is implemented and user-accepted: +panel and Runtime movement share retail's complete augmentation ordering, the authored per-fragment vitae/buff/debuff colors are live, and AP-127 plus -TS-8 are retired by focused and end-to-end packet tests. The remaining -implementation target is #269's capture-driven slope-slide feel residual, -followed by the unfinished live matrix rows. +TS-8 are retired by focused and end-to-end packet tests. #269's +capture-driven slope-slide residual is also closed and user-accepted: +`CTransition::validate_transition` now performs retail's non-OK-only +remembered-plane restore with the preceding `OBJECTINFO::kill_velocity`. +Campaign P now continues with the unfinished live matrix rows. --- diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index 814ef6df..b7bc2d59 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -380,13 +380,19 @@ root-caused, retail-ported, and user-accepted in the same session: authored 0x1B palette exactly: #7FFFFF vitae, #00FF00 buff, #FF0000 debuff. TS-8 is also retired: a real live 0x02C2 payload carries its full StatMod through dispatch and changes the effective skill immediately. -- **#269 filed** — slope-stop slide sometimes runs long. `calc_friction` - (0x0050ee70) and the complete jump chain (`GetJumpHeight` 0x006b09b0, - `InqJumpVelocity` vz=sqrt(h·19.6), 1.0 s/0.8 s powerbar charge) are - byte-verified identical — the user's jump-height hypothesis is - refuted; next step is the live cdb A/B decay-curve trace. +- **#269 closed 2026-07-31** — the live 2,184-quantum trace proved the + landing reflect and friction math were correct. ACDream omitted retail's + `OBJECTINFO::kill_velocity` before restoring a remembered contact plane + in `CTransition::validate_transition @ 0x0050AA70`, retaining full + downhill velocity while repeatedly re-grounding the mover. The exact + non-OK-only restore/kill order and final last-known validity overwrite + are now ported, focused/full gates pass, and the user accepted repeated + slope jumps. Evidence: + `docs/research/2026-07-31-269-slope-stop-capture.md`. Matrix rows accepted so far: speed parity, roof slide, downhill bounce, -flat pop, uphill landing (rows 3/4/5-partial/12-partial). Remaining -rows: 1, 2, 6, 7, 8, 9, 10, 11 plus #269's slide feel. Suite at this -checkpoint: 10,031 passed / 5 skips / 0 failures. +flat pop, uphill landing, and #269's slope-stop feel +(rows 3/4/5-partial/12-partial). Remaining rows: 1, 2, 6, 7, 8, 9, 10, +and 11. The #269 checkpoint passes 4,107 Core tests / 2 skips and 439 +Runtime tests / 0 skips; the complete Release suite passes 10,061 tests / +5 skips / 0 failures. diff --git a/docs/research/2026-07-31-269-slope-stop-capture.md b/docs/research/2026-07-31-269-slope-stop-capture.md new file mode 100644 index 00000000..e48cb9c4 --- /dev/null +++ b/docs/research/2026-07-31-269-slope-stop-capture.md @@ -0,0 +1,104 @@ +# Issue #269 — slope-stop capture and retail correction + +**Date:** 2026-07-31 +**Status:** implemented; user live gate passed +**Scope:** landing-bounce follow-up, `CTransition::validate_transition` + +## Symptom + +After the retail 5%-elasticity landing reflection was restored for #265, +the character could retain too much downhill speed after landing on a +walkable slope. The user described the residual as “slides too far on +landing.” + +## ACDream live capture + +`ACDREAM_CAPTURE_PLAYER_QUANTA=` records the local player's +complete admitted object quantum without changing simulation order: + +1. quantum start; +2. root/PositionManager composition; +3. pre- and post-`UpdatePhysicsInternal`; +4. transition result; +5. final collision-response commit. + +The accepted repro contained 2,184 quanta. The clearest landing was: + +| Quantum | Event | Velocity | +|---|---|---| +| 1740 | final airborne quantum | `(-12.316, 8.187, -26.266)` | +| 1741 | slope collision, normal `(-0.236, 0.236, 0.943)` | | +| 1741 post-response | correct 5% reflect | `(-17.391, 13.262, -6.576)` | +| 1742–1758 | still Contact + OnWalkable, no new collision normal | velocity unchanged | +| 1759+ | contact relationship changes | friction finally begins decaying | + +The reflected velocity had `dot(v, normal) = +1.0252`: it pointed away +from the slope. Retail `calc_friction` correctly skips while this value is +at least `0.25`, so friction was not the defect. ACDream was repeatedly +restoring the remembered slope plane and re-grounding the body without +performing retail's accompanying velocity stop. + +## Retail oracle + +Named-retail: + +- `CPhysicsObj::check_contact` `0x0050F5B0` +- `CPhysicsObj::get_object_info` `0x00511CC0` +- `CTransition::validate_transition` `0x0050AA70` +- `OBJECTINFO::kill_velocity` `0x0050CFE0` + +The exact `validate_transition` order at +`0x0050AAED–0x0050AB42` is: + +1. enter only for a non-OK collision/adjusted/slid result; +2. if `last_known_contact_plane_valid`, call + `OBJECTINFO::kill_velocity`; +3. test the current sphere center against the remembered plane using + `radius + 0.0002`; +4. restore the contact plane only when still within that distance; +5. later, at `0x0050ACFF`, overwrite last-known validity with final + contact-plane validity. + +`OBJECTINFO::kill_velocity` calls +`CPhysicsObj::set_velocity({0,0,0}, 0)`. ACDream had ported the proximity +test and plane restore but omitted this call. It also allowed the +last-known plane to re-ground clean accepted moves, although retail only +consumes it in the non-OK recovery branch. + +## Correction + +`Transition.ValidateTransition` now: + +- calls `ObjectInfo.StopVelocity()` before the remembered-plane + proximity/restore test on a non-OK recovery; +- performs that restore only in the retail branch; +- overwrites last-known validity from final contact validity, so a clean + move away cannot be re-grounded from stale memory. + +The existing `PhysicsEngine.ResolveWithTransition` consumption of +`VelocityKilled` applies the zero to the canonical `PhysicsBody` before +the collision-response tail. The initial 5% landing reflection remains; +only a following collision recovery performs the retail stop. + +## Gates + +- New focused pins: + - collision recovery with a remembered plane kills velocity; + - clean advance with a remembered plane neither kills nor re-grounds. +- Full `AcDream.Core.Tests`: 4,107 passed / 2 skipped. +- Full `AcDream.Runtime.Tests`: 439 passed. +- `AcDream.App` Release build: 0 warnings / 0 errors. +- Complete Release suite: 10,061 passed / 5 skipped / 0 failed. +- User live gate: **PASS** — repeated slope jumps now settle correctly + (“Perfect! Works great!”). + +## Diagnostic tools retained + +- `tools/analyze_269_slope_stop_capture.py` +- `tools/cdb/run-issue269-slope-stop.ps1` +- `tools/cdb/issue269-slope-stop.cdb` + +The cdb runner refuses to attach unless the live retail executable matches +the Sept 2013 named PDB. The locally installed 2015 retail executable does +not match; the static named-retail decode above is therefore the retail +oracle used for this correction. diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 6117dc6b..98eea409 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -2,9 +2,10 @@ ## Phase -Active phase: **L.2 - Movement & Collision Conformance**. +Active phase: **Campaign P — Physics Retail-Feel Parity** (the L.2/R6 +foundation is shipped). -Plan: `docs/plans/2026-04-29-movement-collision-conformance.md`. +Plan: `docs/plans/2026-07-29-physics-parity-campaign.md`. Roadmap: `docs/plans/2026-04-11-roadmap.md`. @@ -78,3 +79,11 @@ InputDispatcher / PlayerMovementController re-test the adjusted `CheckPos`), not returned to `ValidateTransition`; the outer validator treats non-OK as a collision and restores `CurPos`, making edges feel like hard stops even when the tangent was computed. +- 2026-07-31: Campaign P #269 slope-stop parity. A 2,184-quantum live + capture proved the 5% landing reflect and `calc_friction` were correct, + but `ValidateTransition` restored `LastKnownContactPlane` without retail's + preceding `OBJECTINFO::kill_velocity`. Retail consumes that plane only on + a non-OK recovery and overwrites last-known validity from the final contact + plane. Both rules are now ported; focused/full tests and the user's repeated + slope-jump gate pass. See + `docs/research/2026-07-31-269-slope-stop-capture.md`. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index d565bcec..d808ac5c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -5530,6 +5530,30 @@ public sealed class Transition else if (transitionState != TransitionState.Invalid) { // Collision/slide/adjusted: revert to current position. + // Retail CTransition::validate_transition 0x0050AA70 consumes a + // remembered contact plane only on this non-OK recovery path. It + // first calls OBJECTINFO::kill_velocity, then restores the plane + // when the current sphere remains within radius + EPSILON. Omitting + // the kill preserved a landing reflection while repeatedly + // re-grounding the mover, producing Campaign P #269's long slide. + if (ci.LastKnownContactPlaneValid) + { + oi.StopVelocity(); + + var sphereCenter = sp.GlobalCurrCenter[0].Origin; + var radius = sp.GlobalSphere[0].Radius; + float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter) + + ci.LastKnownContactPlane.D; + + if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle)) + { + ci.SetContactPlane( + ci.LastKnownContactPlane, + ci.LastKnownContactPlaneCellId, + ci.LastKnownContactPlaneIsWater); + } + } + if (!ci.CollisionNormalValid) ci.SetCollisionNormal(Vector3.UnitZ); // default: push up @@ -5541,14 +5565,11 @@ public sealed class Transition if (ci.CollisionNormalValid) ci.SetSlidingNormal(ci.CollisionNormal); - // Preserve contact plane for next step. - // L.2.3c (2026-04-29): only OVERWRITE LastKnown when current is valid. - // Previously: `LastKnownValid = ContactPlaneValid` cleared - // LastKnown whenever current was invalid — destroying the prior frame's - // contact memory. After StepUpSlide cleared ContactPlane mid-step - // (failed step-up against a too-tall wall), this propagated to - // LastKnown and the player went airborne for a frame, flickering the - // falling animation. Now LastKnown survives transient losses. + // Retail 0x0050ACFF-0x0050AD9A overwrites last-known validity with + // current contact validity, copies the plane only when valid, and then + // derives Contact/OnWalkable from that final plane. A remembered plane + // can have been restored only in the non-OK recovery branch above; it + // must never re-ground a clean move away from the surface. if (ci.ContactPlaneValid) { ci.LastKnownContactPlaneValid = true; @@ -5569,79 +5590,9 @@ public sealed class Transition else oi.State &= ~ObjectInfoState.OnWalkable; } - else if (ci.LastKnownContactPlaneValid) - { - // L.2.3c: current contact lost transiently (e.g. StepUpSlide - // cleared it during a failed step-up) but the prior frame's - // contact is still valid — keep the mover grounded via the - // last-known plane. Without this, every wall bump dropped the - // player into the falling animation for one frame. - // - // L.2.4 (2026-04-30): PROXIMITY GUARD. Only trust the - // last-known plane if the sphere is still actually near it. - // Geometrically: `angle` is the signed distance from the - // sphere center to the plane. If |angle| exceeds the sphere - // radius (plus a tiny epsilon), the sphere has SEPARATED - // from the plane — typically because we fell off an edge or - // the body dropped vertically while the resolver bounced - // through edge-slide attempts. Without this guard the player - // gets stuck mid-fall in a falling animation forever (live - // bug 2026-04-30: cur.Z=96.6, check.Z=95.1 — 1.5 m below the - // remembered floor, but still being marked Contact + OnWalkable). - // - // Matches ACE PhysicsObj's pre-reuse check on the last-known - // plane and retail's CPhysicsObj::get_object_info logic. - // A6.P3 slice 1 (2026-05-21). Retail uses global_curr_center (NOT - // global_sphere->center) for this proximity check — see - // acclient_2013_pseudo_c.txt:272568. global_sphere is the START - // sphere of the transition; global_curr_center is the CURRENT center - // after sub-step accumulation. Using the wrong one made the proximity - // guard fire on the wrong reference point. - var sphereCenter = sp.GlobalCurrCenter[0].Origin; - var radius = sp.GlobalSphere[0].Radius; - float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter) - + ci.LastKnownContactPlane.D; - - if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle)) - { - // ── Mechanism B — restore CP from LKCP per retail ──────────────── - // A6.P3 slice 1 (2026-05-21). Retail oracle: - // acclient_2013_pseudo_c.txt:272577 (inside CTransition::validate_transition - // at line 272547). When the sphere is geometrically close to the - // LastKnownContactPlane, retail restores CP from LKCP via - // set_contact_plane(&collision_info, &last_known_contact_plane, - // last_known_contact_plane_is_water). This closes the gap that the - // stripped TryFindIndoorWalkablePlane synthesis path used to fill — - // when no fresh Path-6 CP write lands in this transition, CP is - // retained from the previous frame instead of being re-synthesized. - // - // NOTE: SetContactPlane also re-latches LKCP fields - // (TransitionTypes.cs:258-261), which is a no-op here since we - // pass LKCP as the source. - ci.SetContactPlane( - ci.LastKnownContactPlane, - ci.LastKnownContactPlaneCellId, - ci.LastKnownContactPlaneIsWater); - - // Still close enough to the last-known plane — preserve - // grounded state. L.2.3i FloorZ test for OnWalkable. - oi.State |= ObjectInfoState.Contact; - if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ) - oi.State |= ObjectInfoState.OnWalkable; - else - oi.State &= ~ObjectInfoState.OnWalkable; - } - else - { - // Sphere has separated from the last-known plane. - // Drop the memory and let the body resolve normally - // (gravity → next-frame terrain probe → real contact). - ci.LastKnownContactPlaneValid = false; - oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable); - } - } else { + ci.LastKnownContactPlaneValid = false; oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable); } diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index fb8dc1ab..a3d8182b 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -1841,6 +1841,11 @@ public sealed class PlayerMovementController for (int qi = 0; qi < quantumBatch.Count; qi++) { float tickDt = quantumBatch.GetQuantum(qi); + bool captureQuantum = PlayerPhysicsQuantumCapture.IsEnabled; + uint captureCellBefore = CellId; + PlayerPhysicsBodyTraceSnapshot captureQuantumStart = captureQuantum + ? PlayerPhysicsQuantumCapture.Snapshot(_body) + : default; // CPhysicsObj::UpdatePositionInternal (0x00512C30): visible objects // advance their PartArray first. The complete Frame survives; only its @@ -1931,7 +1936,13 @@ public sealed class PlayerMovementController } _body.calc_acceleration(); + PlayerPhysicsBodyTraceSnapshot capturePreIntegration = captureQuantum + ? PlayerPhysicsQuantumCapture.Snapshot(_body) + : default; _body.UpdatePhysicsInternal(tickDt); + PlayerPhysicsBodyTraceSnapshot capturePostIntegration = captureQuantum + ? PlayerPhysicsQuantumCapture.Snapshot(_body) + : default; // Retail process_hooks is the final UpdatePositionInternal step: // after physics, before the transition and manager tail. @@ -2121,6 +2132,30 @@ public sealed class PlayerMovementController _motion.CheckForCompletedMotions, PositionManager); + if (captureQuantum) + { + PlayerPhysicsQuantumCapture.Log( + tickDt, + captureCellBefore, + CellId, + input, + capturePreIntegration.Position - captureQuantumStart.Position, + candidateMoved, + captureQuantumStart, + capturePreIntegration, + capturePostIntegration, + new PlayerPhysicsResolveTraceSnapshot( + resolveResult.Position, + resolveResult.CellId, + resolveResult.Ok, + resolveResult.IsOnGround, + resolveResult.InContact, + resolveResult.OnWalkable, + resolveResult.CollisionNormalValid, + resolveResult.CollisionNormal), + PlayerPhysicsQuantumCapture.Snapshot(_body)); + } + } // ── 4. Determine outbound motion commands ───────────────────────────── diff --git a/src/AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs b/src/AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs new file mode 100644 index 00000000..1ab432f3 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs @@ -0,0 +1,191 @@ +using System.Numerics; +using System.Text.Json; +using AcDream.Core.Physics; + +namespace AcDream.Runtime.Gameplay; + +/// +/// Opt-in, player-only trace of the complete retail object quantum. +/// +/// Set ACDREAM_CAPTURE_PLAYER_QUANTA=<path> before process +/// startup to emit one JSON-Lines record per admitted physics quantum. The +/// capture is deliberately outside the physics implementation: it observes +/// the same stage boundaries as retail +/// CPhysicsObj::UpdateObjectInternal without changing their order or +/// introducing a second simulation path. When disabled, the hot path pays +/// one static null/empty check and performs no allocations. +/// +/// +internal static class PlayerPhysicsQuantumCapture +{ + internal static string? CapturePath { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_CAPTURE_PLAYER_QUANTA"); + + internal static bool IsEnabled => !string.IsNullOrWhiteSpace(CapturePath); + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + IncludeFields = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + private static readonly object s_writerLock = new(); + private static StreamWriter? s_writer; + private static long s_sequence; + private static bool s_processExitHooked; + + internal static PlayerPhysicsBodyTraceSnapshot Snapshot(PhysicsBody body) => + new( + Position: body.Position, + Orientation: body.Orientation, + Velocity: body.Velocity, + Acceleration: body.Acceleration, + GroundNormal: body.GroundNormal, + ContactPlaneValid: body.ContactPlaneValid, + ContactPlane: body.ContactPlane, + Friction: body.Friction, + Elasticity: body.Elasticity, + State: (uint)body.State, + TransientState: (uint)body.TransientState, + FramesStationaryFall: body.FramesStationaryFall); + + internal static void Log( + float dt, + uint cellBefore, + uint cellAfter, + MovementInput input, + Vector3 rootAndManagerDelta, + bool candidateMoved, + PlayerPhysicsBodyTraceSnapshot quantumStart, + PlayerPhysicsBodyTraceSnapshot preIntegration, + PlayerPhysicsBodyTraceSnapshot postIntegration, + PlayerPhysicsResolveTraceSnapshot resolve, + PlayerPhysicsBodyTraceSnapshot postCommit) + { + string? path = CapturePath; + if (string.IsNullOrWhiteSpace(path)) + return; + + var record = new PlayerPhysicsQuantumTraceRecord( + Sequence: Interlocked.Increment(ref s_sequence) - 1, + TimestampTicks: System.Diagnostics.Stopwatch.GetTimestamp(), + Dt: dt, + CellBefore: cellBefore, + CellAfter: cellAfter, + Input: new PlayerMovementInputTraceSnapshot( + input.Forward, + input.Backward, + input.StrafeLeft, + input.StrafeRight, + input.TurnLeft, + input.TurnRight, + input.Run, + input.Jump), + RootAndManagerDelta: rootAndManagerDelta, + CandidateMoved: candidateMoved, + QuantumStart: quantumStart, + PreIntegration: preIntegration, + PostIntegration: postIntegration, + Resolve: resolve, + PostCommit: postCommit); + + string json = JsonSerializer.Serialize(record, s_jsonOptions); + lock (s_writerLock) + { + EnsureWriter_NoLock(path); + s_writer!.WriteLine(json); + s_writer.Flush(); + } + } + + internal static void Close() + { + lock (s_writerLock) + { + s_writer?.Dispose(); + s_writer = null; + } + } + + internal static void ResetForTest() + { + Close(); + CapturePath = null; + Interlocked.Exchange(ref s_sequence, 0); + } + + private static void EnsureWriter_NoLock(string path) + { + if (s_writer is not null) + return; + + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + s_writer = new StreamWriter(new FileStream( + path, + FileMode.Append, + FileAccess.Write, + FileShare.Read)) + { + AutoFlush = false, + }; + + if (!s_processExitHooked) + { + AppDomain.CurrentDomain.ProcessExit += static (_, _) => Close(); + s_processExitHooked = true; + } + } +} + +internal sealed record PlayerPhysicsQuantumTraceRecord( + long Sequence, + long TimestampTicks, + float Dt, + uint CellBefore, + uint CellAfter, + PlayerMovementInputTraceSnapshot Input, + Vector3 RootAndManagerDelta, + bool CandidateMoved, + PlayerPhysicsBodyTraceSnapshot QuantumStart, + PlayerPhysicsBodyTraceSnapshot PreIntegration, + PlayerPhysicsBodyTraceSnapshot PostIntegration, + PlayerPhysicsResolveTraceSnapshot Resolve, + PlayerPhysicsBodyTraceSnapshot PostCommit); + +internal readonly record struct PlayerMovementInputTraceSnapshot( + bool Forward, + bool Backward, + bool StrafeLeft, + bool StrafeRight, + bool TurnLeft, + bool TurnRight, + bool Run, + bool Jump); + +internal readonly record struct PlayerPhysicsBodyTraceSnapshot( + Vector3 Position, + Quaternion Orientation, + Vector3 Velocity, + Vector3 Acceleration, + Vector3 GroundNormal, + bool ContactPlaneValid, + Plane ContactPlane, + float Friction, + float Elasticity, + uint State, + uint TransientState, + int FramesStationaryFall); + +internal readonly record struct PlayerPhysicsResolveTraceSnapshot( + Vector3 Position, + uint CellId, + bool Ok, + bool IsOnGround, + bool InContact, + bool OnWalkable, + bool CollisionNormalValid, + Vector3 CollisionNormal); diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs index 560db1d6..ce837e81 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -483,6 +483,54 @@ public class BSPStepUpTests "wall (failed step-up should preserve LastKnownContactPlane)."); } + /// + /// Campaign P #269: retail validate_transition calls kill_velocity before + /// restoring a last-known contact plane on a non-OK collision recovery. + /// This is the post-landing stop mechanism when the 5% reflect points away + /// from a slope but the next tangential sweep collides with it again. + /// + [Fact] + public void D1b_LastKnownPlaneCollisionRecovery_KillsVelocity() + { + var (root, resolved) = BSPStepUpFixtures.TallWall(); + var transition = BSPStepUpFixtures.MakeGroundedTransition( + from: new Vector3(0.1f, 0f, 0f), + to: new Vector3(0.6f, 0f, 0f), + stepUpHeight: 0.04f); + var engine = MakeTestEngine(root, resolved, terrainZ: 0f); + + transition.FindTransitionalPosition(engine); + + Assert.True( + transition.ObjectInfo.VelocityKilled, + "Retail OBJECTINFO::kill_velocity must run before restoring the " + + "remembered floor after a collision/slide retry."); + } + + /// + /// The same remembered plane must not kill velocity or re-ground a clean + /// accepted move away from it. Retail consumes LastKnownContactPlane only + /// inside validate_transition's non-OK recovery branch. + /// + [Fact] + public void D1c_LastKnownPlaneCleanAdvance_DoesNotKillOrReground() + { + var (root, resolved) = BSPStepUpFixtures.TallWall(); + var transition = BSPStepUpFixtures.MakeAirborneTransition( + from: new Vector3(-2f, 0f, 2f), + to: new Vector3(-2f, 0f, 2.25f)); + transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, -1.52f); + transition.CollisionInfo.LastKnownContactPlaneValid = true; + var engine = MakeTestEngine(root, resolved, terrainZ: -50f); + + transition.FindTransitionalPosition(engine); + + Assert.False(transition.ObjectInfo.VelocityKilled); + Assert.False(transition.CollisionInfo.ContactPlaneValid); + Assert.False(transition.CollisionInfo.LastKnownContactPlaneValid); + Assert.False(transition.ObjectInfo.OnWalkable); + } + /// /// L.2.3b regression: Path 5 dispatch must be guarded against re-entry while /// a step-up is already in progress. Test runs FindTransitionalPosition diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerPhysicsQuantumCaptureTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerPhysicsQuantumCaptureTests.cs new file mode 100644 index 00000000..7c136cea --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerPhysicsQuantumCaptureTests.cs @@ -0,0 +1,164 @@ +using System.Numerics; +using System.Text.Json; +using AcDream.Core.Physics; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +[Collection(PlayerPhysicsQuantumCaptureCollection.Name)] +public sealed class PlayerPhysicsQuantumCaptureTests : IDisposable +{ + private readonly string _path = Path.Combine( + Path.GetTempPath(), + $"acdream-player-physics-{Guid.NewGuid():N}.jsonl"); + + public PlayerPhysicsQuantumCaptureTests() => + PlayerPhysicsQuantumCapture.ResetForTest(); + + [Fact] + public void DisabledCapture_DoesNotCreateAFile() + { + PhysicsBody body = CreateBody(); + + Assert.False(PlayerPhysicsQuantumCapture.IsEnabled); + _ = PlayerPhysicsQuantumCapture.Snapshot(body); + Assert.False(File.Exists(_path)); + } + + [Fact] + public void EnabledCapture_WritesCompleteOrderedQuantum() + { + PhysicsBody body = CreateBody(); + PlayerPhysicsQuantumCapture.CapturePath = _path; + + PlayerPhysicsBodyTraceSnapshot start = + PlayerPhysicsQuantumCapture.Snapshot(body); + body.Velocity = new Vector3(2f, 3f, -4f); + PlayerPhysicsBodyTraceSnapshot pre = + PlayerPhysicsQuantumCapture.Snapshot(body); + body.UpdatePhysicsInternal(0.05f); + PlayerPhysicsBodyTraceSnapshot post = + PlayerPhysicsQuantumCapture.Snapshot(body); + body.Velocity = new Vector3(1f, 1.5f, 0.2f); + PlayerPhysicsBodyTraceSnapshot committed = + PlayerPhysicsQuantumCapture.Snapshot(body); + + PlayerPhysicsQuantumCapture.Log( + dt: 0.05f, + cellBefore: 0xAAB40011, + cellAfter: 0xAAB40012, + input: new MovementInput(Forward: true, Run: true), + rootAndManagerDelta: new Vector3(0.1f, 0.2f, 0.3f), + candidateMoved: true, + quantumStart: start, + preIntegration: pre, + postIntegration: post, + resolve: new PlayerPhysicsResolveTraceSnapshot( + new Vector3(4f, 5f, 6f), + 0xAAB40012, + Ok: true, + IsOnGround: true, + InContact: true, + OnWalkable: true, + CollisionNormalValid: true, + CollisionNormal: Vector3.UnitZ), + postCommit: committed); + PlayerPhysicsQuantumCapture.Close(); + + string[] lines = File.ReadAllLines(_path); + Assert.Single(lines); + + using JsonDocument json = JsonDocument.Parse(lines[0]); + JsonElement root = json.RootElement; + Assert.Equal(0, root.GetProperty("sequence").GetInt64()); + Assert.Equal(0.05f, root.GetProperty("dt").GetSingle()); + Assert.Equal(0xAAB40011u, root.GetProperty("cellBefore").GetUInt32()); + Assert.Equal(0xAAB40012u, root.GetProperty("cellAfter").GetUInt32()); + Assert.True(root.GetProperty("input").GetProperty("forward").GetBoolean()); + Assert.True(root.GetProperty("input").GetProperty("run").GetBoolean()); + Assert.Equal(2f, + root.GetProperty("preIntegration") + .GetProperty("velocity") + .GetProperty("x") + .GetSingle()); + Assert.True(root.GetProperty("resolve").GetProperty("onWalkable").GetBoolean()); + Assert.Equal(0.2f, + root.GetProperty("postCommit") + .GetProperty("velocity") + .GetProperty("z") + .GetSingle()); + } + + [Fact] + public void ResetForTest_ClosesWriterAndRestartsSequence() + { + PhysicsBody body = CreateBody(); + PlayerPhysicsQuantumCapture.CapturePath = _path; + PlayerPhysicsBodyTraceSnapshot snapshot = + PlayerPhysicsQuantumCapture.Snapshot(body); + + WriteMinimal(snapshot); + PlayerPhysicsQuantumCapture.ResetForTest(); + PlayerPhysicsQuantumCapture.CapturePath = _path; + WriteMinimal(snapshot); + PlayerPhysicsQuantumCapture.Close(); + + string[] lines = File.ReadAllLines(_path); + Assert.Equal(2, lines.Length); + Assert.All(lines, line => + { + using JsonDocument json = JsonDocument.Parse(line); + Assert.Equal(0, json.RootElement.GetProperty("sequence").GetInt64()); + }); + } + + public void Dispose() + { + PlayerPhysicsQuantumCapture.ResetForTest(); + if (File.Exists(_path)) + File.Delete(_path); + } + + private void WriteMinimal(PlayerPhysicsBodyTraceSnapshot snapshot) => + PlayerPhysicsQuantumCapture.Log( + 0.04f, + 1, + 1, + default, + Vector3.Zero, + false, + snapshot, + snapshot, + snapshot, + new PlayerPhysicsResolveTraceSnapshot( + snapshot.Position, + 1, + true, + true, + true, + true, + false, + Vector3.Zero), + snapshot); + + private static PhysicsBody CreateBody() => new() + { + Position = new Vector3(1f, 2f, 3f), + Velocity = new Vector3(4f, 5f, 6f), + Acceleration = new Vector3(0f, 0f, -9.8f), + GroundNormal = Vector3.UnitZ, + ContactPlaneValid = true, + ContactPlane = new Plane(Vector3.UnitZ, -3f), + Friction = 0.95f, + Elasticity = 0.05f, + State = PhysicsStateFlags.Gravity, + TransientState = + TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + }; +} + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class PlayerPhysicsQuantumCaptureCollection +{ + public const string Name = "Player physics quantum capture"; +} diff --git a/tools/analyze_269_slope_stop_capture.py b/tools/analyze_269_slope_stop_capture.py new file mode 100644 index 00000000..29f55956 --- /dev/null +++ b/tools/analyze_269_slope_stop_capture.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Summarize an ACDREAM_CAPTURE_PLAYER_QUANTA JSONL for issue #269.""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +from typing import Any + + +def vector(value: dict[str, Any]) -> tuple[float, float, float]: + return float(value["x"]), float(value["y"]), float(value["z"]) + + +def length(value: tuple[float, float, float]) -> float: + return math.sqrt(sum(component * component for component in value)) + + +def horizontal(value: tuple[float, float, float]) -> float: + return math.hypot(value[0], value[1]) + + +def subtract( + left: tuple[float, float, float], + right: tuple[float, float, float], +) -> tuple[float, float, float]: + return tuple(a - b for a, b in zip(left, right, strict=True)) + + +def scale( + value: tuple[float, float, float], + scalar: float, +) -> tuple[float, float, float]: + return tuple(component * scalar for component in value) + + +def input_active(record: dict[str, Any]) -> bool: + state = record["input"] + return any( + bool(state[key]) + for key in ( + "forward", + "backward", + "strafeLeft", + "strafeRight", + "turnLeft", + "turnRight", + "jump", + ) + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("capture", type=Path) + parser.add_argument( + "--tail", + type=int, + default=45, + help="quanta printed after the final directional-input release", + ) + args = parser.parse_args() + + with args.capture.open("r", encoding="utf-8") as stream: + records = [json.loads(line) for line in stream if line.strip()] + + if not records: + print("capture contains no physics quanta") + return 2 + + release_indices = [ + index + for index in range(1, len(records)) + if input_active(records[index - 1]) and not input_active(records[index]) + ] + start = release_indices[-1] if release_indices else max(0, len(records) - args.tail) + stop = min(len(records), start + args.tail) + + print( + "seq dt walk(pre/post) |v|pre |v|fric |v|commit " + "horizCommit rootDelta collision fsf" + ) + for record in records[start:stop]: + dt = float(record["dt"]) + pre = record["preIntegration"] + post_integration = record["postIntegration"] + post_commit = record["postCommit"] + pre_velocity = vector(pre["velocity"]) + integrated_velocity = vector(post_integration["velocity"]) + acceleration = vector(pre["acceleration"]) + friction_velocity = subtract( + integrated_velocity, + scale(acceleration, dt), + ) + commit_velocity = vector(post_commit["velocity"]) + transient_pre = int(pre["transientState"]) + transient_post = int(post_commit["transientState"]) + pre_walk = (transient_pre & 0x2) != 0 + post_walk = (transient_post & 0x2) != 0 + root_delta = length(vector(record["rootAndManagerDelta"])) + resolve = record["resolve"] + collision = "yes" if resolve["collisionNormalValid"] else "no" + print( + f'{record["sequence"]:5d} {dt:0.5f} ' + f"{int(pre_walk)}/{int(post_walk)} " + f"{length(pre_velocity):8.4f} " + f"{length(friction_velocity):8.4f} " + f"{length(commit_velocity):8.4f} " + f"{horizontal(commit_velocity):8.4f} " + f"{root_delta:8.4f} {collision:>3s} " + f'{post_commit["framesStationaryFall"]:d}' + ) + + if release_indices: + print(f"\nlast directional-input release: sequence {records[start]['sequence']}") + else: + print("\nno directional-input release edge found; showing capture tail") + print(f"records: {len(records)}, displayed: {stop - start}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/cdb/issue269-slope-stop.cdb b/tools/cdb/issue269-slope-stop.cdb new file mode 100644 index 00000000..0ec5e652 --- /dev/null +++ b/tools/cdb/issue269-slope-stop.cdb @@ -0,0 +1,48 @@ +$$ +$$ Issue #269 retail slope-stop trace. +$$ +$$ This script must only be attached to the Sept 2013 EoR acclient.exe +$$ paired with refs/acclient.pdb (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32). +$$ The PowerShell runner verifies that pairing before attach. +$$ +$$ It records the player object's complete physics-integrator boundary and +$$ handle_all_collisions boundary as raw IEEE-754 bits. That is the minimum +$$ runtime evidence needed to distinguish friction cadence, contact loss, and +$$ post-sweep collision response without guessing from visible distance. +$$ + +.logopen ${ARG_LOG_PATH} +.sympath ${ARG_SYMBOL_PATH} +.symopt+ 0x40 +.reload /f acclient.exe + +r $t0 = 0 +r $t1 = 0 + +$$ CPhysicsObj offsets from PDB dt: +$$ state +0xa8, transient +0xac, friction +0xbc, +$$ velocity +0xe0, acceleration +0xec, contact_plane.N +0x130. +$$ UpdatePhysicsInternal entry: ecx=this, [esp+4]=dt, [esp+8]=Frame*. +bp acclient!CPhysicsObj::UpdatePhysicsInternal ".if (@ecx == poi(acclient!CPhysicsObj::player_object)) { r $t0=@$t0+1; .printf \"[UPI-IN] q=%d dt_h=%08X state=%08X transient=%08X friction_h=%08X vx_h=%08X vy_h=%08X vz_h=%08X ax_h=%08X ay_h=%08X az_h=%08X nx_h=%08X ny_h=%08X nz_h=%08X fx_h=%08X fy_h=%08X fz_h=%08X\\n\", @$t0, dwo(@esp+4), dwo(@ecx+0xa8), dwo(@ecx+0xac), dwo(@ecx+0xbc), dwo(@ecx+0xe0), dwo(@ecx+0xe4), dwo(@ecx+0xe8), dwo(@ecx+0xec), dwo(@ecx+0xf0), dwo(@ecx+0xf4), dwo(@ecx+0x130), dwo(@ecx+0x134), dwo(@ecx+0x138), dwo(poi(@esp+8)+0x34), dwo(poi(@esp+8)+0x38), dwo(poi(@esp+8)+0x3c) }; gc" + +$$ UpdatePhysicsInternal epilogue: edi=this, ebx=Frame*, velocity has +$$ completed friction + acceleration and Frame contains the integrated delta. +$$ The terminal hit intentionally omits gc so the top-level qd detaches cleanly. +bp acclient+0x0011093a ".if (@edi == poi(acclient!CPhysicsObj::player_object)) { .printf \"[UPI-OUT] q=%d transient=%08X vx_h=%08X vy_h=%08X vz_h=%08X fx_h=%08X fy_h=%08X fz_h=%08X\\n\", @$t0, dwo(@edi+0xac), dwo(@edi+0xe0), dwo(@edi+0xe4), dwo(@edi+0xe8), dwo(@ebx+0x34), dwo(@ebx+0x38), dwo(@ebx+0x3c); .if (@$t0 < ${ARG_MAX_QUANTA}) { gc } } .else { gc }" + +$$ handle_all_collisions entry: ecx=this, [esp+4]=COLLISIONINFO*. +$$ COLLISIONINFO offsets from PDB dt: normal-valid +0x48, +$$ normal +0x4c, frames_stationary_fall +0x80. +bp acclient!CPhysicsObj::handle_all_collisions ".if (@ecx == poi(acclient!CPhysicsObj::player_object)) { r $t1=@$t1+1; .printf \"[HAC-IN] h=%d q=%d fsf=%d normalValid=%d nx_h=%08X ny_h=%08X nz_h=%08X vx_h=%08X vy_h=%08X vz_h=%08X transient=%08X\\n\", @$t1, @$t0, dwo(poi(@esp+4)+0x80), dwo(poi(@esp+4)+0x48), dwo(poi(@esp+4)+0x4c), dwo(poi(@esp+4)+0x50), dwo(poi(@esp+4)+0x54), dwo(@ecx+0xe0), dwo(@ecx+0xe4), dwo(@ecx+0xe8), dwo(@ecx+0xac) }; gc" + +$$ Four epilogues correspond to fsf 0, 1, 2, and 3. At each address esi +$$ still owns this and the final velocity/transient state has been written. +bp acclient+0x00114977 ".if (@esi == poi(acclient!CPhysicsObj::player_object)) { .printf \"[HAC-OUT] h=%d q=%d fsfClass=0 vx_h=%08X vy_h=%08X vz_h=%08X transient=%08X\\n\", @$t1, @$t0, dwo(@esi+0xe0), dwo(@esi+0xe4), dwo(@esi+0xe8), dwo(@esi+0xac) }; gc" +bp acclient+0x00114997 ".if (@esi == poi(acclient!CPhysicsObj::player_object)) { .printf \"[HAC-OUT] h=%d q=%d fsfClass=1 vx_h=%08X vy_h=%08X vz_h=%08X transient=%08X\\n\", @$t1, @$t0, dwo(@esi+0xe0), dwo(@esi+0xe4), dwo(@esi+0xe8), dwo(@esi+0xac) }; gc" +bp acclient+0x001149b1 ".if (@esi == poi(acclient!CPhysicsObj::player_object)) { .printf \"[HAC-OUT] h=%d q=%d fsfClass=2 vx_h=%08X vy_h=%08X vz_h=%08X transient=%08X\\n\", @$t1, @$t0, dwo(@esi+0xe0), dwo(@esi+0xe4), dwo(@esi+0xe8), dwo(@esi+0xac) }; gc" +bp acclient+0x001149c6 ".if (@esi == poi(acclient!CPhysicsObj::player_object)) { .printf \"[HAC-OUT] h=%d q=%d fsfClass=3 vx_h=%08X vy_h=%08X vz_h=%08X transient=%08X\\n\", @$t1, @$t0, dwo(@esi+0xe0), dwo(@esi+0xe4), dwo(@esi+0xe8), dwo(@esi+0xac) }; gc" + +.printf "issue269 slope-stop probe armed; maxQuanta=${ARG_MAX_QUANTA}\\n" +g +.echo === DETACHING AFTER BOUNDED CAPTURE === +qd diff --git a/tools/cdb/run-issue269-slope-stop.ps1 b/tools/cdb/run-issue269-slope-stop.ps1 new file mode 100644 index 00000000..564dcf36 --- /dev/null +++ b/tools/cdb/run-issue269-slope-stop.ps1 @@ -0,0 +1,74 @@ +param( + [Parameter(Mandatory = $false)] + [ValidatePattern('^[A-Za-z0-9_.-]+$')] + [string]$ScenarioTag = "slope-stop", + + [Parameter(Mandatory = $false)] + [ValidateRange(60, 1800)] + [int]$MaxQuanta = 450 +) + +$ErrorActionPreference = "Stop" + +$cdbExe = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" +if (-not (Test-Path -LiteralPath $cdbExe)) { + throw "cdb.exe was not found at '$cdbExe'." +} + +$retail = Get-CimInstance Win32_Process -Filter "Name = 'acclient.exe'" | + Select-Object -First 1 +if ($null -eq $retail) { + throw "No live retail acclient.exe process was found." +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$checkScript = Join-Path $repoRoot "tools\pdb-extract\check_exe_pdb.py" +$pairing = & py $checkScript $retail.ExecutablePath 2>&1 | Out-String +if ($pairing -notmatch "=== MATCH:") { + throw @" +The live retail executable does not pair with the named-retail PDB. +Process path: $($retail.ExecutablePath) +$pairing +"@ +} + +$symbolCandidates = @( + (Join-Path $repoRoot "refs"), + (Join-Path $env:USERPROFILE "source\repos\acdream\refs"), + (Join-Path $env:USERPROFILE ".windbg\x86\sym") +) +$symbolPath = $symbolCandidates | + Where-Object { Test-Path -LiteralPath $_ } | + Select-Object -First 1 +if ([string]::IsNullOrWhiteSpace($symbolPath)) { + throw "The matching acclient.pdb symbol directory could not be found." +} + +$templatePath = Join-Path $PSScriptRoot "issue269-slope-stop.cdb" +$artifactDir = Join-Path $repoRoot "artifacts\issue269" +New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$logPath = Join-Path $artifactDir "$ScenarioTag-retail-$timestamp.log" +$scriptPath = Join-Path $env:TEMP "issue269-$ScenarioTag-$timestamp.cdb" + +$script = Get-Content -LiteralPath $templatePath -Raw +$script = $script.Replace('${ARG_LOG_PATH}', $logPath) +$script = $script.Replace('${ARG_SYMBOL_PATH}', $symbolPath) +$script = $script.Replace('${ARG_MAX_QUANTA}', $MaxQuanta.ToString( + [System.Globalization.CultureInfo]::InvariantCulture)) +Set-Content -LiteralPath $scriptPath -Value $script -Encoding ASCII + +Write-Host "Attaching cdb to retail PID $($retail.ProcessId)." +Write-Host "Capture: $logPath" +Write-Host "Perform the slope run, release movement, and let the character settle." +Write-Host "The probe detaches after $MaxQuanta player physics quanta." + +try { + & $cdbExe -p $retail.ProcessId -cf $scriptPath 2>&1 | + Out-File -LiteralPath "$logPath.console" -Encoding ASCII +} +finally { + Remove-Item -LiteralPath $scriptPath -ErrorAction SilentlyContinue +} + +Write-Host "Retail capture complete: $logPath" From 2dcb4f1d94944ac8f7752fd9f9e8398cc5dc5a51 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 09:26:28 +0200 Subject: [PATCH 05/73] fix(physics): port retail stair edge backprobe --- docs/ISSUES.md | 32 ++++++ .../2026-07-29-physics-parity-campaign.md | 10 ++ ...2026-07-31-271-stair-side-slide-capture.md | 108 ++++++++++++++++++ .../project_movement_collision_conformance.md | 12 ++ src/AcDream.Core/Physics/TransitionTypes.cs | 20 +++- .../Issue185OutdoorStairsSeamReplayTests.cs | 42 +++++++ 6 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 docs/research/2026-07-31-271-stair-side-slide-capture.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c64ad444..0211aa91 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -98,6 +98,38 @@ Copy this block when adding a new issue: --- +## #271 — Stair-side collision reverses uphill movement and rapidly slides the player down + +**Status:** DONE — 2026-07-31 (implementation + user live gate) +**Severity:** MEDIUM (movement feel and navigation) +**Component:** physics / `edge_slide` / `precipice_slide` + +**Symptom:** while running diagonally up an outdoor staircase and pressing +against its side, the character could suddenly move backward and slide rapidly +to the bottom. + +**Root cause:** a 677-quantum live trace caught the first bad frame. A valid +X side-wall collision turned a requested `+0.88377` uphill Y displacement into +`-0.34059`, followed three frames later by a 1.52 m snap to terrain. +`EdgeSlideAfterStepDownFailed` promoted ACDream's separately retained +`LastWalkable` polygon into the current `SPHEREPATH::walkable` slot. At the +stair side this could be the preceding tread, so `PrecipiceSlide` projected +against stale geometry and reversed the tangent. + +Named-retail `CTransition::edge_slide @ 0x0050B3D0` never substitutes an older +polygon: when current `walkable` is null it back-probes at the current sphere +center, restores the failed candidate, and only then invokes +`SPHEREPATH::precipice_slide @ 0x0050CC80`. Both stale-history substitutions +are removed. The exact captured frame is pinned against the installed stair +fixture: pre-fix output moved downhill to Y `75.346481`, while the retail-flow +result advances uphill to Y `76.078186` and Z `60.016247`. Core Release passes +4,108 tests / 2 skips; the complete Release solution passes 10,062 tests / +5 skips. The user then repeatedly climbed the same stairs while pressing into +their sides and confirmed the rapid downhill slide was gone. Evidence: +`docs/research/2026-07-31-271-stair-side-slide-capture.md`. + +--- + ## #270 — Stuck spell animations + intermittently missing monster attack animations **Status:** CLOSED 2026-07-31 — both symptoms user-verified fixed (stuck casts: exhaustion-edge gate `a46c8e65`; missing monster attack animations: spawn settle placement `21b3a3f3` + lost-cell retry `807fdb5f`). Final settle-session log: 14/15 spawn settles grounded; Falling-refusal spam 2,954 → 15 transient pre-settle lines. All #270 probes stripped. diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index b7bc2d59..a227cd19 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -389,6 +389,16 @@ root-caused, retail-ported, and user-accepted in the same session: are now ported, focused/full gates pass, and the user accepted repeated slope jumps. Evidence: `docs/research/2026-07-31-269-slope-stop-capture.md`. +- **#271 closed 2026-07-31** — a bounded stair-side + trace proved ACDream could bypass retail's current-position edge back-probe + by promoting a stale `LastWalkable` tread. That made PrecipiceSlide reverse + an uphill tangent and rapidly carry the player down the stairs. The two + stale-history substitutions are removed; current-walkable, back-probe, and + no-walkable outcomes now follow `CTransition::edge_slide @ 0x0050B3D0`. + The exact captured frame is pinned in the existing installed-stair fixture + and the complete Release suite passes 10,062 tests / 5 skips. The user + accepted repeated uphill runs while pressing into the stair sides. Evidence: + `docs/research/2026-07-31-271-stair-side-slide-capture.md`. Matrix rows accepted so far: speed parity, roof slide, downhill bounce, flat pop, uphill landing, and #269's slope-stop feel diff --git a/docs/research/2026-07-31-271-stair-side-slide-capture.md b/docs/research/2026-07-31-271-stair-side-slide-capture.md new file mode 100644 index 00000000..1e09b459 --- /dev/null +++ b/docs/research/2026-07-31-271-stair-side-slide-capture.md @@ -0,0 +1,108 @@ +# #271 — Stair-side uphill reversal capture + +**Date:** 2026-07-31 + +**Status:** closed; retail control flow restored and user live gate passed + +## Symptom + +When the local player ran diagonally uphill while pressing into the side of +an outdoor staircase, the character could suddenly move backward and rapidly +slide to the bottom. The symptom was intermittent because it required the +forward candidate to hit the side wall while the step-down recovery crossed a +tread edge. + +This is not an RDP, render-rate, animation, or gravity symptom. It reproduced +inside the pure Core collision resolver from one captured input frame. + +## Live evidence + +The bounded capture is under the ignored local artifact pointer: + +`artifacts/issue271-stair-side/LATEST.txt` + +It contains 677 local-player physics quanta plus the matching resolver stream. +The first decisive frame is quantum 310: + +```text +current = (133.03775, 75.53931, 59.608147) +target = (133.33783, 76.42308, 59.608147) +input = forward + run +result = (133.18779, 75.19872, 59.316677) +normal = (-1, approximately 0, approximately 0) +``` + +The X side-wall collision was valid, but the tangential Y component reversed: +an uphill request of `+0.88377` produced `-0.34059`. Three frames later, +quantum 313 snapped from Z `59.52598` to terrain Z `58.005`. A second attempt +reproduced the same family at quanta 479–482, falling from Z `60.96376` to +`58.005`. + +## Retail oracle + +Named retail: + +- `CTransition::edge_slide` at `0x0050B3D0` +- current-walkable branch at `0x0050B44A` +- no-walkable back-probe at `0x0050B458–0x0050B50F` +- `SPHEREPATH::precipice_slide` at `0x0050CC80` + +Retail tests only the current `SPHEREPATH::walkable` pointer. If it is null, +retail: + +1. offsets the failed candidate back to the current sphere center; +2. runs `step_down` there to rediscover the surface actually under the mover; +3. restores the failed candidate; +4. runs `precipice_slide` against the newly discovered polygon; or +5. returns `COLLIDED_TS` when the back-probe found no walkable polygon. + +Retail has no substitution of an older saved walkable polygon in either null +case. + +## ACDream divergence and root cause + +`EdgeSlideAfterStepDownFailed` previously called +`SpherePath.RestoreLastWalkable()`: + +- before deciding whether to enter the retail back-probe; and +- again when the back-probe found no current walkable polygon. + +`LastWalkable` is a separate ACDream history used by the still-open +CliffSlide compatibility path. At a staircase side wall it could describe the +preceding tread rather than the surface below the current player position. +Promoting it into the current slot bypassed retail's back-probe. +`PrecipiceSlide` then projected the failed forward candidate along the stale +tread edge, producing the backward/downhill displacement seen in the capture. + +The fix removes both stale-history promotions from the edge-slide dispatch. +Current walkable state still takes retail's direct precipice path; absent +state now always takes retail's current-position back-probe. + +## Deterministic regression + +`Issue185OutdoorStairsSeamReplayTests` reuses the captured +`0x01000AC5` staircase collision fixture and the exact quantum-310 position, +contact plane, movement delta, player flags, and 1.5 m Setup step-down height. + +Pre-fix: + +```text +out = (133.187790, 75.346481, 59.430882) +``` + +Fixed: + +```text +out = (133.187790, 76.078186, 60.016247) +``` + +The regression requires meaningful positive uphill progress and forbids a +downhill Z displacement. The complete Core Release suite passes 4,108 tests / +2 skips; the complete Release solution passes 10,062 tests / 5 skips. + +## Live acceptance + +The user repeatedly ran uphill while pressing into both sides of the affected +staircase. Movement remained stable and the former rapid downhill reversal did +not recur. The client then closed through the normal logout path, with ACE +confirming graceful logout. diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 98eea409..99bdbcf8 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -87,3 +87,15 @@ InputDispatcher / PlayerMovementController plane. Both rules are now ported; focused/full tests and the user's repeated slope-jump gate pass. See `docs/research/2026-07-31-269-slope-stop-capture.md`. +- 2026-07-31: #271 stair-side uphill reversal. Never promote ACDream's + retained `LastWalkable` history into retail's current + `SPHEREPATH::walkable` slot inside `edge_slide`. When current walkable is + null, retail `CTransition::edge_slide @ 0x0050B3D0` must back-probe at the + current sphere center and either use that newly discovered polygon or + collide. The stale substitution selected a preceding stair tread, reversed + the uphill tangent, and dropped the player rapidly down the stairs. The + exact live frame is pinned in `Issue185OutdoorStairsSeamReplayTests`; Core + passes 4,108 / 2 skips and the complete Release solution passes 10,062 / + 5 skips. The user accepted repeated uphill stair-side runs on 2026-07-31. + See + `docs/research/2026-07-31-271-stair-side-slide-capture.md`. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index d808ac5c..b3f9cf81 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -2192,9 +2192,18 @@ public sealed class Transition return CliffSlide(cliffPlane); } - if (!sp.HasWalkablePolygon) - sp.RestoreLastWalkable(); - + // Retail tests only SPHEREPATH::walkable here. When the failed + // step-down has no current walkable polygon it MUST continue into the + // back-probe below, which rediscovers the polygon under CurPos before + // testing the failed candidate against its edge + // (CTransition::edge_slide 0x0050B3D0, 0x0050B44A-0x0050B50F). + // + // #271 (2026-07-31): acdream previously promoted its separately + // retained LastWalkable polygon into the current slot at this point. + // At a staircase side wall that polygon could be the preceding tread. + // PrecipiceSlide then projected the forward candidate along the stale + // tread edge, reversing its uphill tangent and dropping the player + // rapidly down the stairs. Do not restore stale history here. if (sp.HasWalkablePolygon) { // L.4-walkable-steep (2026-04-30): the stored Walkable polygon @@ -2293,9 +2302,8 @@ public sealed class Transition ci.ContactPlaneIsWater = false; sp.RestoreCheckPos(); - if (!sp.HasWalkablePolygon) - sp.RestoreLastWalkable(); - + // Retail returns Collided when the back-probe found no walkable. + // In particular, it does not substitute a retained earlier polygon. if (sp.HasWalkablePolygon) return sp.PrecipiceSlide(this); diff --git a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs index ec210cbf..9ee07592 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs @@ -177,6 +177,48 @@ public class Issue185OutdoorStairsSeamReplayTests "A continuous walkable ramp seam must not persist a horizontal sliding normal (#137 family)."); } + /// + /// #271 live capture, quantum 310: a forward/uphill displacement that also + /// presses into the staircase's side wall must keep its uphill tangent. + /// Pre-fix the composite retry path reversed that tangent, moving from + /// Y=75.539 to Y=75.199 and rapidly carrying the player back down the stairs. + /// + [Fact] + public void OutdoorStairs_SideWallContact_DoesNotReverseUphillTangent() + { + var engine = BuildStairEngine(); + var body = GroundedOnTread(); + body.Position = new Vector3(133.03775f, 75.53931f, 59.608147f); + body.ContactPlane = new Plane( + new Vector3(3.2782555e-07f, -0.62469506f, 0.78086877f), + 0.75193405f); + + ResolveResult result = engine.ResolveWithTransition( + currentPos: body.Position, + targetPos: body.Position + new Vector3(0.30007935f, 0.8837738f, 0f), + cellId: StairCellId, + sphereRadius: 0.48f, + sphereHeight: 1.835f, + stepUpHeight: 0.6f, + stepDownHeight: 1.5f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000000u); + + _out.WriteLine( + $"out=({result.Position.X:F6},{result.Position.Y:F6},{result.Position.Z:F6}) " + + $"collision={result.CollisionNormalValid} " + + $"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})"); + + Assert.True(result.Position.Y > body.Position.Y + 0.25f, + $"Side-wall response failed to preserve meaningful uphill motion: " + + $"{body.Position.Y:F6} -> {result.Position.Y:F6}."); + Assert.True(result.Position.Z >= body.Position.Z - 0.001f, + $"Side-wall response dropped the grounded player downhill: " + + $"{body.Position.Z:F6} -> {result.Position.Z:F6}."); + } + private static string SolutionRoot() { var dir = AppContext.BaseDirectory; From 2b9dfec9d7845c3678de5b87d99a825fb8a210c2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 09:31:47 +0200 Subject: [PATCH 06/73] docs(physics): close stat-chain live gate --- docs/ISSUES.md | 4 +++- docs/plans/2026-07-29-physics-parity-campaign.md | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0211aa91..96b43e8f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -217,7 +217,7 @@ Capture and decode: ## #268 — Character panel: vitae color, buff coloring, and augmentation bonuses -**Status:** IMPLEMENTED 2026-07-31 — closure pends the user visual/live gate. +**Status:** DONE — 2026-07-31 (implementation + user visual/live gate). **Severity:** MEDIUM (presentation parity) **Component:** retained UI / character window @@ -240,6 +240,8 @@ the authored LayoutDesc 0x2100002E / FooterTitle 0x1000024E palette index 3 (#7FFFFF), while positive/negative buff fragments use palette indices 1/2 (#00FF00/#FF0000). Attributes, secondary attributes, and skills share those exact colors. AP-127 and TS-8 are retired by the same stat-chain package. +The user confirmed the live buff values, footer coloring, and immediate skill +row refresh after the final retained-UI invalidation correction. --- diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index a227cd19..d06b0696 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -372,14 +372,15 @@ root-caused, retail-ported, and user-accepted in the same session: user-accepted ("almost pass with merits"). Investigation + byte-decode record: `docs/research/2026-07-30-landing-bounce-family.md`. - **#267 shipped** (vitae/buff panel values; attributes vitae-immune). - **#268 implemented 2026-07-31; live visual gate pending**: the complete + **#268 closed 2026-07-31**: the complete augmentation chain is shared by panel and Runtime movement; AP-127 is retired. Attributes, secondary attributes, and skills use retail's vitae-excluded green/red comparison. The selected-skill footer now renders per-fragment colors through the shared retained text primitive, using the authored 0x1B palette exactly: #7FFFFF vitae, #00FF00 buff, #FF0000 debuff. TS-8 is also retired: a real live 0x02C2 payload carries its full - StatMod through dispatch and changes the effective skill immediately. + StatMod through dispatch and changes the effective skill immediately. The + user accepted the live colors, values, footer, and immediate row refresh. - **#269 closed 2026-07-31** — the live 2,184-quantum trace proved the landing reflect and friction math were correct. ACDream omitted retail's `OBJECTINFO::kill_velocity` before restoring a remembered contact plane From d6e8b603032cb7bd112406e0519be9b9fa443c8f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 10:16:27 +0200 Subject: [PATCH 07/73] fix(movement): invalidate burden on enchantment changes --- docs/ISSUES.md | 37 ++++++++++ docs/plans/2026-04-11-roadmap.md | 6 +- .../2026-07-29-physics-parity-campaign.md | 8 +++ ...-07-30-stat-coupled-movement-pseudocode.md | 17 +++-- .../project_movement_collision_conformance.md | 9 +++ .../InteractionRetainedUiComposition.cs | 11 ++- .../UI/Layout/IndicatorBarController.cs | 9 ++- .../UI/Layout/InventoryController.cs | 15 +++- src/AcDream.App/UI/RetailUiRuntime.cs | 4 +- .../Session/LiveSessionEventRouter.cs | 8 ++- .../UI/Layout/IndicatorBarControllerTests.cs | 23 ++++++- .../UI/Layout/InventoryControllerTests.cs | 41 ++++++++++- .../Session/LiveSessionEventRouterTests.cs | 68 +++++++++++++++++++ 13 files changed, 231 insertions(+), 25 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 96b43e8f..3dc8f9a3 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -98,6 +98,43 @@ Copy this block when adding a new issue: --- +## #272 — Strength enchantments do not invalidate burden + +**Status:** DONE — 2026-07-31 (implementation, automated gates, and user live +buff/death gate) +**Severity:** HIGH (movement state and retained HUD disagree with retail) +**Filed:** 2026-07-31 +**Component:** player qualities / enchantments / burden + +**Symptom:** while overburdened, casting a Strength spell did not reduce the +burden state until the base Strength attribute changed. Dying purged the +Strength spell but did not restore the overburdened state. + +**Root cause:** `LiveSessionEventRouter.RecomputeBurden` read raw +`AttributeValue.Current` and subscribed only to base Strength/object-table +changes. The indicator bar and inventory meter had the same raw-Strength +composition, and the inventory meter did not observe enchantment changes. +Retail `CACQualities::InqLoad @ 0x0058F130` calls +`CACQualities::InqAttribute @ 0x00591A00`, which applies +`CACQualities::EnchantAttribute @ 0x00594570`; every load query therefore uses +effective Strength. + +**Fix:** all three consumers now read +`LocalPlayerState.GetEffectiveAttribute(Strength)`. The Runtime burden owner, +indicator bar, and inventory meter subscribe to the canonical +`Spellbook.EnchantmentsChanged` edge, covering add, remove, expiration, +dispel, and death purge through one path. Regression tests pin both +buff-to-unburdened and purge-to-overburdened transitions without any base +attribute update. + +**Acceptance:** overload a character, cast a Strength spell, and observe the +burden icon/meter plus movement update immediately. Die while the spell is +active and observe the spell purge restore the overburdened icon/meter and +movement immediately. **Passed live 2026-07-31:** the user confirmed the +burden state now updates correctly. + +--- + ## #271 — Stair-side collision reverses uphill movement and rapidly slides the player down **Status:** DONE — 2026-07-31 (implementation + user live gate) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4aa27c21..39561d28 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -53,7 +53,11 @@ TS-8 are retired by focused and end-to-end packet tests. #269's capture-driven slope-slide residual is also closed and user-accepted: `CTransition::validate_transition` now performs retail's non-OK-only remembered-plane restore with the preceding `OBJECTINFO::kill_velocity`. -Campaign P now continues with the unfinished live matrix rows. +The matrix then exposed #272: burden was invalidated by base Strength but not +by Strength enchantment add/purge. Runtime movement plus both retained burden +surfaces now use effective Strength and the canonical enchantment-change edge; +automated gates pass and the connected buff/death gate was user-accepted on +2026-07-31. Campaign P then continues with the unfinished live matrix rows. --- diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index d06b0696..2a204afa 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -400,6 +400,14 @@ root-caused, retail-ported, and user-accepted in the same session: and the complete Release suite passes 10,062 tests / 5 skips. The user accepted repeated uphill runs while pressing into the stair sides. Evidence: `docs/research/2026-07-31-271-stair-side-slide-capture.md`. +- **#272 complete and user-accepted 2026-07-31** — + `CACQualities::InqLoad` consumes enchantment-adjusted Strength through + `InqAttribute`, but Runtime movement and both retained burden displays read + raw Strength and did not share the enchantment invalidation edge. They now + consume `GetEffectiveAttribute(Strength)` and + `Spellbook.EnchantmentsChanged`, so buff, dispel, expiration, and death + purge recompute the same burden state immediately. Focused and full + Runtime/App tests pass. Matrix rows accepted so far: speed parity, roof slide, downhill bounce, flat pop, uphill landing, and #269's slope-stop feel diff --git a/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md b/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md index 6d8ff3ee..8493e940 100644 --- a/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md +++ b/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md @@ -54,11 +54,16 @@ InqLoad(this, &loadOut): return 1 // always succeeds for CACQualities (has vtable) ``` -This EXACTLY matches acdream's existing `IndicatorBarController.UpdateBurden()` -/ `InventoryController.RefreshBurden()` pattern (Strength attribute + prop -0xE6 aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the -wire value is absent) — already ported, already correct, already tested via -the UI. **`AcDream.Core.Items.BurdenMath` +The property/capacity shape matches acdream's +`IndicatorBarController.UpdateBurden()` / +`InventoryController.RefreshBurden()` pattern (Strength attribute + prop 0xE6 +aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the wire +value is absent). A 2026-07-31 connected gate exposed one omitted retail +detail: `InqAttribute` returns the enchantment-adjusted attribute, while all +three acdream burden consumers still read raw `AttributeValue.Current`. +Issue #272 corrects them to `LocalPlayerState.GetEffectiveAttribute(Strength)` +and invalidates burden on the canonical `Spellbook.EnchantmentsChanged` edge. +**`AcDream.Core.Items.BurdenMath` (`EncumbranceCapacity`/`LoadRatio`/`LoadModifier`) is the SAME formulas at the SAME addresses.** P1's `EncumbranceSystem` (Physics-namespaced, for citation clarity next to `MovementSystem`) delegates to `BurdenMath` rather @@ -317,8 +322,10 @@ Runtime (AcDream.Runtime, presentation-free): - onSkillsUpdated callback -> character.Character.UpdateMovementSkillBase(...) - NEW: inventory.Objects.{ObjectAdded,ObjectUpdated,ObjectRemoved,ObjectMoved, ContainerContentsReplaced,Cleared} + LocalPlayer.AttributeChanged(Strength) + + Spellbook.EnchantmentsChanged -> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal, SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden) + using effective/enchantment-adjusted Strength -> character.Character.MovementSkills.UpdateBurden(ratio) - NEW: character.Character.LocalPlayer.Changed(VitalKind.Stamina) -> character.Character.MovementSkills.UpdateStamina(current) diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 99bdbcf8..65cdbffe 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -99,3 +99,12 @@ InputDispatcher / PlayerMovementController 5 skips. The user accepted repeated uphill stair-side runs on 2026-07-31. See `docs/research/2026-07-31-271-stair-side-slide-capture.md`. +- 2026-07-31: #272 burden invalidation. Retail `InqLoad @ 0x0058F130` + obtains Strength through `InqAttribute @ 0x00591A00`, so carry capacity + always uses the enchantment-adjusted value. Runtime movement, the indicator + bar, and the inventory meter must all consume + `LocalPlayerState.GetEffectiveAttribute(Strength)` and react to the same + `Spellbook.EnchantmentsChanged` event. That one event covers spell add, + remove, expiration, dispel, and death purge. Never refresh this through a + UI-only workaround or wait for a base-attribute packet. The connected + buff/death gate was user-accepted on 2026-07-31. diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 535121ef..4d084b7f 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -614,9 +614,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.Character.Spellbook, d.Inventory.Objects, () => d.PlayerIdentity.ServerGuid, - () => d.Character.LocalPlayer.GetAttribute( - LocalPlayerState.AttributeKind.Strength) - is { } strength ? (int?)strength.Current : null, + () => d.Character.LocalPlayer.GetEffectiveAttribute( + LocalPlayerState.AttributeKind.Strength), () => late.Session.LinkStatus, d.ClientTime, () => late.Session.CurrentSession?.RequestLinkStatusPing(), @@ -656,9 +655,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory () => d.PlayerIdentity.ServerGuid, iconComposer.GetIcon, iconComposer.GetDragIcon, - () => d.Character.LocalPlayer.GetAttribute( - LocalPlayerState.AttributeKind.Strength) - is { } strength ? (int?)strength.Current : null, + () => d.Character.LocalPlayer.GetEffectiveAttribute( + LocalPlayerState.AttributeKind.Strength), + d.Character.Spellbook, guid => late.Session.CurrentSession?.SendUse(guid), (item, container, placement) => late.Session.CurrentSession?.SendPutItemInContainer( diff --git a/src/AcDream.App/UI/Layout/IndicatorBarController.cs b/src/AcDream.App/UI/Layout/IndicatorBarController.cs index 60e6b741..6f4becf7 100644 --- a/src/AcDream.App/UI/Layout/IndicatorBarController.cs +++ b/src/AcDream.App/UI/Layout/IndicatorBarController.cs @@ -91,7 +91,7 @@ public sealed class IndicatorBarController : IRetainedPanelController _miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame); _endCharacterSession.OnClick = bindings.RequestEndCharacterSession; - bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments; + bindings.Spellbook.EnchantmentsChanged += OnEnchantmentsChanged; bindings.Objects.ObjectAdded += OnObjectChanged; bindings.Objects.ObjectUpdated += OnObjectChanged; bindings.Objects.ObjectRemoved += OnObjectChanged; @@ -229,6 +229,11 @@ public sealed class IndicatorBarController : IRetainedPanelController private void OnObjectMoved(ClientObjectMove _) => UpdateBurden(); private void OnContainerContentsReplaced(uint _) => UpdateBurden(); private void OnObjectsCleared() => UpdateBurden(); + private void OnEnchantmentsChanged() + { + UpdateEnchantments(); + UpdateBurden(); + } private void UpdateBurden() { @@ -253,7 +258,7 @@ public sealed class IndicatorBarController : IRetainedPanelController { if (_disposed) return; _disposed = true; - _bindings.Spellbook.EnchantmentsChanged -= UpdateEnchantments; + _bindings.Spellbook.EnchantmentsChanged -= OnEnchantmentsChanged; _bindings.Objects.ObjectAdded -= OnObjectChanged; _bindings.Objects.ObjectUpdated -= OnObjectChanged; _bindings.Objects.ObjectRemoved -= OnObjectChanged; diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 4030a4b1..ec4f5e1b 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -3,6 +3,7 @@ using System.Numerics; using AcDream.App.UI; using AcDream.Core.Items; using AcDream.Core.Selection; +using AcDream.Core.Spells; namespace AcDream.App.UI.Layout; @@ -43,6 +44,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo private readonly Func _iconIds; private readonly Func? _dragIconIds; private readonly Func _strength; + private readonly Spellbook? _burdenSpellbook; private readonly Func? _ownerName; private readonly UiItemList? _contentsGrid; @@ -96,13 +98,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo Action? notifyMergeAttempt, ItemInteractionController? itemInteraction, Action? onClose, - StackSplitQuantityState? stackSplitQuantity) + StackSplitQuantityState? stackSplitQuantity, + Spellbook? burdenSpellbook) { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _dragIconIds = dragIconIds; _strength = strength; + _burdenSpellbook = burdenSpellbook; _ownerName = ownerName; _sendUse = sendUse; _sendPutItemInContainer = sendPutItemInContainer; @@ -195,6 +199,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectUpdated += OnObjectChanged; _objects.Cleared += OnObjectsCleared; _selection.Changed += OnSelectionChanged; + if (_burdenSpellbook is not null) + _burdenSpellbook.EnchantmentsChanged += RefreshBurden; if (_itemInteraction is not null) { _itemInteraction.StateChanged += OnInteractionStateChanged; @@ -244,14 +250,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo ItemInteractionController? itemInteraction = null, Action? onClose = null, StackSplitQuantityState? stackSplitQuantity = null, - Func? dragIconIds = null) + Func? dragIconIds = null, + Spellbook? burdenSpellbook = null) => new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection, ownerName, datFont, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, sendUse, sendPutItemInContainer, sendStackableSplitToContainer, sendStackableMerge, notifyMergeAttempt, itemInteraction, - onClose, stackSplitQuantity); + onClose, stackSplitQuantity, burdenSpellbook); private void OnObjectChanged(ClientObject o) { @@ -944,6 +951,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectUpdated -= OnObjectChanged; _objects.Cleared -= OnObjectsCleared; _selection.Changed -= OnSelectionChanged; + if (_burdenSpellbook is not null) + _burdenSpellbook.EnchantmentsChanged -= RefreshBurden; if (_contentsGrid is not null) { _contentsGrid.PrimaryItemPressed = null; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index cec6f563..d8a45095 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -122,6 +122,7 @@ public sealed record InventoryRuntimeBindings( Func ResolveIcon, Func ResolveDragIcon, Func Strength, + Spellbook Spellbook, Action? SendUse, Action? SendPutItemInContainer, Action? SendStackableSplitToContainer, @@ -1816,7 +1817,8 @@ public sealed class RetailUiRuntime : IDisposable notifyMergeAttempt, b.ItemInteraction, () => CloseWindow(WindowNames.Inventory), StackSplitQuantity, - b.ResolveDragIcon); + b.ResolveDragIcon, + b.Spellbook); InventoryPanelController = inventory; PaperdollController paperdoll = PaperdollController.Bind( layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction, diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 704fa486..9098d5e3 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -229,6 +229,10 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting if (kind == LocalPlayerState.AttributeKind.Strength) RecomputeBurden(inventory, character); }); + SubscribeParameterless( + h => character.Character.Spellbook.EnchantmentsChanged += h, + h => character.Character.Spellbook.EnchantmentsChanged -= h, + () => RecomputeBurden(inventory, character)); // Current-stamina push — CACQualities::InqRunRate/InqJumpVelocity's // stamina==0 effective-skill-zeroing gate (pseudocode doc §5). @@ -370,8 +374,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting { uint player = inventory.PlayerGuid(); ClientObject? playerObject = inventory.Objects.Get(player); - int strength = (int)(character.Character.LocalPlayer - .GetAttribute(LocalPlayerState.AttributeKind.Strength)?.Current ?? 0u); + int strength = character.Character.LocalPlayer + .GetEffectiveAttribute(LocalPlayerState.AttributeKind.Strength) ?? 0; int aug = playerObject?.Properties.GetInt( (uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0; int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug); diff --git a/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs index 8e5c01f9..84fa8f60 100644 --- a/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs @@ -89,6 +89,25 @@ public sealed class IndicatorBarControllerTests Assert.Equal(expectedState, button.ActiveRetailStateId); } + [Fact] + public void EnchantmentChange_ReevaluatesBurdenFromEffectiveStrength() + { + var h = CreateHarness(strength: 10); + using IndicatorBarController controller = h.Controller; + h.Objects.UpdateIntProperty(Player, 5u, 1600); + UiButton button = h.Button(IndicatorBarController.BurdenButtonId); + Assert.Equal(IndicatorBarController.EncumberedState, button.ActiveRetailStateId); + + h.Strength = 20; + h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord( + 42u, 1u, 60d, Player, Bucket: 1u)); + Assert.Equal(IndicatorBarController.UnencumberedState, button.ActiveRetailStateId); + + h.Strength = 10; + h.Spellbook.OnPurgeAll(); + Assert.Equal(IndicatorBarController.EncumberedState, button.ActiveRetailStateId); + } + [Fact] public void Burden_ClickOpensCharacterInformationPanel() { @@ -205,7 +224,7 @@ public sealed class IndicatorBarControllerTests spellbook, objects, () => Player, - () => strength, + () => harness.Strength, () => harness.LinkStatus, () => harness.Time, harness.ToggledPanels.Add, @@ -222,7 +241,7 @@ public sealed class IndicatorBarControllerTests public ImportedLayout Layout { get; } = layout; public Spellbook Spellbook { get; } = spellbook; public ClientObjectTable Objects { get; } = objects; - public int Strength { get; } = strength; + public int Strength { get; set; } = strength; public List ToggledPanels { get; } = []; public double Time { get; set; } public LinkStatusSnapshot LinkStatus { get; set; } = new(true, 0d); diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 9e7dcc3f..fd61d255 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -3,6 +3,7 @@ using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Selection; +using AcDream.Core.Spells; using Xunit; namespace AcDream.App.Tests.UI.Layout; @@ -63,10 +64,12 @@ public class InventoryControllerTests Action? onClose = null, SelectionState? selection = null, StackSplitQuantityState? stackSplitQuantity = null, - ItemInteractionController? itemInteraction = null) + ItemInteractionController? itemInteraction = null, + Func? strengthProvider = null, + Spellbook? burdenSpellbook = null) => InventoryController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0u, - strength: () => strength, datFont: null, + strength: strengthProvider ?? (() => strength), datFont: null, ownerName: ownerName is null ? null : () => ownerName, sendUse: uses is null ? null : g => uses.Add(g), sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)), @@ -78,7 +81,8 @@ public class InventoryControllerTests onClose: onClose, selection: selection ?? new SelectionState(), stackSplitQuantity: stackSplitQuantity, - itemInteraction: itemInteraction); + itemInteraction: itemInteraction, + burdenSpellbook: burdenSpellbook); private static UiButton MakeButton(uint id) { @@ -238,6 +242,37 @@ public class InventoryControllerTests Assert.Contains("50%", CaptionText(burdenText)); } + [Fact] + public void EnchantmentChange_RefreshesBurdenFromEffectiveStrength() + { + var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + var props = new PropertyBundle(); + props.Ints[5] = 1600; + objects.UpsertProperties(Player, props); + int effectiveStrength = 10; + var spellbook = new Spellbook(); + using InventoryController controller = Bind( + layout, + objects, + strengthProvider: () => effectiveStrength, + burdenSpellbook: spellbook); + + Assert.Equal(1600f / 1500f / 3f, meter.Fill() ?? -1f, 3); + Assert.Contains("106%", CaptionText(burdenText)); + + effectiveStrength = 20; + spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord( + 42u, 1u, 60d, Player, Bucket: 1u)); + Assert.Equal(1600f / 3000f / 3f, meter.Fill() ?? -1f, 3); + Assert.Contains("53%", CaptionText(burdenText)); + + effectiveStrength = 10; + spellbook.OnPurgeAll(); + Assert.Equal(1600f / 1500f / 3f, meter.Fill() ?? -1f, 3); + Assert.Contains("106%", CaptionText(burdenText)); + } + [Fact] public void Captions_render_known_strings() { diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index fc3d2fe3..44b73527 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -233,6 +233,74 @@ public sealed class LiveSessionEventRouterTests router.Dispose(); } + [Fact] + public void StrengthEnchantmentChange_RecomputesBurdenWithoutBaseAttributeUpdate() + { + using var session = NewSession(); + const uint playerGuid = 0x50000001u; + var objects = new ClientObjectTable(); + var character = new RuntimeCharacterState(); + character.InstallSpellMetadata(SpellTable.LoadFromReader(new StringReader( + "Spell ID,Name,Flags [Hex]\n42,Strength Test,0x4\n"))); + int movementStatsUpdated = 0; + + var router = new LiveSessionEventRouter( + session, + NoOpEntitySink(), + NoOpEnvironmentSink(), + new LiveInventorySessionBindings( + objects, + PlayerGuid: () => playerGuid, + OnShortcuts: null, + OnUseDone: null, + ItemMana: new ItemManaState(), + ExternalContainers: new ExternalContainerState()), + new LiveCharacterSessionBindings( + new CombatState(), + character, + ResolveSkillFormulaBonus: null, + OnSkillsUpdated: null, + OnConfirmationRequest: null, + OnConfirmationDone: null, + ClientTime: () => 0d, + OnMovementStatsUpdated: () => movementStatsUpdated++), + NewSocialBindings()); + + router.Attach(); + character.LocalPlayer.OnAttributeUpdate( + atType: 1u, ranks: 90u, start: 10u, xp: 0u); + var props = new PropertyBundle(); + props.Ints[(uint)PropertyInt.EncumbranceVal] = 16500; + objects.UpsertProperties(playerGuid, props); + Assert.Equal(1.1f, character.MovementSkills.Burden, precision: 4); + + int beforeBuff = movementStatsUpdated; + character.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord( + SpellId: 42u, + LayerId: 1u, + Duration: 60d, + CasterGuid: playerGuid, + StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute, + StatModKey: 1u, + StatModValue: 1.2f, + Bucket: 1u)); + + Assert.Equal(120, character.LocalPlayer.GetEffectiveAttribute( + LocalPlayerState.AttributeKind.Strength)); + Assert.Equal(16500f / 18000f, character.MovementSkills.Burden, precision: 4); + Assert.Equal(beforeBuff + 1, movementStatsUpdated); + + int beforePurge = movementStatsUpdated; + character.Spellbook.OnPurgeAll(); + + Assert.Equal(100, character.LocalPlayer.GetEffectiveAttribute( + LocalPlayerState.AttributeKind.Strength)); + Assert.Equal(1.1f, character.MovementSkills.Burden, precision: 4); + Assert.Equal(beforePurge + 1, movementStatsUpdated); + + router.Dispose(); + } + [Fact] public void ObjectTablePropertyChange_RecomputesMovementSkillAugmentations() { From 4dd40ad8fe1ba3a5a5c38ea13ca0cea191425944 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 10:20:48 +0200 Subject: [PATCH 08/73] docs(physics): close Campaign P visual matrix --- docs/ISSUES.md | 56 ++++++++++++++++++- docs/plans/2026-04-11-roadmap.md | 8 ++- docs/plans/2026-05-12-milestones.md | 6 +- .../2026-07-29-physics-parity-campaign.md | 12 +++- ...2026-07-30-physics-parity-visual-matrix.md | 21 +++++++ 5 files changed, 94 insertions(+), 9 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 3dc8f9a3..bfe40dfd 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -35,11 +35,12 @@ What does NOT go here: [`docs/architecture/code-structure.md`](architecture/code-structure.md). - **Active M4 prelude:** resume [`plans/2026-07-23-world-interaction-completion.md`](plans/2026-07-23-world-interaction-completion.md). - Slices 1–3, including the complete assessment surface, are user-accepted. - Equipped-child picking and vendor browse/transactions remain Slices 4–6. + Slices 1–4, including equipped-child picking, are user-accepted. Vendor + browsing and authoritative transactions remain Slices 5–6. - **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its connected performance, lifetime, and unattended portal routes pass. -- **Carried behavior debt:** `#116` slide response (Campaign P P2 scope; +- **Carried behavior debt:** `#116` slide response, `#273` tight-gap + collision clearance, and deferred restricted-house gate `#274`; `#153` closed 2026-07-30 on the AD-30 hold + arrival StopCompletely + canonical outbound + reveal-barrier evidence chain). TS-50/TS-51/TS-53 are tracked in the divergence register. @@ -98,6 +99,55 @@ Copy this block when adding a new issue: --- +## #274 — Restricted/barred-house entry needs a connected retail comparison + +**Status:** OPEN — explicitly deferred by the user on 2026-07-31 +**Severity:** LOW (validation debt; no confirmed failure) +**Filed:** 2026-07-31 +**Component:** physics / EnvCell entry restrictions + +**Description:** Campaign P ported retail +`CObjCell::check_entry_restrictions` and retired AP-71, but the final +connected barred-house scenario was not run. The user requested that this +gate be deferred and retained as an issue rather than block current work. + +**Acceptance:** at a known restricted house, use equivalent characters in +retail and acdream. Both must reject an unauthorized character at the same +threshold, while an owner/guest enters normally. Record the house/cell, +character access state, and result before closing. + +--- + +## #273 — ACDream can squeeze through tight world gaps that block retail + +**Status:** OPEN — live mismatch confirmed 2026-07-31; exact location/capture +still required +**Severity:** MEDIUM (world traversal differs from retail) +**Filed:** 2026-07-31 +**Component:** physics / player collision shape and cell collision + +**Symptom:** in some tight world-geometry passages, acdream can pass through +a gap that blocks the retail client. The Campaign P wall/corner slide, +crowd, remote movement, door, portal, and general collision checks otherwise +passed. + +**Scope:** this is not folded into #116, which tracks a specific +near-perpendicular slide-response/fixture family. The new symptom is +under-blocking or clearance divergence and may involve the active player +sphere list, scale/pose, candidate-cell collision set, or a missing +world-collision primitive. + +**Next evidence:** record one exact reproducible location, heading, movement +input, character scale/equipment, and cell ID in both clients. Capture the +acdream transition path and active Setup-derived sphere list before changing +collision math. + +**Acceptance:** the captured tight gap blocks or permits traversal at the same +clearance as retail without regressing normal doorways, stairs, wall grazing, +or crowd movement. + +--- + ## #272 — Strength enchantments do not invalidate burden **Status:** DONE — 2026-07-31 (implementation, automated gates, and user live diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 39561d28..a00872c4 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -35,7 +35,7 @@ N0–N6 with a permanent loss-injection gate at N5 and a user Coldeve endurance session as final acceptance. The plan is [`2026-07-29-network-transport-campaign.md`](2026-07-29-network-transport-campaign.md). -**Campaign P — physics retail-feel parity (ACTIVE, started 2026-07-29):** +**Campaign P — physics retail-feel parity (CLOSED 2026-07-31):** user-directed pre-vendor detour closing every physics-scope gap the 2026-07-29 audit found: stat-coupled movement (burden/stamina/vitae → run/jump), the collision response-layer edge family (friction gate, @@ -57,7 +57,11 @@ The matrix then exposed #272: burden was invalidated by base Strength but not by Strength enchantment add/purge. Runtime movement plus both retained burden surfaces now use effective Strength and the canonical enchantment-change edge; automated gates pass and the connected buff/death gate was user-accepted on -2026-07-31. Campaign P then continues with the unfinished live matrix rows. +2026-07-31. The final session also accepted burden/exhaustion, wall/corner, +crowd, two-client remote/door/portal, and shallow-water behavior. The user +waived the general sweep and explicitly deferred the barred-house gate as +#274. A separate tight-gap clearance mismatch is carried as #273 pending an +exact-location capture. World-interaction Slice 5 vendor browsing resumes. --- diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 5b4b6e0e..0044ae4b 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -87,8 +87,10 @@ program: spell-bar overflow, status Use/Assess, assessment information, equipped-child picking, vendor browsing, and authoritative vendor transactions. This is deliberately using the extracted interaction owners and canonical shared main-panel host before quest/emote/character-creation bodies -broaden the feature surface. Slices 1–3 are user-accepted; resume at Slice 4 -equipped-child picking. +broaden the feature surface. Slices 1–4 are user-accepted. Campaign P closed +on 2026-07-31 with tight-gap collision clearance (#273) and the deferred +restricted-house gate (#274) explicitly carried; resume at Slice 5 vendor +browsing. The separately authorized modern-runtime performance program has completed Slices A–D: corrected measurement, prepared-package bake/dedup, package-only diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index 2a204afa..caa443cc 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -1,5 +1,9 @@ # Campaign P — Physics Retail-Feel Parity +**Status:** CLOSED 2026-07-31 — final user matrix accepted; tight-gap +clearance issue #273 and deferred restricted-house gate #274 are explicitly +carried follow-ups. + **Filed:** 2026-07-29. **Directed by the user** as a pre-vendor-management detour after the same-day physics/collision retail-fidelity audit. The world-interaction program (Slice 5, vendor browsing) resumes when this @@ -411,7 +415,11 @@ root-caused, retail-ported, and user-accepted in the same session: Matrix rows accepted so far: speed parity, roof slide, downhill bounce, flat pop, uphill landing, and #269's slope-stop feel -(rows 3/4/5-partial/12-partial). Remaining rows: 1, 2, 6, 7, 8, 9, 10, -and 11. The #269 checkpoint passes 4,107 Core tests / 2 skips and 439 +(rows 3/4/5-partial/12-partial). The 2026-07-31 final session accepted +burdened movement, exhausted jumping, wall/corner response, crowded-monster +movement, two-client remote/door/portal behavior, and shallow water. The user +waived the general sweep, deferred restricted-house validation as #274, and +retained the separate tight-gap clearance mismatch as #273. Automated +scenario 11 remains 20/20 passing. The #269 checkpoint passes 4,107 Core tests / 2 skips and 439 Runtime tests / 0 skips; the complete Release suite passes 10,061 tests / 5 skips / 0 failures. diff --git a/docs/plans/2026-07-30-physics-parity-visual-matrix.md b/docs/plans/2026-07-30-physics-parity-visual-matrix.md index 68341762..9fbf3b61 100644 --- a/docs/plans/2026-07-30-physics-parity-visual-matrix.md +++ b/docs/plans/2026-07-30-physics-parity-visual-matrix.md @@ -72,3 +72,24 @@ manual halves of 11-12. threading at a specific site, or the #116 head-sphere change — both P3/final-slice deltas). - Other scenarios: not yet reported. + +## User matrix session 2 results (2026-07-31) + +- Scenario 1 (burdened movement): **PASS**. +- Scenario 2 (exhausted jumping): **PASS**. +- Scenario 6 (wall graze/corner movement): **PASS**. +- Scenario 7 (crowded-monster movement): **PASS**. +- Scenario 8 (two-client remote movement, doors, portals, and its collision + checks): **PASS**. +- Scenario 10 (shallow-water sink-in): **PASS**. +- Scenario 9 (restricted/barred house): **DEFERRED BY USER** and retained as + issue #274. +- Scenario 12 (general movement sweep): **WAIVED BY USER**; the accepted + focused rows and existing automated soak are sufficient for this campaign. +- A separate live mismatch remains: acdream can squeeze through some tight + gaps that block retail. This is outside the accepted wall-graze response + check and is retained as issue #273 pending an exact-location capture. + +Together with the previously accepted scenarios 3–5 and the automated +20-login scenario 11, the Campaign P matrix is closed with #273 and #274 as +explicit carried follow-ups. From c24bc571cfb78519197b94ebc0220afbdb751c0d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 12:10:03 +0200 Subject: [PATCH 09/73] fix(physics): enforce retail step-down support radius (#273) --- .../retail-divergence-register.md | 2 +- .../2026-07-31-issue273-tight-gap-support.md | 95 + src/AcDream.Core/Physics/BSPQuery.cs | 36 +- src/AcDream.Core/Physics/TransitionTypes.cs | 43 +- .../Fixtures/issue273/0x01000F69.gfxobj.json | 1626 +++++++++++++++++ .../Issue185OutdoorStairsSeamReplayTests.cs | 18 +- .../Issue273HoltburgTightGapReplayTests.cs | 259 +++ tools/A8CellAudit/Program.cs | 37 +- tools/SetupInspect/Program.cs | 25 +- 9 files changed, 2117 insertions(+), 24 deletions(-) create mode 100644 docs/research/2026-07-31-issue273-tight-gap-support.md create mode 100644 tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json create mode 100644 tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f68e1b5e..6ebad661 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -128,7 +128,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. |---|---|---|---|---|---| | AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position | -| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 | +| AP-3 | Step-down chain also runs for a valid contact plane when that plane is steeper than walkable; retail's `transitional_insert` OK-path returns immediately for every valid contact plane and enters the step-down tail only when contact is invalid | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`) | The added steep-contact entry preserves the current cliff-slide compensation while the response-layer state/order family remains open (AP-4/AD-53/AD-54/TS-4) | A steep valid contact can enter step-down/edge response where retail restores or validates state through its normal contact path, producing different retry and slide behavior | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | | AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) | | AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | | ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` | diff --git a/docs/research/2026-07-31-issue273-tight-gap-support.md b/docs/research/2026-07-31-issue273-tight-gap-support.md new file mode 100644 index 00000000..7ee1c305 --- /dev/null +++ b/docs/research/2026-07-31-issue273-tight-gap-support.md @@ -0,0 +1,95 @@ +# Issue #273 — Holtburg tight-gap support validation + +**Date:** 2026-07-31 +**Status:** implementation, automated gates, and exact live gate pass +**Scope:** grounded player step-down support at a floor edge beside a static +cylinder + +## Captured scene + +The reproducible gap is in outdoor cell `0xA9B40032`, between: + +- building shell GfxObj `0x01000F69`, placed at + `(158.178, 37.7055, 94.0)` with quaternion + `(w=.939319, x=0, y=0, z=-.343045)`; +- static post `0xCA9B4027`, placed at `(160.173, 34.487, 95.975)`, + represented by its Setup-authored cylinder (`radius=.282`, + `height=5.564`); +- the local player Setup's exact two spheres (`radius=.48`, origins + `z=.475` and `z=1.35`). + +The building's supporting ledge terminates at local `x=4`. The first +post-side response moved the player's foot-sphere center to approximately +local `x=4.33`. The full `.48` movement sphere still overlapped the floor, so +the existing step-down path accepted the candidate. Repeated frames then +carried the player around the post and outside the building shell. + +The fixture +`tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json` preserves +the installed DAT PhysicsBSP. The replay in +`Issue273HoltburgTightGapReplayTests` uses the captured object placement, +player spheres, static posts, and movement offsets. + +## Retail mechanism + +The missing rule is not extra collision padding and is not a larger player +sphere. It is retail's second-stage support validation: + +1. `CTransition::step_down` (`0x0050B2A0`) performs the ordinary downward + collision probe. +2. After finding a walkable contact plane, an EdgeSlide mover that is not in + StepUp calls `CTransition::check_walkable` (`0x0050AFF0`). The binary + sequence is `test ah,2` at `0x0050B36A`, which is state bit `0x200` + (`EdgeSlide`), followed by the `step_up == 0` test and call at + `0x0050B380`. +3. `CTransition::check_walkable` first calls + `SPHEREPATH::check_walkables` (`0x0050C3E0`). +4. `SPHEREPATH::check_walkables` halves the saved foot-sphere radius and + calls `CPolygon::check_walkable` (`0x00538E60`). +5. If the remembered polygon does not support that smaller sphere, + `CTransition::check_walkable` performs a downward CheckWalkable insertion. + BSP leaves require both `walkable_hits_sphere` and + `CPolygon::check_small_walkable` (`BSPLEAF::hits_walkable`, + `0x0053D670`). +6. If neither check finds support, `CTransition::step_down` rejects the + candidate and the existing edge-response chain handles it. + +ACDream already had the small-radius BSP-leaf test, but +`DoCheckWalkable` treated the mere presence of a remembered polygon as +success, and the ordinary `DoStepDown(..., runPlacement:false)` path never +called it. This let a full-radius overlap stand in for actual foot support. + +## Port + +- `BSPQuery.CheckWalkableSupport` is the shared resolved-polygon form of + retail `CPolygon::check_walkable`. +- `SpherePath.CheckWalkables` implements the retail half-radius remembered + polygon check without mutating canonical sphere state. +- `Transition.DoCheckWalkable` now tests the remembered polygon rather than + treating a non-null polygon as sufficient. +- `Transition.DoStepDown` restores the EdgeSlide/non-StepUp support gate + before the existing placement-policy seam. + +There are no location checks, object IDs, guessed radii, widened collision +shapes, or gap-specific tolerances in the production fix. + +## Regression impact + +The existing #271 staircase-side replay begins with its center `.288 m` +outside a tread whose retail half-radius support boundary is `.24 m`. +Retail may therefore stop that exact candidate. The test now preserves the +original user-visible invariant—never reverse or accelerate downhill—without +requiring forward progress beyond retail's support boundary. The ordinary +continuous staircase replay still requires and achieves forward progress. + +## Gates + +- issue #273 fixture/replay: 3 passed; +- focused BSP, step-up, edge-slide, #185/#271 family: 42 passed / 1 skipped; +- complete Core tests: 4,111 passed / 2 skipped; +- Release solution build: passed; +- complete Release solution tests: 10,068 passed / 5 skipped. + +The user accepted the exact in-client Holtburg gap gate on 2026-07-31: the +gap blocks from the tested approach, and the adjacent movement checks remain +healthy. diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 7e314906..2d08eb6e 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -284,28 +284,46 @@ public static class BSPQuery CollisionSphere sphere, Vector3 up, bool small) + => CheckWalkableSupport( + poly.Plane, + poly.Vertices, + sphere.Center, + small ? sphere.Radius * 0.5f : sphere.Radius, + up); + + /// + /// Retail CPolygon::check_walkable against an already resolved + /// polygon. The caller supplies the effective support radius; retail's + /// SPHEREPATH::check_walkables halves its saved sphere before + /// entering this routine. + /// + internal static bool CheckWalkableSupport( + Plane plane, + ReadOnlySpan vertices, + Vector3 center, + float supportRadius, + Vector3 up) { - float angleUp = Vector3.Dot(poly.Plane.Normal, up); + float angleUp = Vector3.Dot(plane.Normal, up); if (angleUp < PhysicsGlobals.EPSILON) return false; - float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp; - var center = sphere.Center - up * angle; + float angle = (Vector3.Dot(plane.Normal, center) + plane.D) / angleUp; + center -= up * angle; - float radsum = sphere.Radius * sphere.Radius; - if (small) radsum *= 0.25f; + float radsum = supportRadius * supportRadius; - int n = poly.Vertices.Length; + int n = vertices.Length; int prevIdx = n - 1; for (int i = 0; i < n; i++) { - var v = poly.Vertices[i]; - var lv = poly.Vertices[prevIdx]; + var v = vertices[i]; + var lv = vertices[prevIdx]; prevIdx = i; var edge = v - lv; var disp = center - lv; - var cross = Vector3.Cross(poly.Plane.Normal, edge); + var cross = Vector3.Cross(plane.Normal, edge); float diff = Vector3.Dot(disp, cross); if (diff < 0f) diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index b3f9cf81..1f2a1de2 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -850,6 +850,27 @@ public sealed class SpherePath return true; } + /// + /// Retail SPHEREPATH::check_walkables (0x0050C3E0). A missing + /// remembered polygon passes; otherwise the foot sphere is tested against + /// that polygon with half its normal radius. Retail mutates an embedded + /// scratch sphere before the test. Our world-space representation can pass + /// the equivalent effective radius without mutating canonical sphere state. + /// + internal bool CheckWalkables() + { + if (!HasWalkablePolygon || WalkableVertices is null) + return true; + + var footSphere = GlobalSphere[0]; + return BSPQuery.CheckWalkableSupport( + WalkablePlane, + WalkableVertices, + footSphere.Origin, + footSphere.Radius * 0.5f, + WalkableUp); + } + /// /// Retail SPHEREPATH::init reset for a retained transition record. /// Every logical value is restored; only the two private exact-length @@ -5211,6 +5232,22 @@ public sealed class Transition && CollisionInfo.ContactPlaneValid && CollisionInfo.ContactPlane.Normal.Z >= walkableZ) { + // Retail CTransition::step_down (0x0050B2A0) validates the + // candidate's actual support before placement whenever an + // already-grounded mover is not performing a step-up. The first + // check uses SPHEREPATH::check_walkables' half-radius foot sphere; + // when that fails, DoCheckWalkable performs the downward BSP + // probe with the same small-support rule. Omitting this gate let a + // wall/post slide leave most of the player sphere beyond a floor + // edge while the full-radius step-down overlap still counted as + // grounded (issue #273). + if (ObjectInfo.EdgeSlide + && !sp.StepUp + && !DoCheckWalkable(walkableZ, engine)) + { + return false; + } + // L.2.3h (2026-04-29): Placement validation is for the // DoStepUp use case (prevents climbing through walls by // stepping up onto ground beyond a tall wall). For the @@ -5461,8 +5498,10 @@ public sealed class Transition if ((oi.State & ObjectInfoState.OnWalkable) == 0) return true; - // If the current walkable entry is still valid, skip the probe. - if (sp.WalkableValid) + // Retail first validates the remembered polygon with a half-radius + // support sphere. Merely having a polygon pointer is insufficient: + // the candidate may already hang too far beyond its edge. + if (sp.CheckWalkables()) return true; sp.SaveCheckPos(); diff --git a/tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json b/tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json new file mode 100644 index 00000000..bd0deee2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json @@ -0,0 +1,1626 @@ +{ + "GfxObjId": 16781161, + "BoundingSphereOrigin": { + "X": -0.270307, + "Y": 0.579602, + "Z": 4.34457 + }, + "BoundingSphereRadius": 9.92391, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.2503828, + "Y": 0, + "Z": 0.968147 + }, + "D": -9.729877 + }, + "Vertices": [ + { + "X": 4.64, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": 2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 10.05 + }, + { + "X": 2.32, + "Y": -5.8396, + "Z": 9.45 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.28 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": -4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": -4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": 4, + "Y": 7.28, + "Z": 8.05 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.2 + }, + "Vertices": [ + { + "X": -4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": 4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": 4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": -4, + "Y": -5.2, + "Z": 8.05 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1.3701269E-05, + "Y": -0.00013341238, + "Z": 1 + }, + "D": -1.9990327 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.25, + "Z": 2 + }, + { + "X": -4, + "Y": 7.25, + "Z": 2 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 1.9998 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 2 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.00012937472, + "Y": 9.773888E-06, + "Z": 1 + }, + "D": -1.9994949 + }, + "Vertices": [ + { + "X": -4, + "Y": -4.7, + "Z": 2 + }, + { + "X": -3.3, + "Y": -2.01574, + "Z": 2 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 1.9998 + }, + { + "X": -4, + "Y": 7.25, + "Z": 2 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": -2 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.25, + "Z": 2 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 2 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 2 + }, + { + "X": 4, + "Y": -4.7, + "Z": 2 + } + ] + }, + { + "Id": 6, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": -2 + }, + "Vertices": [ + { + "X": -3.3, + "Y": -2.01574, + "Z": 2 + }, + { + "X": -4, + "Y": -4.7, + "Z": 2 + }, + { + "X": 1, + "Y": -2.01571, + "Z": 2 + } + ] + }, + { + "Id": 7, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": -2 + }, + "Vertices": [ + { + "X": 1, + "Y": -2.01571, + "Z": 2 + }, + { + "X": -4, + "Y": -4.7, + "Z": 2 + }, + { + "X": 4, + "Y": -4.7, + "Z": 2 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 2 + } + ] + }, + { + "Id": 8, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -4.1392085E-09, + "Z": -1 + }, + "D": 3.000926E-08 + }, + "Vertices": [ + { + "X": -4, + "Y": 7.25, + "Z": 0 + }, + { + "X": 4, + "Y": 7.25, + "Z": 0 + }, + { + "X": 4, + "Y": -7.15, + "Z": 5.96046E-08 + }, + { + "X": -4, + "Y": -7.15, + "Z": 5.96046E-08 + } + ] + }, + { + "Id": 9, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -0.63237643, + "Z": 0.77466124 + }, + "D": -4.5214915 + }, + "Vertices": [ + { + "X": -4, + "Y": -7.15, + "Z": 5.96046E-08 + }, + { + "X": 4, + "Y": -7.15, + "Z": 5.96046E-08 + }, + { + "X": 4, + "Y": -4.7, + "Z": 2 + }, + { + "X": -4, + "Y": -4.7, + "Z": 2 + } + ] + }, + { + "Id": 10, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": -4, + "Y": -4.7, + "Z": 2 + }, + { + "X": -4, + "Y": 7.25, + "Z": 2 + }, + { + "X": -4, + "Y": 7.25, + "Z": 0 + } + ] + }, + { + "Id": 11, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.25, + "Z": 0 + }, + { + "X": 4, + "Y": 7.25, + "Z": 2 + }, + { + "X": 4, + "Y": -4.7, + "Z": 2 + } + ] + }, + { + "Id": 12, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.25 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.25, + "Z": 2 + }, + { + "X": 4, + "Y": 7.25, + "Z": 0 + }, + { + "X": -4, + "Y": 7.25, + "Z": 0 + }, + { + "X": -4, + "Y": 7.25, + "Z": 2 + } + ] + }, + { + "Id": 13, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.9196 + }, + "Vertices": [ + { + "X": 2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": -2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 0, + "Y": 7.9196, + "Z": 10.05 + } + ] + }, + { + "Id": 14, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.9196 + }, + "Vertices": [ + { + "X": -2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 0, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": -4.64, + "Y": 7.9196, + "Z": 8.85 + } + ] + }, + { + "Id": 15, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.9196 + }, + "Vertices": [ + { + "X": 0, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": 2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 4.64, + "Y": 7.9196, + "Z": 8.85 + } + ] + }, + { + "Id": 16, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.9196 + }, + "Vertices": [ + { + "X": 2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 0, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": -2.32, + "Y": 7.9196, + "Z": 9.45 + } + ] + }, + { + "Id": 17, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.8396 + }, + "Vertices": [ + { + "X": -2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": 2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 10.05 + } + ] + }, + { + "Id": 18, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.8396 + }, + "Vertices": [ + { + "X": 2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": 4.64, + "Y": -5.8396, + "Z": 8.85 + } + ] + }, + { + "Id": 19, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.8396 + }, + "Vertices": [ + { + "X": 0, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": -2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": -4.64, + "Y": -5.8396, + "Z": 8.85 + } + ] + }, + { + "Id": 20, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.8396 + }, + "Vertices": [ + { + "X": -2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": 2.32, + "Y": -5.8396, + "Z": 9.45 + } + ] + }, + { + "Id": 21, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -0.5302367, + "Z": -0.84784967 + }, + "D": 4.067959 + }, + "Vertices": [ + { + "X": -4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": 4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": 4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": -4.62, + "Y": -5.8396, + "Z": 8.45 + } + ] + }, + { + "Id": 22, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.5421266, + "Y": 0, + "Z": -0.8402968 + }, + "D": 4.595883 + }, + "Vertices": [ + { + "X": 4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": 4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": 4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": 4.62, + "Y": -5.8396, + "Z": 8.45 + } + ] + }, + { + "Id": 23, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": -3.3 + }, + "Vertices": [ + { + "X": -3.3, + "Y": -2.01574, + "Z": 7.55 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 7.55 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 1.9998 + }, + { + "X": -3.3, + "Y": -2.01574, + "Z": 2 + } + ] + }, + { + "Id": 24, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -3.3 + }, + "Vertices": [ + { + "X": 3.3, + "Y": 6.5003, + "Z": 2 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 2 + } + ] + }, + { + "Id": 25, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 6.0688367E-06, + "Y": 1, + "Z": 0 + }, + "D": -6.50032 + }, + "Vertices": [ + { + "X": 3.3, + "Y": 6.5003, + "Z": 2 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 1.9998 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 7.55 + } + ] + }, + { + "Id": 26, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": -4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": -4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": -4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": -4, + "Y": 7.28, + "Z": 8.05 + } + ] + }, + { + "Id": 27, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": 4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": 4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": 4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": 4, + "Y": -5.2, + "Z": 8.05 + } + ] + }, + { + "Id": 28, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.2503826, + "Y": 0, + "Z": 0.968147 + }, + "D": -9.7298765 + }, + "Vertices": [ + { + "X": 2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": 4.64, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": 4.64, + "Y": 7.9196, + "Z": 8.85 + } + ] + }, + { + "Id": 29, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -0.2503828, + "Y": 0, + "Z": 0.968147 + }, + "D": -9.729877 + }, + "Vertices": [ + { + "X": -4.64, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": -4.64, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 10.05 + }, + { + "X": 0, + "Y": 7.9196, + "Z": 10.05 + } + ] + }, + { + "Id": 30, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0.530237, + "Z": -0.8478495 + }, + "D": 2.965063 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": -4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": -4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": 4.62, + "Y": 7.9196, + "Z": 8.45 + } + ] + }, + { + "Id": 31, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -0.9987523, + "Y": 0, + "Z": -0.049937498 + }, + "D": -4.1922636 + }, + "Vertices": [ + { + "X": -4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": -4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": -4.64, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": -4.64, + "Y": 7.9196, + "Z": 8.85 + } + ] + }, + { + "Id": 32, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.9196 + }, + "Vertices": [ + { + "X": 4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": -4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": -4.64, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": 4.64, + "Y": 7.9196, + "Z": 8.85 + } + ] + }, + { + "Id": 33, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.9987523, + "Y": 0, + "Z": -0.049937498 + }, + "D": -4.1922636 + }, + "Vertices": [ + { + "X": 4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": 4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": 4.64, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": 4.64, + "Y": -5.8396, + "Z": 8.85 + } + ] + }, + { + "Id": 34, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.8396 + }, + "Vertices": [ + { + "X": -4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": 4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": 4.64, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": -4.64, + "Y": -5.8396, + "Z": 8.85 + } + ] + }, + { + "Id": 35, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -0.5421266, + "Y": 0, + "Z": -0.8402968 + }, + "D": 4.595883 + }, + "Vertices": [ + { + "X": -4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": -4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": -4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": -4.62, + "Y": 7.9196, + "Z": 8.45 + } + ] + }, + { + "Id": 36, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 4.3537307E-06, + "Y": -1, + "Z": 0 + }, + "D": -2.0157256 + }, + "Vertices": [ + { + "X": -3.3, + "Y": -2.01574, + "Z": 2 + }, + { + "X": -1, + "Y": -2.01573, + "Z": 2 + }, + { + "X": -1, + "Y": -2.01573, + "Z": 5.528 + }, + { + "X": -3.3, + "Y": -2.01574, + "Z": 7.55 + } + ] + }, + { + "Id": 37, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 7.871597E-06, + "Y": -1, + "Z": 2.000765E-06 + }, + "D": -2.0157309 + }, + "Vertices": [ + { + "X": 1, + "Y": -2.01571, + "Z": 7.55 + }, + { + "X": -3.3, + "Y": -2.01574, + "Z": 7.55 + }, + { + "X": -1, + "Y": -2.01573, + "Z": 5.528 + }, + { + "X": 1, + "Y": -2.01571, + "Z": 5.528 + } + ] + }, + { + "Id": 38, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 4.3537307E-06, + "Y": -1, + "Z": 0 + }, + "D": -2.0157144 + }, + "Vertices": [ + { + "X": 1, + "Y": -2.01571, + "Z": 5.528 + }, + { + "X": 1, + "Y": -2.01571, + "Z": 2 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 2 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + } + ] + }, + { + "Id": 39, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.55 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 7.55 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 7.55 + }, + { + "X": -4, + "Y": 7.28, + "Z": 7.55 + } + ] + }, + { + "Id": 40, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.5500007 + }, + "Vertices": [ + { + "X": -3.3, + "Y": 6.50034, + "Z": 7.55 + }, + { + "X": -3.3, + "Y": -2.01574, + "Z": 7.55 + }, + { + "X": -4, + "Y": -5.2, + "Z": 7.55 + } + ] + }, + { + "Id": 41, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.5500007 + }, + "Vertices": [ + { + "X": -3.3, + "Y": 6.50034, + "Z": 7.55 + }, + { + "X": -4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": -4, + "Y": 7.28, + "Z": 7.55 + } + ] + }, + { + "Id": 42, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.55 + }, + "Vertices": [ + { + "X": -3.3, + "Y": -2.01574, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + }, + { + "X": 4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": -4, + "Y": -5.2, + "Z": 7.55 + } + ] + }, + { + "Id": 43, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.5500007 + }, + "Vertices": [ + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 7.55 + }, + { + "X": 4, + "Y": 7.28, + "Z": 7.55 + } + ] + }, + { + "Id": 44, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.5500007 + }, + "Vertices": [ + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + }, + { + "X": 4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": 4, + "Y": -5.2, + "Z": 7.55 + } + ] + }, + { + "Id": 45, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 4.3537307E-06, + "Y": -1, + "Z": 0 + }, + "D": -2.0157144 + }, + "Vertices": [ + { + "X": 1, + "Y": -2.01571, + "Z": 5.528 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + }, + { + "X": 1, + "Y": -2.01571, + "Z": 7.55 + } + ] + }, + { + "Id": 46, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": -4, + "Y": 7.25, + "Z": 0 + }, + { + "X": -4, + "Y": -7.15, + "Z": 5.96046E-08 + }, + { + "X": -4, + "Y": -4.7, + "Z": 2 + } + ] + }, + { + "Id": 47, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": 4, + "Y": -4.7, + "Z": 2 + }, + { + "X": 4, + "Y": -7.15, + "Z": 5.96046E-08 + }, + { + "X": 4, + "Y": 7.25, + "Z": 0 + } + ] + }, + { + "Id": 48, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.250383, + "Y": 0, + "Z": 0.96814686 + }, + "D": -9.7298765 + }, + "Vertices": [ + { + "X": 2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 0, + "Y": 7.9196, + "Z": 10.05 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 10.05 + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs index 9ee07592..de6bf12b 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs @@ -179,12 +179,18 @@ public class Issue185OutdoorStairsSeamReplayTests /// /// #271 live capture, quantum 310: a forward/uphill displacement that also - /// presses into the staircase's side wall must keep its uphill tangent. - /// Pre-fix the composite retry path reversed that tangent, moving from - /// Y=75.539 to Y=75.199 and rapidly carrying the player back down the stairs. + /// presses into the staircase's side wall must never reverse downhill. + /// Pre-fix the composite retry path moved from Y=75.539 to Y=75.199 and + /// rapidly carried the player back down the stairs. + /// + /// The captured center is 0.288 m beyond the tread's side edge. Retail's + /// SPHEREPATH::check_walkables uses a 0.24 m half-radius support sphere, so + /// stopping at this exact side-wall position is valid; advancing farther + /// uphill is not. The regression invariant is therefore no downhill motion, + /// not mandatory forward progress beyond retail's support boundary. /// [Fact] - public void OutdoorStairs_SideWallContact_DoesNotReverseUphillTangent() + public void OutdoorStairs_SideWallContact_DoesNotReverseDownhill() { var engine = BuildStairEngine(); var body = GroundedOnTread(); @@ -211,8 +217,8 @@ public class Issue185OutdoorStairsSeamReplayTests $"collision={result.CollisionNormalValid} " + $"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})"); - Assert.True(result.Position.Y > body.Position.Y + 0.25f, - $"Side-wall response failed to preserve meaningful uphill motion: " + + Assert.True(result.Position.Y >= body.Position.Y - 0.001f, + $"Side-wall response reversed the intended uphill motion: " + $"{body.Position.Y:F6} -> {result.Position.Y:F6}."); Assert.True(result.Position.Z >= body.Position.Z - 0.001f, $"Side-wall response dropped the grounded player downhill: " + diff --git a/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs new file mode 100644 index 00000000..66abfcc7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Immutable; +using System.IO; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Issue #273 — exact Holtburg tight-gap replay captured live on 2026-07-31. +/// The player presses between building shell 0x01000F69 and the timber post +/// at 0xCA9B4027. Retail blocks this passage; before the fix ACDream lets the +/// post slide feed a displaced step-down probe into precipice-slide, which +/// carries the player around the post and along the building's outer edge. +/// +/// The shell collision is a self-contained dump of the installed DAT's real +/// PhysicsBSP. Post dimensions, building frame, player Setup spheres, and +/// movement frames are copied from the live trace. +/// +public sealed class Issue273HoltburgTightGapReplayTests +{ + private readonly ITestOutputHelper _output; + + public Issue273HoltburgTightGapReplayTests(ITestOutputHelper output) + => _output = output; + + private const uint Landblock = 0xA9B40000u; + private const uint Cell = 0xA9B40032u; + private const uint ShellGfxObj = 0x01000F69u; + private const uint PlayerEntity = 0x000F4243u; + + private static readonly Vector3 BuildingOrigin = new(158.178f, 37.7055f, 94f); + private static readonly Quaternion BuildingRotation = + Quaternion.Normalize(new Quaternion(0f, 0f, -0.343045f, 0.939319f)); + private static readonly Matrix4x4 BuildingTransform = + Matrix4x4.CreateFromQuaternion(BuildingRotation) + * Matrix4x4.CreateTranslation(BuildingOrigin); + + private static readonly ImmutableArray PlayerSpheres = + [ + new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), 0.480f), + new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.480f), + ]; + + private static PhysicsEngine BuildEngine() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + string dumpPath = Path.Combine( + SolutionRoot(), + "tests", + "AcDream.Core.Tests", + "Fixtures", + "issue273", + "0x01000F69.gfxobj.json"); + Assert.True(File.Exists(dumpPath), $"Missing issue #273 fixture: {dumpPath}"); + cache.RegisterGfxObjForTest( + ShellGfxObj, + GfxObjDumpSerializer.Hydrate(GfxObjDumpSerializer.Read(dumpPath))); + + // The shell is registered through retail's building channel, not as a + // shadow object. Its one portal is irrelevant to this exterior sweep. + cache.CacheBuilding( + Cell, + Array.Empty(), + BuildingTransform, + ShellGfxObj); + + // Terrain is deliberately below the shell. The player stands on the + // shell's authored z=2 ledge (world z=96), not synthetic terrain. + var heights = new byte[81]; + var heightTable = new float[256]; + Array.Fill(heightTable, -1000f); + engine.AddLandblock( + Landblock, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + + RegisterPost( + engine, + 0xCA9B4027u, + new Vector3(160.173f, 34.487f, 95.975f), + radius: 0.282f); + RegisterPost( + engine, + 0xCA9B402Eu, + new Vector3(158.282f, 34.610f, 95.975f), + radius: 0.282f); + RegisterPost( + engine, + 0xCA9B402Fu, + new Vector3(157.952f, 32.239f, 96f), + radius: 0.600f); + + return engine; + } + + private static void RegisterPost( + PhysicsEngine engine, + uint entityId, + Vector3 basePosition, + float radius) + { + engine.ShadowObjects.Register( + entityId, + gfxObjId: 0u, + worldPos: basePosition, + rotation: Quaternion.Identity, + radius, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: Landblock, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 5.564f, + state: 0u, + seedCellId: Cell, + isStatic: true); + } + + private static PhysicsBody GroundedBody(Vector3 position) + { + Vector3[] localWalkable = + [ + new(4f, 7.25f, 2f), + new(3.3f, 6.5003f, 2f), + new(3.3f, -2.0157f, 2f), + new(4f, -4.7f, 2f), + ]; + var worldWalkable = new Vector3[localWalkable.Length]; + for (int i = 0; i < localWalkable.Length; i++) + worldWalkable[i] = Vector3.Transform(localWalkable[i], BuildingTransform); + + var floor = new Plane(Vector3.UnitZ, -96f); + return new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + ContactPlaneValid = true, + ContactPlane = floor, + ContactPlaneCellId = Cell, + WalkablePolygonValid = true, + WalkablePlane = floor, + WalkableUp = Vector3.UnitZ, + WalkableVertices = worldWalkable, + TransientState = + TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + }; + } + + [Theory] + [InlineData(1.239f, true)] + [InlineData(1.241f, false)] + public void RetailHalfRadiusSupport_RejectsCenterBeyondQuarterMeterEdge( + float centerX, + bool expected) + { + Vector3[] floor = + [ + new(0f, 0f, 0f), + new(1f, 0f, 0f), + new(1f, 1f, 0f), + new(0f, 1f, 0f), + ]; + + // The player's 0.48 m foot sphere becomes a 0.24 m support sphere in + // SPHEREPATH::check_walkables. Just inside that boundary is supported; + // just outside it is not, even though the full movement sphere still + // overlaps the floor polygon. + bool supported = BSPQuery.CheckWalkableSupport( + new Plane(Vector3.UnitZ, 0f), + floor, + new Vector3(centerX, 0.5f, 0.48f), + supportRadius: 0.24f, + Vector3.UnitZ); + + Assert.Equal(expected, supported); + } + + [Fact] + public void CapturedRun_DoesNotSqueezeBetweenPostAndBuilding() + { + PhysicsEngine engine = BuildEngine(); + Vector3 position = new(160.016f, 33.562f, 96.005f); + var body = GroundedBody(position); + uint cell = Cell; + + // First frame is copied verbatim from the live capture. Subsequent + // held-forward frames use the stable displacement visible in that + // same trace after input acceleration settles. + Vector3[] offsets = + [ + new(1.396f, 1.123f, 0f), + new(0.727f, 0.584f, 0f), + new(0.624f, 0.501f, 0f), + new(0.727f, 0.585f, 0f), + new(0.728f, 0.585f, 0f), + new(0.727f, 0.585f, 0f), + new(0.727f, 0.585f, 0f), + new(0.728f, 0.585f, 0f), + ]; + + for (int frame = 0; frame < offsets.Length; frame++) + { + ResolveResult result = engine.ResolveWithTransition( + currentPos: position, + targetPos: position + offsets[frame], + cellId: cell, + sphereRadius: 0.48f, + sphereHeight: 1.835f, + stepUpHeight: 0.6f, + stepDownHeight: 1.5f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: PlayerEntity, + sphereList: PlayerSpheres, + sphereScale: 1f); + + _output.WriteLine( + $"f{frame}: in=({position.X:F3},{position.Y:F3},{position.Z:F3}) " + + $"out=({result.Position.X:F3},{result.Position.Y:F3},{result.Position.Z:F3}) " + + $"hit={result.CollisionNormalValid} " + + $"normal=({result.CollisionNormal.X:F3}," + + $"{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})"); + + position = result.Position; + cell = result.CellId; + body.Position = position; + } + + // The captured broken run reached (163.234, 36.982) by this point, + // already beyond the post and sliding along the building. Retail + // blocks the passage before the player can cross the post's Y. + Assert.True( + position.Y < 34.487f, + $"Player squeezed through the retail-blocked gap: " + + $"final=({position.X:F3},{position.Y:F3},{position.Z:F3})."); + } + + private static string SolutionRoot() + { + string? directory = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(directory)) + { + if (File.Exists(Path.Combine(directory, "AcDream.slnx"))) + return directory; + directory = Path.GetDirectoryName(directory); + } + + throw new InvalidOperationException( + $"Could not locate AcDream.slnx from {AppContext.BaseDirectory}."); + } +} diff --git a/tools/A8CellAudit/Program.cs b/tools/A8CellAudit/Program.cs index d3948ac8..c9d3df60 100644 --- a/tools/A8CellAudit/Program.cs +++ b/tools/A8CellAudit/Program.cs @@ -323,9 +323,9 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo { uint lbPrefix = landblockId & 0xFFFF0000u; uint stabIdBase = 0xC0000000u - | (((landblockId >> 24) & 0xFFu) << 16) - | (((landblockId >> 16) & 0xFFu) << 8); - uint nextEntityId = stabIdBase + 1u; + | (((landblockId >> 24) & 0xFFu) << 20) + | (((landblockId >> 16) & 0xFFu) << 12); + uint nextEntityId = stabIdBase; int supportedObjects = 0; foreach (var obj in info.Objects) @@ -333,7 +333,13 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo if (!IsSupported(obj.Id)) continue; supportedObjects++; - nextEntityId++; + uint entityId = nextEntityId++; + Console.WriteLine( + $"objectOrdinal={supportedObjects} entity=0x{entityId:X8} " + + $"model=0x{obj.Id:X8} " + + $"pos=({obj.Frame.Origin.X:R},{obj.Frame.Origin.Y:R},{obj.Frame.Origin.Z:R}) " + + $"quat=({obj.Frame.Orientation.W:R},{obj.Frame.Orientation.X:R}," + + $"{obj.Frame.Orientation.Y:R},{obj.Frame.Orientation.Z:R})"); } Console.WriteLine( @@ -365,7 +371,9 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo Console.WriteLine( $"buildingOrdinal={zeroBased + 1} registryId={registryText} shellEntity=0x{shellEntityId:X8} " + - $"model=0x{building.ModelId:X8} pos=({building.Frame.Origin.X:F2},{building.Frame.Origin.Y:F2},{building.Frame.Origin.Z:F2}) " + + $"model=0x{building.ModelId:X8} pos=({building.Frame.Origin.X:R},{building.Frame.Origin.Y:R},{building.Frame.Origin.Z:R}) " + + $"quat=({building.Frame.Orientation.W:R},{building.Frame.Orientation.X:R}," + + $"{building.Frame.Orientation.Y:R},{building.Frame.Orientation.Z:R}) " + $"portalCells={portalText}"); } @@ -436,6 +444,25 @@ static void DumpGfxObj(DatCollection dats, uint gfxObjId) Console.WriteLine( $"classify: walls={walls} (outwardFacing={outwardWalls} inwardFacing={inwardWalls}) " + $"floors={floors} ceilings={ceilings} slopes={slopes}"); + Console.WriteLine("physics polygons:"); + foreach (var (polyId, poly) in g.PhysicsPolygons.OrderBy(p => p.Key)) + { + Vector3 polyMin = new(float.MaxValue); + Vector3 polyMax = new(float.MinValue); + foreach (ushort vertexId in poly.VertexIds.Select(id => (ushort)id)) + { + if (!g.VertexArray.Vertices.TryGetValue(vertexId, out var vertex)) + continue; + polyMin = Vector3.Min(polyMin, vertex.Origin); + polyMax = Vector3.Max(polyMax, vertex.Origin); + } + Vector3 normal = ComputeNormalG(g, poly); + Console.WriteLine( + $" poly=0x{polyId:X4} n=({normal.X:F3},{normal.Y:F3},{normal.Z:F3}) " + + $"min=({polyMin.X:F3},{polyMin.Y:F3},{polyMin.Z:F3}) " + + $"max=({polyMax.X:F3},{polyMax.Y:F3},{polyMax.Z:F3}) " + + $"sides={poly.SidesType} stip={poly.Stippling}"); + } Console.WriteLine(); } diff --git a/tools/SetupInspect/Program.cs b/tools/SetupInspect/Program.cs index c08c1225..54ea24cc 100644 --- a/tools/SetupInspect/Program.cs +++ b/tools/SetupInspect/Program.cs @@ -45,6 +45,26 @@ if (!dats.TryGet(setupId, out var setup) || setup is null) Console.WriteLine($"=== Setup 0x{setupId:X8} ==="); Console.WriteLine($"Flags = 0x{(uint)setup.Flags:X8}"); +Console.WriteLine($"Radius/Height = {setup.Radius:F3} / {setup.Height:F3}"); +Console.WriteLine($"StepUp/StepDown = {setup.StepUpHeight:F3} / {setup.StepDownHeight:F3}"); +Console.WriteLine($"Spheres = {setup.Spheres.Count}"); +for (int i = 0; i < setup.Spheres.Count; i++) +{ + Sphere sphere = setup.Spheres[i]; + Console.WriteLine( + $" sphere[{i}] origin=({sphere.Origin.X:R},{sphere.Origin.Y:R},{sphere.Origin.Z:R}) " + + $"radius={sphere.Radius:R} " + + $"radiusBits=0x{BitConverter.SingleToUInt32Bits(sphere.Radius):X8}"); +} +Console.WriteLine($"CylSpheres = {setup.CylSpheres.Count}"); +for (int i = 0; i < setup.CylSpheres.Count; i++) +{ + CylSphere cylinder = setup.CylSpheres[i]; + Console.WriteLine( + $" cyl[{i}] origin=({cylinder.Origin.X:R},{cylinder.Origin.Y:R},{cylinder.Origin.Z:R}) " + + $"radius={cylinder.Radius:R} height={cylinder.Height:R} " + + $"radiusBits=0x{BitConverter.SingleToUInt32Bits(cylinder.Radius):X8}"); +} Console.WriteLine($"Parts = {setup.Parts.Count}"); for (int i = 0; i < setup.Parts.Count; i++) { @@ -78,7 +98,10 @@ foreach (uint gfxId in setup.Parts.Select(p => (uint)p).Distinct()) Console.WriteLine( $" gfx=0x{gfxId:X8} verts={count} " + $"x[{minX:F2},{maxX:F2}] y[{minY:F2},{maxY:F2}] z[{minZ:F2},{maxZ:F2}] " - + $"sortCenter=({gfx.SortCenter.X:F2},{gfx.SortCenter.Y:F2},{gfx.SortCenter.Z:F2})"); + + $"sortCenter=({gfx.SortCenter.X:F2},{gfx.SortCenter.Y:F2},{gfx.SortCenter.Z:F2}) " + + $"flags=0x{(uint)gfx.Flags:X8} " + + $"physicsBsp={(gfx.PhysicsBSP?.Root is null ? "none" : "present")} " + + $"physicsPolygons={gfx.PhysicsPolygons.Count}"); } Console.WriteLine($"DefaultAnimation = 0x{(uint)setup.DefaultAnimation:X8}"); From 10b55d7485dad510bf90810d314fc0b06fbe5580 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 12:17:35 +0200 Subject: [PATCH 10/73] test(physics): harden tight-gap collision controls --- .../Issue185OutdoorStairsSeamReplayTests.cs | 64 ++++- .../Issue273HoltburgTightGapReplayTests.cs | 232 ++++++++++++++++-- 2 files changed, 265 insertions(+), 31 deletions(-) diff --git a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs index de6bf12b..89495a1b 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs @@ -193,11 +193,7 @@ public class Issue185OutdoorStairsSeamReplayTests public void OutdoorStairs_SideWallContact_DoesNotReverseDownhill() { var engine = BuildStairEngine(); - var body = GroundedOnTread(); - body.Position = new Vector3(133.03775f, 75.53931f, 59.608147f); - body.ContactPlane = new Plane( - new Vector3(3.2782555e-07f, -0.62469506f, 0.78086877f), - 0.75193405f); + var body = CapturedSideWallBody(x: 133.03775f); ResolveResult result = engine.ResolveWithTransition( currentPos: body.Position, @@ -225,6 +221,64 @@ public class Issue185OutdoorStairsSeamReplayTests $"{body.Position.Z:F6} -> {result.Position.Z:F6}."); } + /// + /// Control for the outside-support capture above. Moving the same center + /// 8.8 cm inward puts it 0.20 m beyond the tread edge, inside retail's + /// 0.24 m half-radius support allowance. The side-wall response must then + /// preserve meaningful uphill progress rather than treating every contact + /// near the edge as unsupported. + /// + [Fact] + public void OutdoorStairs_SideWallContact_InsideHalfRadius_AdvancesUphill() + { + var engine = BuildStairEngine(); + var body = CapturedSideWallBody(x: 132.95f); + + ResolveResult result = engine.ResolveWithTransition( + currentPos: body.Position, + targetPos: body.Position + new Vector3(0.30007935f, 0.8837738f, 0f), + cellId: StairCellId, + sphereRadius: 0.48f, + sphereHeight: 1.835f, + stepUpHeight: 0.6f, + stepDownHeight: 1.5f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000000u); + + _out.WriteLine( + $"inside-support out=({result.Position.X:F6},{result.Position.Y:F6}," + + $"{result.Position.Z:F6}) collision={result.CollisionNormalValid} " + + $"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3}," + + $"{result.CollisionNormal.Z:F3})"); + + Assert.True(result.Position.Y > body.Position.Y + 0.25f, + $"Inside-half-radius support failed to preserve meaningful uphill motion: " + + $"{body.Position.Y:F6} -> {result.Position.Y:F6}."); + Assert.True(result.Position.Z > body.Position.Z + 0.10f, + $"Inside-half-radius support failed to climb the tread: " + + $"{body.Position.Z:F6} -> {result.Position.Z:F6}."); + } + + private static PhysicsBody CapturedSideWallBody(float x) + { + PhysicsBody body = GroundedOnTread(); + body.Position = new Vector3(x, 75.53931f, 59.608147f); + var tread = new Plane( + new Vector3(3.2782555e-07f, -0.62469506f, 0.78086877f), + 0.75193405f); + body.ContactPlane = tread; + body.WalkablePlane = tread; + + // GroundedOnTread describes k=4. The live #271 capture is on k=1: + // three authored 0.5 m Y / 0.4 m Z stair increments lower. + Vector3 delta = new(0f, 1.5f, 1.2f); + Vector3[] vertices = Assert.IsType(body.WalkableVertices); + body.WalkableVertices = Array.ConvertAll(vertices, vertex => vertex - delta); + return body; + } + private static string SolutionRoot() { var dir = AppContext.BaseDirectory; diff --git a/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs index 66abfcc7..73692eb8 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs @@ -44,7 +44,10 @@ public sealed class Issue273HoltburgTightGapReplayTests new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.480f), ]; - private static PhysicsEngine BuildEngine() + private static PhysicsEngine BuildEngine( + bool preparedFlat = false, + bool includeShell = true, + bool includeBlockingPost = true) { var cache = new PhysicsDataCache(); var engine = new PhysicsEngine { DataCache = cache }; @@ -57,23 +60,38 @@ public sealed class Issue273HoltburgTightGapReplayTests "issue273", "0x01000F69.gfxobj.json"); Assert.True(File.Exists(dumpPath), $"Missing issue #273 fixture: {dumpPath}"); - cache.RegisterGfxObjForTest( - ShellGfxObj, - GfxObjDumpSerializer.Hydrate(GfxObjDumpSerializer.Read(dumpPath))); + GfxObjPhysics physics = GfxObjDumpSerializer.Hydrate( + GfxObjDumpSerializer.Read(dumpPath)); + if (preparedFlat) + { + cache.CollisionTraversalMode = CollisionTraversalMode.Flat; + cache.CacheGfxObj( + ShellGfxObj, + FlatCollisionAssetBuilder.FlattenGfxObj(physics)); + } + else + { + cache.RegisterGfxObjForTest(ShellGfxObj, physics); + } // The shell is registered through retail's building channel, not as a // shadow object. Its one portal is irrelevant to this exterior sweep. - cache.CacheBuilding( - Cell, - Array.Empty(), - BuildingTransform, - ShellGfxObj); + if (includeShell) + { + cache.CacheBuilding( + Cell, + Array.Empty(), + BuildingTransform, + ShellGfxObj); + } - // Terrain is deliberately below the shell. The player stands on the - // shell's authored z=2 ledge (world z=96), not synthetic terrain. + // Terrain is deliberately below the shell in the real fixture. The + // no-shell counterfactual substitutes a featureless floor at the same + // height so it isolates the removed shell wall/ledge while preserving + // grounded movement around the remaining post. var heights = new byte[81]; var heightTable = new float[256]; - Array.Fill(heightTable, -1000f); + Array.Fill(heightTable, includeShell ? -1000f : 96f); engine.AddLandblock( Landblock, new TerrainSurface(heights, heightTable), @@ -82,11 +100,14 @@ public sealed class Issue273HoltburgTightGapReplayTests 0f, 0f); - RegisterPost( - engine, - 0xCA9B4027u, - new Vector3(160.173f, 34.487f, 95.975f), - radius: 0.282f); + if (includeBlockingPost) + { + RegisterPost( + engine, + 0xCA9B4027u, + new Vector3(160.173f, 34.487f, 95.975f), + radius: 0.282f); + } RegisterPost( engine, 0xCA9B402Eu, @@ -185,9 +206,66 @@ public sealed class Issue273HoltburgTightGapReplayTests [Fact] public void CapturedRun_DoesNotSqueezeBetweenPostAndBuilding() { - PhysicsEngine engine = BuildEngine(); + ReplayFrame[] trace = RunCapturedReplay(BuildEngine()); + Vector3 position = trace[^1].Result.Position; + + // The captured broken run reached (163.234, 36.982) by this point, + // already beyond the post and sliding along the building. Retail + // blocks the passage before the player can cross the post's Y. + Assert.True( + position.Y < 34.487f, + $"Player squeezed through the retail-blocked gap: " + + $"final=({position.X:F3},{position.Y:F3},{position.Z:F3})."); + } + + [Fact] + public void CapturedRun_PreparedFlatMatchesGraphForEveryFrame() + { + ReplayFrame[] graph = RunCapturedReplay(BuildEngine()); + ReplayFrame[] preparedFlat = RunCapturedReplay( + BuildEngine(preparedFlat: true)); + + Assert.Equal(graph.Length, preparedFlat.Length); + for (int frame = 0; frame < graph.Length; frame++) + AssertFrameBitwise(graph[frame], preparedFlat[frame], frame); + } + + [Theory] + [InlineData(false, true, "shell")] + [InlineData(true, false, "blocking post")] + public void CapturedRun_RequiresBothShellAndBlockingPost( + bool includeShell, + bool includeBlockingPost, + string omittedGeometry) + { + PhysicsEngine engine = BuildEngine( + includeShell: includeShell, + includeBlockingPost: includeBlockingPost); + PhysicsBody body = GroundedBody(new Vector3(160.016f, 33.562f, 96.005f)); + if (!includeShell) + { + // Do not retain the removed shell through the body's remembered + // support polygon. The contact plane keeps the counterfactual at + // the captured height while the shell geometry itself is absent. + body.WalkablePolygonValid = false; + body.WalkableVertices = null; + } + + ReplayFrame[] trace = RunCapturedReplay(engine, body); + Vector3 final = trace[^1].Result.Position; + + Assert.True( + final.Y > 34.487f, + $"Omitting the {omittedGeometry} still reproduced the complete " + + $"fixture's block: final=({final.X:F3},{final.Y:F3},{final.Z:F3})."); + } + + private ReplayFrame[] RunCapturedReplay( + PhysicsEngine engine, + PhysicsBody? body = null) + { Vector3 position = new(160.016f, 33.562f, 96.005f); - var body = GroundedBody(position); + body ??= GroundedBody(position); uint cell = Cell; // First frame is copied verbatim from the live capture. Subsequent @@ -204,6 +282,7 @@ public sealed class Issue273HoltburgTightGapReplayTests new(0.727f, 0.585f, 0f), new(0.728f, 0.585f, 0f), ]; + var trace = new ReplayFrame[offsets.Length]; for (int frame = 0; frame < offsets.Length; frame++) { @@ -232,17 +311,118 @@ public sealed class Issue273HoltburgTightGapReplayTests position = result.Position; cell = result.CellId; body.Position = position; + trace[frame] = new ReplayFrame( + result, + body.TransientState, + body.ContactPlaneValid, + body.ContactPlane, + body.ContactPlaneCellId, + body.ContactPlaneIsWater, + body.WalkablePolygonValid, + body.WalkablePlane, + body.WalkableUp, + body.SlidingNormal); } - // The captured broken run reached (163.234, 36.982) by this point, - // already beyond the post and sliding along the building. Retail - // blocks the passage before the player can cross the post's Y. - Assert.True( - position.Y < 34.487f, - $"Player squeezed through the retail-blocked gap: " - + $"final=({position.X:F3},{position.Y:F3},{position.Z:F3})."); + return trace; } + private static void AssertFrameBitwise( + ReplayFrame expected, + ReplayFrame actual, + int frame) + { + string context = $"frame {frame}"; + AssertVectorBitwise(expected.Result.Position, actual.Result.Position, context); + Assert.Equal(expected.Result.CellId, actual.Result.CellId); + Assert.Equal(expected.Result.IsOnGround, actual.Result.IsOnGround); + Assert.Equal( + expected.Result.CollisionNormalValid, + actual.Result.CollisionNormalValid); + AssertVectorBitwise( + expected.Result.CollisionNormal, + actual.Result.CollisionNormal, + context); + Assert.Equal(expected.Result.Ok, actual.Result.Ok); + AssertQuaternionBitwise( + expected.Result.Orientation, + actual.Result.Orientation, + context); + Assert.Equal(expected.Result.InContact, actual.Result.InContact); + Assert.Equal(expected.Result.OnWalkable, actual.Result.OnWalkable); + AssertPlaneBitwise( + expected.Result.ContactPlane, + actual.Result.ContactPlane, + context); + Assert.Equal( + expected.Result.ContactPlaneCellId, + actual.Result.ContactPlaneCellId); + Assert.Equal( + expected.Result.ContactPlaneIsWater, + actual.Result.ContactPlaneIsWater); + Assert.Equal(expected.TransientState, actual.TransientState); + Assert.Equal(expected.ContactPlaneValid, actual.ContactPlaneValid); + AssertPlaneBitwise(expected.ContactPlane, actual.ContactPlane, context); + Assert.Equal(expected.ContactPlaneCellId, actual.ContactPlaneCellId); + Assert.Equal(expected.ContactPlaneIsWater, actual.ContactPlaneIsWater); + Assert.Equal(expected.WalkablePolygonValid, actual.WalkablePolygonValid); + AssertPlaneBitwise(expected.WalkablePlane, actual.WalkablePlane, context); + AssertVectorBitwise(expected.WalkableUp, actual.WalkableUp, context); + AssertVectorBitwise(expected.SlidingNormal, actual.SlidingNormal, context); + } + + private static void AssertVectorBitwise( + Vector3 expected, + Vector3 actual, + string context) + { + AssertFloatBitwise(expected.X, actual.X, $"{context}.X"); + AssertFloatBitwise(expected.Y, actual.Y, $"{context}.Y"); + AssertFloatBitwise(expected.Z, actual.Z, $"{context}.Z"); + } + + private static void AssertQuaternionBitwise( + Quaternion expected, + Quaternion actual, + string context) + { + AssertFloatBitwise(expected.X, actual.X, $"{context}.X"); + AssertFloatBitwise(expected.Y, actual.Y, $"{context}.Y"); + AssertFloatBitwise(expected.Z, actual.Z, $"{context}.Z"); + AssertFloatBitwise(expected.W, actual.W, $"{context}.W"); + } + + private static void AssertPlaneBitwise( + Plane expected, + Plane actual, + string context) + { + AssertVectorBitwise(expected.Normal, actual.Normal, $"{context}.Normal"); + AssertFloatBitwise(expected.D, actual.D, $"{context}.D"); + } + + private static void AssertFloatBitwise( + float expected, + float actual, + string context) + => Assert.True( + BitConverter.SingleToInt32Bits(expected) + == BitConverter.SingleToInt32Bits(actual), + $"{context}: expected 0x{BitConverter.SingleToInt32Bits(expected):X8}, " + + $"actual 0x{BitConverter.SingleToInt32Bits(actual):X8}"); + + private readonly record struct ReplayFrame( + ResolveResult Result, + TransientStateFlags TransientState, + bool ContactPlaneValid, + Plane ContactPlane, + uint ContactPlaneCellId, + bool ContactPlaneIsWater, + bool WalkablePolygonValid, + Plane WalkablePlane, + Vector3 WalkableUp, + Vector3 SlidingNormal); + private static string SolutionRoot() { string? directory = AppContext.BaseDirectory; From e5f855ac40c48c95d4595031aeaa7aea6dd2caf2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 12:25:03 +0200 Subject: [PATCH 11/73] fix(physics): restore nested per-cell collision retries --- src/AcDream.Core/Physics/PhysicsEngine.cs | 12 ++ src/AcDream.Core/Physics/TransitionTypes.cs | 182 +++++++++++------- .../TransitionInsertIntoCellRetryTests.cs | 169 ++++++++++++++++ 3 files changed, 292 insertions(+), 71 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 0ea2d6e9..33fe27ea 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -63,6 +63,18 @@ public sealed class PhysicsEngine /// public Action? DiagnosticLog { get; set; } + /// + /// Deterministic test seam for the retail per-cell dispatcher. Production + /// leaves this null. Tests may observe a completed phase and substitute its + /// returned state to prove retry/order semantics without geometry-specific + /// response coupling. + /// + internal Func< + TransitionCellCollisionPhase, + uint, + TransitionState, + TransitionState>? TransitionCellCollisionTestHook { get; set; } + /// /// True once the landblock covering has had its /// terrain + cells registered via . Accepts a canonical diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 1f2a1de2..ace80f6c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -15,6 +15,18 @@ public enum TransitionState Slid = 4, } +/// +/// Test-observation boundary for retail's atomic per-cell collision pass. +/// Production never installs the corresponding hook on +/// . +/// +internal enum TransitionCellCollisionPhase +{ + Environment, + Building, + Objects, +} + public enum InsertType { Transition = 0, @@ -1685,14 +1697,18 @@ public sealed class Transition // ----------------------------------------------------------------------- /// - /// ACE Transition.TransitionalInsert — retry loop for collision resolution. + /// Retail CTransition::transitional_insert (0x0050B6F0) — the + /// outer transition retry loop. Each outer attempt delegates the complete + /// primary-cell transaction to , which owns a + /// second retry budget matching CTransition::insert_into_cell + /// (0x00509E70). /// /// - /// Per ACE: iterate up to numAttempts times. Each iteration runs the full - /// collision pipeline (env + objects) at the current CheckPos. The pipeline - /// can MUTATE CheckPos (push-out, slide). On Slid/Adjusted, clear state and - /// retry — the next iteration tests the NEW CheckPos against all nearby - /// objects again, which catches "slide into a second wall" corner cases. + /// Each outer attempt asks to retry the atomic + /// environment → building → objects composition up to + /// times. An Adjusted/Slid result that + /// exhausts that inner budget is then eligible for another outer attempt. + /// Retail therefore permits up to N×N primary-cell passes, not N total. /// /// /// @@ -1730,18 +1746,21 @@ public sealed class Transition for (int attempt = 0; attempt < numAttempts; attempt++) { - // ── Phase 1: environment collision (terrain + indoor BSP) ─── - // Primary cell only — retail CEnvCell/CLandCell::find_collisions - // step 1 (find_env_collisions). Other cells run in Phase 2.5. - transitState = FindEnvCollisions(engine); + transitState = InsertIntoCell( + engine, + sp.CheckCellId, + numAttempts); if (transitState == TransitionState.Collided) + { + sp.NegPolyHit = false; return TransitionState.Collided; + } if (transitState == TransitionState.Slid) { - // Env collision slid the sphere. Clear state and retry at - // the new CheckPos to see if we hit anything else. + // Retail transitional_insert repeats the Slid contact clear + // at its outer boundary, then clears neg_poly_hit. ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; sp.NegPolyHit = false; @@ -1750,70 +1769,15 @@ public sealed class Transition if (transitState == TransitionState.Adjusted) { - // Env modified CheckPos. Retry at new position. + // insert_into_cell exhausted its own retry budget. Preserve + // every sphere field except neg_poly_hit at this outer + // boundary and start the next outer attempt. sp.NegPolyHit = false; continue; } - // ── Phase 1b: the building channel (BR-7 / A6.P4) ─────────── - // CLandCell::find_collisions (Ghidra 0x00532d60) interposes - // CSortCell::find_collisions (0x005340a0 — the per-LandCell - // building shell BSP) between env and objects. Indoor primary - // cells have no building leg (CEnvCell::find_collisions, - // 0x0052c100). No-op when the cell has no building. - var bldgState = FindBuildingCollisions(engine, sp.CheckCellId); - - if (bldgState == TransitionState.Collided) - return TransitionState.Collided; - - if (bldgState == TransitionState.Slid) - { - transitState = bldgState; - ci.ContactPlaneValid = false; - ci.ContactPlaneIsWater = false; - sp.NegPolyHit = false; + if (transitState != TransitionState.OK) continue; - } - - if (bldgState == TransitionState.Adjusted) - { - transitState = bldgState; - sp.NegPolyHit = false; - continue; - } - - // ── Phase 2: object collision — PRIMARY cell's shadow list ── - // Retail CObjCell::find_obj_collisions(this) (0x0052b750), the - // tail of the primary cell's find_collisions. Other cells' - // lists run per cell in Phase 2.5 (check_other_cells). - var objState = FindObjCollisionsInCell(engine, sp.CheckCellId); - // L.4-diag: log Phase outcomes per attempt so we can see whether - // we're escaping to the step-down branch or churning in retries. - DumpPhase2(attempt, transitState, objState); - - if (objState == TransitionState.Collided) - return TransitionState.Collided; - - if (objState == TransitionState.Slid) - { - // Object collision applied a push-out and set sliding normal. - // Retry at the new CheckPos — we may have slid into another - // object, or need to re-verify env at the new position. - transitState = objState; - ci.ContactPlaneValid = false; - ci.ContactPlaneIsWater = false; - sp.NegPolyHit = false; - continue; - } - - if (objState == TransitionState.Adjusted) - { - // Object modified CheckPos (e.g. PerfectClip adjust_to_plane). - // Retry at the new position. - transitState = objState; - sp.NegPolyHit = false; - continue; - } // ── Phase 2.5: other cells + carried-cell advance ──────────── // Retail transitional_insert OK_TS case (0x0050b756): on a clean @@ -2151,6 +2115,82 @@ public sealed class Transition return transitState; } + /// + /// Retail CTransition::insert_into_cell (0x00509E70). The cell's + /// virtual find_collisions call is one atomic environment → + /// building → objects transaction. Adjusted retries the entire + /// transaction. Slid additionally clears only contact-plane validity and + /// water state before retrying. OK and Collided return immediately. + /// + private TransitionState InsertIntoCell( + PhysicsEngine engine, + uint cellId, + int numAttempts) + { + if (cellId == 0) + return TransitionState.Collided; + + TransitionState state = TransitionState.OK; + for (int attempt = 0; attempt < numAttempts; attempt++) + { + state = FindPrimaryCellCollisions(engine, cellId, attempt); + if (state is TransitionState.OK or TransitionState.Collided) + return state; + + if (state == TransitionState.Slid) + { + CollisionInfo.ContactPlaneValid = false; + CollisionInfo.ContactPlaneIsWater = false; + } + } + + return state; + } + + /// + /// Primary-cell virtual find_collisions composition. A non-OK + /// response terminates this pass, so an inner retry always restarts from + /// environment before revisiting the building and object channels. + /// + private TransitionState FindPrimaryCellCollisions( + PhysicsEngine engine, + uint cellId, + int innerAttempt) + { + TransitionState environment = ObservePrimaryCellPhase( + engine, + TransitionCellCollisionPhase.Environment, + cellId, + FindEnvCollisions(engine)); + if (environment != TransitionState.OK) + return environment; + + TransitionState building = ObservePrimaryCellPhase( + engine, + TransitionCellCollisionPhase.Building, + cellId, + FindBuildingCollisions(engine, cellId)); + if (building != TransitionState.OK) + return building; + + TransitionState objects = ObservePrimaryCellPhase( + engine, + TransitionCellCollisionPhase.Objects, + cellId, + FindObjCollisionsInCell(engine, cellId)); + DumpPhase2(innerAttempt, environment, objects); + return objects; + } + + private static TransitionState ObservePrimaryCellPhase( + PhysicsEngine engine, + TransitionCellCollisionPhase phase, + uint cellId, + TransitionState actual) + => engine.TransitionCellCollisionTestHook is { } hook + ? hook(phase, cellId, actual) + : actual; + private TransitionState EdgeSlideAfterStepDownFailed( PhysicsEngine engine, float stepDownHeight, diff --git a/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs new file mode 100644 index 00000000..dfee3d58 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Pins retail's two-level collision retry structure: +/// transitional_insert(N) wraps insert_into_cell(N), and every +/// inner retry restarts the complete cell transaction in +/// environment → building → objects order. +/// +public sealed class TransitionInsertIntoCellRetryTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = 0xA9B40001u; + private const uint ShellGfxObj = 0x0100F001u; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void NestedRetry_RecomputesAtomicCellPipeline_AndExceedsOuterBudget( + bool preparedFlat) + { + PhysicsEngine engine = BuildEngine(preparedFlat); + var phases = new List(); + int environmentCalls = 0; + int buildingCalls = 0; + int objectCalls = 0; + + engine.TransitionCellCollisionTestHook = (phase, cellId, actual) => + { + Assert.Equal(Cell, cellId); + Assert.Equal(TransitionState.OK, actual); + phases.Add(phase); + + switch (phase) + { + case TransitionCellCollisionPhase.Environment: + environmentCalls++; + return environmentCalls == 1 + ? TransitionState.Adjusted + : TransitionState.OK; + case TransitionCellCollisionPhase.Building: + buildingCalls++; + return buildingCalls == 1 + ? TransitionState.Adjusted + : TransitionState.OK; + case TransitionCellCollisionPhase.Objects: + objectCalls++; + return objectCalls == 1 + ? TransitionState.Adjusted + : TransitionState.OK; + default: + throw new ArgumentOutOfRangeException(nameof(phase)); + } + }; + + Vector3 current = new(10f, 10f, 5f); + Vector3 target = current + new Vector3(0.05f, 0f, 0f); + var body = new PhysicsBody + { + Position = current, + Orientation = Quaternion.Identity, + TransientState = TransientStateFlags.Active, + }; + + ResolveResult result = engine.ResolveWithTransition( + current, + target, + Cell, + sphereRadius: 0.48f, + sphereHeight: 1.835f, + stepUpHeight: 0.6f, + stepDownHeight: 1.5f, + isOnGround: false, + body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x000F4243u); + + Assert.True(result.Ok); + Assert.Equal(target, result.Position); + + // The first inner budget of three passes stops successively at env, + // building, and objects. The second outer attempt starts a fourth + // complete pass and succeeds. A flattened N=3 loop cannot reach it. + TransitionCellCollisionPhase[] expected = + [ + TransitionCellCollisionPhase.Environment, + TransitionCellCollisionPhase.Environment, + TransitionCellCollisionPhase.Building, + TransitionCellCollisionPhase.Environment, + TransitionCellCollisionPhase.Building, + TransitionCellCollisionPhase.Objects, + TransitionCellCollisionPhase.Environment, + TransitionCellCollisionPhase.Building, + TransitionCellCollisionPhase.Objects, + ]; + Assert.Equal(expected, phases); + Assert.Equal(4, environmentCalls); + Assert.Equal(3, buildingCalls); + Assert.Equal(2, objectCalls); + Assert.True( + environmentCalls > 3, + "The fixture must require more complete cell passes than the " + + "outer N=3 budget while remaining inside retail's N×N budget."); + } + + private static PhysicsEngine BuildEngine(bool preparedFlat) + { + var (root, resolved) = BSPStepUpFixtures.FlatRoof(); + var normalized = new Dictionary(resolved.Count); + foreach ((ushort id, ResolvedPolygon polygon) in resolved) + { + normalized.Add(id, new ResolvedPolygon + { + Id = id, + Vertices = polygon.Vertices, + Plane = polygon.Plane, + NumPoints = polygon.NumPoints, + SidesType = polygon.SidesType, + }); + } + var physics = new GfxObjPhysics + { + SourceId = ShellGfxObj, + BSP = new PhysicsBSPTree { Root = root }, + Resolved = normalized, + BoundingSphere = root.BoundingSphere, + }; + + var cache = new PhysicsDataCache(); + if (preparedFlat) + { + cache.CollisionTraversalMode = CollisionTraversalMode.Flat; + cache.CacheGfxObj( + ShellGfxObj, + FlatCollisionAssetBuilder.FlattenGfxObj(physics)); + } + else + { + cache.RegisterGfxObjForTest(ShellGfxObj, physics); + } + + // The far-away shell makes the building channel execute its actual + // graph/flat traversal and return OK without affecting the mover. + cache.CacheBuilding( + Cell, + Array.Empty(), + Matrix4x4.CreateTranslation(100f, 100f, 100f), + ShellGfxObj); + + var engine = new PhysicsEngine { DataCache = cache }; + var heights = new byte[81]; + var heightTable = new float[256]; + Array.Fill(heightTable, -1000f); + engine.AddLandblock( + Landblock, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + return engine; + } +} From 67d1e9b331f88d1846b983ed3611d450b4e83408 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 12:31:34 +0200 Subject: [PATCH 12/73] fix(physics): preserve refreshed cell retry state --- src/AcDream.Core/Physics/PhysicsEngine.cs | 1 + src/AcDream.Core/Physics/TransitionTypes.cs | 26 +-- .../TransitionInsertIntoCellRetryTests.cs | 167 +++++++++++++++++- 3 files changed, 183 insertions(+), 11 deletions(-) diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 33fe27ea..e78d74df 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -70,6 +70,7 @@ public sealed class PhysicsEngine /// response coupling. /// internal Func< + Transition, TransitionCellCollisionPhase, uint, TransitionState, diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index ace80f6c..b8aa4093 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1728,6 +1728,11 @@ public sealed class Transition /// The former flat per-landblock object query is gone. /// /// + internal TransitionState TransitionalInsertForTest( + int numAttempts, + PhysicsEngine engine) + => TransitionalInsert(numAttempts, engine); + private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine) { if (SpherePath.CheckCellId == 0) return TransitionState.OK; @@ -2133,7 +2138,7 @@ public sealed class Transition TransitionState state = TransitionState.OK; for (int attempt = 0; attempt < numAttempts; attempt++) { - state = FindPrimaryCellCollisions(engine, cellId, attempt); + state = FindPrimaryCellCollisions(engine, attempt); if (state is TransitionState.OK or TransitionState.Collided) return state; @@ -2154,41 +2159,42 @@ public sealed class Transition /// private TransitionState FindPrimaryCellCollisions( PhysicsEngine engine, - uint cellId, int innerAttempt) { + TransitionState actualEnvironment = FindEnvCollisions(engine); + uint currentCellId = SpherePath.CheckCellId; TransitionState environment = ObservePrimaryCellPhase( engine, TransitionCellCollisionPhase.Environment, - cellId, - FindEnvCollisions(engine)); + currentCellId, + actualEnvironment); if (environment != TransitionState.OK) return environment; TransitionState building = ObservePrimaryCellPhase( engine, TransitionCellCollisionPhase.Building, - cellId, - FindBuildingCollisions(engine, cellId)); + currentCellId, + FindBuildingCollisions(engine, currentCellId)); if (building != TransitionState.OK) return building; TransitionState objects = ObservePrimaryCellPhase( engine, TransitionCellCollisionPhase.Objects, - cellId, - FindObjCollisionsInCell(engine, cellId)); + currentCellId, + FindObjCollisionsInCell(engine, currentCellId)); DumpPhase2(innerAttempt, environment, objects); return objects; } - private static TransitionState ObservePrimaryCellPhase( + private TransitionState ObservePrimaryCellPhase( PhysicsEngine engine, TransitionCellCollisionPhase phase, uint cellId, TransitionState actual) => engine.TransitionCellCollisionTestHook is { } hook - ? hook(phase, cellId, actual) + ? hook(this, phase, cellId, actual) : actual; private TransitionState EdgeSlideAfterStepDownFailed( diff --git a/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs index dfee3d58..b97985c1 100644 --- a/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs @@ -31,7 +31,7 @@ public sealed class TransitionInsertIntoCellRetryTests int buildingCalls = 0; int objectCalls = 0; - engine.TransitionCellCollisionTestHook = (phase, cellId, actual) => + engine.TransitionCellCollisionTestHook = (_, phase, cellId, actual) => { Assert.Equal(Cell, cellId); Assert.Equal(TransitionState.OK, actual); @@ -109,6 +109,148 @@ public sealed class TransitionInsertIntoCellRetryTests + "outer N=3 budget while remaining inside retail's N×N budget."); } + [Fact] + public void NestedRetry_AlwaysAdjusted_ExhaustsExactNByNBudget() + { + PhysicsEngine engine = BuildEngine(preparedFlat: false); + int calls = 0; + engine.TransitionCellCollisionTestHook = + (_, phase, cellId, actual) => + { + Assert.Equal(TransitionCellCollisionPhase.Environment, phase); + Assert.Equal(Cell, cellId); + Assert.Equal(TransitionState.OK, actual); + calls++; + return TransitionState.Adjusted; + }; + + Vector3 current = new(10f, 10f, 5f); + Vector3 target = current + new Vector3(0.05f, 0f, 0f); + Transition transition = BSPStepUpFixtures.MakeAirborneTransition( + current, + target, + Cell); + transition.SpherePath.SetCheckPos(target, Cell); + TransitionState result = transition.TransitionalInsertForTest(3, engine); + + Assert.Equal(TransitionState.Adjusted, result); + Assert.Equal(target, transition.SpherePath.CheckPos); + Assert.Equal(9, calls); + } + + [Fact] + public void NestedRetry_SlidClearsInnerContact_ThenOuterNegPoly() + { + PhysicsEngine engine = BuildEngine(preparedFlat: false); + int environmentCalls = 0; + var contactPlane = new Plane(Vector3.UnitZ, -5f); + + engine.TransitionCellCollisionTestHook = + (transition, phase, _, actual) => + { + if (phase != TransitionCellCollisionPhase.Environment) + return actual; + + environmentCalls++; + if (environmentCalls <= 3) + { + if (environmentCalls > 1) + { + Assert.False(transition.CollisionInfo.ContactPlaneValid); + Assert.False(transition.CollisionInfo.ContactPlaneIsWater); + Assert.True(transition.SpherePath.NegPolyHit); + } + + transition.CollisionInfo.SetContactPlane( + contactPlane, + Cell, + isWater: true); + transition.SpherePath.NegPolyHit = true; + return TransitionState.Slid; + } + + // Three Slid responses exhaust the first inner N=3 budget. + // Its final Slid clear removes contact/water; the outer + // transitional_insert boundary additionally removes neg-poly. + Assert.False(transition.CollisionInfo.ContactPlaneValid); + Assert.False(transition.CollisionInfo.ContactPlaneIsWater); + Assert.False(transition.SpherePath.NegPolyHit); + return actual; + }; + + Vector3 current = new(10f, 10f, 5f); + ResolveResult result = ResolveAirborne( + engine, + current, + current + new Vector3(0.05f, 0f, 0f), + Cell); + + Assert.True(result.Ok); + Assert.Equal(4, environmentCalls); + } + + [Fact] + public void NoDataCache_CrossLandblockRepick_UsesRefreshedPrimaryCell() + { + PhysicsEngine engine = BuildCrossLandblockEngine(); + var cells = new List(); + var phases = new List(); + engine.TransitionCellCollisionTestHook = + (_, phase, cellId, actual) => + { + phases.Add(phase); + cells.Add(cellId); + return actual; + }; + + Vector3 current = new(191.99f, 10f, 5f); + Vector3 target = new(192.01f, 10f, 5f); + ResolveResult result = ResolveAirborne( + engine, + current, + target, + 0xA9B40039u); + + Assert.True(result.Ok); + Assert.Equal(target, result.Position); + Assert.Equal(0xAAB40001u, result.CellId); + Assert.Equal( + new[] + { + TransitionCellCollisionPhase.Environment, + TransitionCellCollisionPhase.Building, + TransitionCellCollisionPhase.Objects, + }, + phases); + Assert.All(cells, cellId => Assert.Equal(0xAAB40001u, cellId)); + } + + private static ResolveResult ResolveAirborne( + PhysicsEngine engine, + Vector3 current, + Vector3 target, + uint cellId) + { + var body = new PhysicsBody + { + Position = current, + Orientation = Quaternion.Identity, + TransientState = TransientStateFlags.Active, + }; + return engine.ResolveWithTransition( + current, + target, + cellId, + sphereRadius: 0.48f, + sphereHeight: 1.835f, + stepUpHeight: 0.6f, + stepDownHeight: 1.5f, + isOnGround: false, + body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x000F4243u); + } + private static PhysicsEngine BuildEngine(bool preparedFlat) { var (root, resolved) = BSPStepUpFixtures.FlatRoof(); @@ -166,4 +308,27 @@ public sealed class TransitionInsertIntoCellRetryTests 0f); return engine; } + + private static PhysicsEngine BuildCrossLandblockEngine() + { + var engine = new PhysicsEngine { DataCache = null }; + var heights = new byte[81]; + var heightTable = new float[256]; + Array.Fill(heightTable, -1000f); + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + engine.AddLandblock( + 0xAAB4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 192f, + 0f); + return engine; + } } From 4ca7230b36be9eecad58dbbd5fc0c35e73754e33 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 12:40:50 +0200 Subject: [PATCH 13/73] fix(physics): hold retail cell across inner retries --- src/AcDream.Core/Physics/PhysicsEngine.cs | 118 ++++++++++++++---- src/AcDream.Core/Physics/TransitionTypes.cs | 64 ++++++---- .../IndoorContactPlaneRetentionTests.cs | 2 +- .../TransitionInsertIntoCellRetryTests.cs | 67 +++++----- 4 files changed, 168 insertions(+), 83 deletions(-) diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index e78d74df..72347120 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -385,33 +385,97 @@ public sealed class PhysicsEngine float localX = worldX - lb.WorldOffsetX; float localY = worldY - lb.WorldOffsetY; if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) - { - var sample = lb.Terrain.SampleSurfacePolygon(localX, localY); - var vertices = new TerrainTriangleVertices( - OffsetTerrainVertex(sample.Vertices.V0, lb), - OffsetTerrainVertex(sample.Vertices.V1, lb), - OffsetTerrainVertex(sample.Vertices.V2, lb)); - - var normal = sample.Normal; - float d = -Vector3.Dot(normal, vertices[0]); - var plane = new System.Numerics.Plane(normal, d); - - float waterDepth = lb.Terrain.SampleWaterDepth(localX, localY); - bool isWater = waterDepth >= 0.45f; - uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); - uint fullCellId = (kvp.Key & 0xFFFF0000u) | lowCellId; - - return new TerrainWalkableSample( - plane, - vertices, - waterDepth, - isWater, - fullCellId); - } + return BuildTerrainWalkableSample(kvp.Key, lb, localX, localY); } return null; } + /// + /// Samples only the fixed outdoor cell supplied to retail + /// CTransition::insert_into_cell. The target point may move into a + /// neighboring cell during a retry, but retail continues dispatching the + /// captured CObjCell* until that inner call returns. + /// + internal TerrainWalkableSample? SampleTerrainWalkableInCell( + uint cellId, + float worldX, + float worldY) + { + uint lowCellId = cellId & 0xFFFFu; + if (lowCellId is < 1u or > 0x40u) + return null; + + foreach (var kvp in _landblocks) + { + uint requestedPrefix = cellId & 0xFFFF0000u; + if (requestedPrefix != 0u && + (kvp.Key & 0xFFFF0000u) != requestedPrefix) + continue; + + LandblockPhysics lb = kvp.Value; + float localX = worldX - lb.WorldOffsetX; + float localY = worldY - lb.WorldOffsetY; + if (requestedPrefix == 0u && + (localX < 0f || localX >= 192f || + localY < 0f || localY >= 192f)) + { + continue; + } + int cellIndex = (int)lowCellId - 1; + int cellX = cellIndex / TerrainSurface.CellsPerSide; + int cellY = cellIndex % TerrainSurface.CellsPerSide; + float minX = cellX * TerrainSurface.CellSize; + float minY = cellY * TerrainSurface.CellSize; + float maxX = minX + TerrainSurface.CellSize; + float maxY = minY + TerrainSurface.CellSize; + + if (localX < minX || localX >= maxX || + localY < minY || localY >= maxY) + { + return null; + } + + return BuildTerrainWalkableSample( + kvp.Key, + lb, + localX, + localY); + } + + return null; + } + + private static TerrainWalkableSample BuildTerrainWalkableSample( + uint landblockId, + LandblockPhysics landblock, + float localX, + float localY) + { + TerrainSurfacePolygon sample = landblock.Terrain.SampleSurfacePolygon( + localX, + localY); + var vertices = new TerrainTriangleVertices( + OffsetTerrainVertex(sample.Vertices.V0, landblock), + OffsetTerrainVertex(sample.Vertices.V1, landblock), + OffsetTerrainVertex(sample.Vertices.V2, landblock)); + + Vector3 normal = sample.Normal; + float d = -Vector3.Dot(normal, vertices[0]); + var plane = new System.Numerics.Plane(normal, d); + + float waterDepth = landblock.Terrain.SampleWaterDepth(localX, localY); + bool isWater = waterDepth >= 0.45f; + uint lowCellId = landblock.Terrain.ComputeOutdoorCellId(localX, localY); + uint fullCellId = (landblockId & 0xFFFF0000u) | lowCellId; + + return new TerrainWalkableSample( + plane, + vertices, + waterDepth, + isWater, + fullCellId); + } + private static Vector3 OffsetTerrainVertex(Vector3 vertex, LandblockPhysics landblock) => new( vertex.X + landblock.WorldOffsetX, @@ -466,11 +530,11 @@ public sealed class PhysicsEngine } /// - /// TEST-ONLY outdoor cell re-derive. The single caller is - /// Transition.FindEnvCollisions's cache-null fallback + /// TEST-ONLY outdoor cell re-derive. The sole caller is + /// Transition.RunCheckOtherCellsAndAdvance's cache-null fallback /// (PhysicsEngineTests run engines without a , - /// so is unavailable). Production - /// membership flows exclusively through the collide-then-pick advance + /// so is unavailable). Normal + /// production membership flows exclusively through the collide-then-pick advance /// (RunCheckOtherCellsAndAdvanceFindCellSet). /// /// diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index b8aa4093..f98776a0 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -2138,7 +2138,7 @@ public sealed class Transition TransitionState state = TransitionState.OK; for (int attempt = 0; attempt < numAttempts; attempt++) { - state = FindPrimaryCellCollisions(engine, attempt); + state = FindPrimaryCellCollisions(engine, cellId, attempt); if (state is TransitionState.OK or TransitionState.Collided) return state; @@ -2159,31 +2159,30 @@ public sealed class Transition /// private TransitionState FindPrimaryCellCollisions( PhysicsEngine engine, + uint cellId, int innerAttempt) { - TransitionState actualEnvironment = FindEnvCollisions(engine); - uint currentCellId = SpherePath.CheckCellId; TransitionState environment = ObservePrimaryCellPhase( engine, TransitionCellCollisionPhase.Environment, - currentCellId, - actualEnvironment); + cellId, + FindEnvCollisions(engine, cellId)); if (environment != TransitionState.OK) return environment; TransitionState building = ObservePrimaryCellPhase( engine, TransitionCellCollisionPhase.Building, - currentCellId, - FindBuildingCollisions(engine, currentCellId)); + cellId, + FindBuildingCollisions(engine, cellId)); if (building != TransitionState.OK) return building; TransitionState objects = ObservePrimaryCellPhase( engine, TransitionCellCollisionPhase.Objects, - currentCellId, - FindObjCollisionsInCell(engine, currentCellId)); + cellId, + FindObjCollisionsInCell(engine, cellId)); DumpPhase2(innerAttempt, environment, objects); return objects; } @@ -3125,7 +3124,9 @@ public sealed class Transition } } - internal TransitionState FindEnvCollisions(PhysicsEngine engine) + internal TransitionState FindEnvCollisions( + PhysicsEngine engine, + uint primaryCellId) { var sp = SpherePath; var ci = CollisionInfo; @@ -3153,23 +3154,19 @@ public sealed class Transition // first (the indoor BSP block / the terrain block below), then advance the cell only in the // post-collision step (RunCheckOtherCellsAndAdvance) — retail's collide-then-pick order. // - // Cache-null fallback: PhysicsEngineTests use engines without a DataCache (no cell registry, - // so FindCellSet is unavailable). Keep the old outdoor re-derive for them only. - if (engine.DataCache is null) - { - uint resolvedOutdoorCellId = engine.ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId); - if (resolvedOutdoorCellId != sp.CheckCellId) - sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); - } - + // insert_into_cell receives one CObjCell* from transitional_insert + // and invokes that same object's virtual find_collisions method for + // every inner retry. primaryCellId is therefore deliberately fixed + // even if a response mutates sphere_path.check_cell; only the next + // outer transitional_insert attempt captures the replacement cell. // ── Indoor cell BSP collision ──────────────────────────────────── // If the player is in an indoor cell (low 16 bits >= 0x0100), // query the CellStruct's PhysicsBSP for wall/floor/ceiling collision. // ACE: EnvCell.find_env_collisions -> CellStructure.PhysicsBSP.find_collisions - uint cellLow = sp.CheckCellId & 0xFFFFu; + uint cellLow = primaryCellId & 0xFFFFu; if (cellLow >= 0x0100 && engine.DataCache is not null) { - var cellPhysics = engine.DataCache.GetCellStruct(sp.CheckCellId); + var cellPhysics = engine.DataCache.GetCellStruct(primaryCellId); // AP-71 (Campaign P Slice P4, 2026-07-30): retail CEnvCell:: // find_env_collisions (pc:309576) calls check_entry_restrictions @@ -3241,7 +3238,7 @@ public sealed class Transition // so we degrade to "translation-only" instead of the prior // "both broken". Console.WriteLine(System.FormattableString.Invariant( - $"[indoor-bsp] WARN cellPhysics.WorldTransform did not decompose cleanly for cell 0x{sp.CheckCellId:X8} — falling back to identity rotation")); + $"[indoor-bsp] WARN cellPhysics.WorldTransform did not decompose cleanly for cell 0x{primaryCellId:X8} — falling back to identity rotation")); cellRotation = Quaternion.Identity; cellOrigin = cellPhysics.WorldTransform.Translation; } @@ -3270,7 +3267,7 @@ public sealed class Transition : System.FormattableString.Invariant( $"n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) sides={hit.SidesType}"); Console.WriteLine(System.FormattableString.Invariant( - $"[indoor-bsp] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} result={cellState} ") + $"[indoor-bsp] cell=0x{primaryCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} result={cellState} ") + polyDesc); } @@ -3314,7 +3311,10 @@ public sealed class Transition // Runs against the carried (outdoor) cell. The post-collision pick below then promotes // to an interior cell if the sphere has re-entered a building (replacing the removed // pre-pick's outdoor→indoor promotion). - var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y); + var terrainWalkable = engine.SampleTerrainWalkableInCell( + primaryCellId, + footCenter.X, + footCenter.Y); if (terrainWalkable is not null) { // Per-point water depth: 0.9 on fully water cells, 0.45 on partial- @@ -3355,7 +3355,21 @@ public sealed class Transition PhysicsEngine engine, Vector3 footCenter, float sphereRadius) { var sp = SpherePath; - if (engine.DataCache is null) return TransitionState.OK; + + // Test-only engines can omit the cell registry required for retail's + // find_cell_list. Preserve their outdoor membership update here at + // the post-primary boundary, never from inside the fixed-cell + // insert_into_cell transaction. + if (engine.DataCache is null) + { + uint resolvedOutdoorCellId = engine.ResolveCellId( + sp.GlobalSphere[0].Origin, + sphereRadius, + sp.CheckCellId); + if (resolvedOutdoorCellId != sp.CheckCellId) + sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); + return TransitionState.OK; + } // Retail check_other_cells (acclient_2013_pseudo_c.txt:272735) calls each // other cell's find_collisions with `this`, so it reads the CURRENT diff --git a/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs b/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs index eeaf753e..a643df16 100644 --- a/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs @@ -292,7 +292,7 @@ public class IndoorContactPlaneRetentionTests t.SpherePath.SetCheckPos(newPos, IndoorCellId); // Simulate FindEnvCollisions as the physics loop calls it. - t.FindEnvCollisions(engine); + t.FindEnvCollisions(engine, IndoorCellId); } // ── Assert ──────────────────────────────────────────────────────────── diff --git a/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs index b97985c1..446871ef 100644 --- a/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs @@ -190,39 +190,68 @@ public sealed class TransitionInsertIntoCellRetryTests } [Fact] - public void NoDataCache_CrossLandblockRepick_UsesRefreshedPrimaryCell() + public void NestedRetry_FixesPrimaryCellUntilNextOuterAttempt() { - PhysicsEngine engine = BuildCrossLandblockEngine(); + PhysicsEngine engine = BuildEngine(preparedFlat: false); var cells = new List(); var phases = new List(); + int environmentCalls = 0; + const uint nextOuterCell = 0xA9B40002u; engine.TransitionCellCollisionTestHook = - (_, phase, cellId, actual) => + (transition, phase, cellId, actual) => { phases.Add(phase); cells.Add(cellId); + + if (phase == TransitionCellCollisionPhase.Environment) + { + environmentCalls++; + if (environmentCalls == 1) + { + transition.SpherePath.SetCheckPos( + transition.SpherePath.CheckPos, + nextOuterCell); + } + + if (environmentCalls <= 3) + return TransitionState.Adjusted; + } + return actual; }; - Vector3 current = new(191.99f, 10f, 5f); - Vector3 target = new(192.01f, 10f, 5f); + Vector3 current = new(10f, 10f, 5f); + Vector3 target = current + new Vector3(0.05f, 0f, 0f); ResolveResult result = ResolveAirborne( engine, current, target, - 0xA9B40039u); + Cell); Assert.True(result.Ok); Assert.Equal(target, result.Position); - Assert.Equal(0xAAB40001u, result.CellId); Assert.Equal( new[] { + TransitionCellCollisionPhase.Environment, + TransitionCellCollisionPhase.Environment, + TransitionCellCollisionPhase.Environment, TransitionCellCollisionPhase.Environment, TransitionCellCollisionPhase.Building, TransitionCellCollisionPhase.Objects, }, phases); - Assert.All(cells, cellId => Assert.Equal(0xAAB40001u, cellId)); + Assert.Equal( + new[] + { + Cell, + Cell, + Cell, + nextOuterCell, + nextOuterCell, + nextOuterCell, + }, + cells); } private static ResolveResult ResolveAirborne( @@ -309,26 +338,4 @@ public sealed class TransitionInsertIntoCellRetryTests return engine; } - private static PhysicsEngine BuildCrossLandblockEngine() - { - var engine = new PhysicsEngine { DataCache = null }; - var heights = new byte[81]; - var heightTable = new float[256]; - Array.Fill(heightTable, -1000f); - engine.AddLandblock( - 0xA9B4FFFFu, - new TerrainSurface(heights, heightTable), - Array.Empty(), - Array.Empty(), - 0f, - 0f); - engine.AddLandblock( - 0xAAB4FFFFu, - new TerrainSurface(heights, heightTable), - Array.Empty(), - Array.Empty(), - 192f, - 0f); - return engine; - } } From c559c48d80dcc55d24e72d66bbb3310947f553dc Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 12:55:49 +0200 Subject: [PATCH 14/73] fix(physics): restore retail edge-response ordering --- .../retail-divergence-register.md | 12 +- ...0-response-layer-edge-family-pseudocode.md | 63 ++- src/AcDream.Core/Physics/TransitionTypes.cs | 234 +++-------- .../RetailEdgeResponseOrderingTests.cs | 396 ++++++++++++++++++ 4 files changed, 525 insertions(+), 180 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6ebad661..0b588c86 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -1,4 +1,4 @@ -# Retail Divergence Register — current through 2026-07-30 +# Retail Divergence Register — current through 2026-07-31 **What this is.** The single auditable register of every known place acdream's runtime behavior can deviate from the retail client (Sept 2013 EoR build, @@ -66,8 +66,8 @@ accepted-divergence entries (#96, #49, #50). | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AD-53 | `Transition.CliffSlide`'s reference-normal cross-product operand tries THREE sources in priority order (`LastWalkablePlane` if `Normal.Z >= FloorZ`, then `LastKnownContactPlane` at the same threshold, then world-up `UnitZ`); retail's `CTransition::cliff_slide` uses `this->collision_info.last_known_contact_plane.N` directly, with no fallback chain | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`, the `referenceNormal`/`refSource` selection above the cross-product) | Filed 2026-07-30 splitting TS-1's retirement (Campaign P Slice P2). A fresh read of `last_known_contact_plane`'s own maintenance (pc:272659-272668) confirms retail overwrites it unconditionally from `contact_plane` every `validate_transition` pass — including with a steep plane — so retail keeps NO separately-preserved flat-ground history there either; this fallback chain is a genuine acdream invention, not a retail-matching read. Kept because it compensates for AP-4's incomplete `OnWalkable` bookkeeping (L.4-cliffslide-fallback, 2026-04-30): without it, `cross(currentSteep, lastKnownSteep)` degenerates to zero after >1 frame on a continuous steep slope, and CliffSlide returns `OK` (no deflection) instead of downhill drift — the "stay on the roof" wedge the L.4 session fought | If AP-4's `OnWalkable` reordering is ever completed/removed, `last_known_contact_plane` should carry the same information retail's does and this fallback chain becomes unneeded ballast (or, worse, silently picks a stale `LastWalkablePlane` over the now-correct current one) — re-audit together with AP-4 | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2 gap #2 | -| AD-54 | `Transition.EdgeSlideAfterStepDownFailed` reroutes to `CliffSlide` instead of `PrecipiceSlide` when the stored walkable polygon itself is steeper than `FloorZ` (`sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ`); retail's raw `SPHEREPATH::edge_slide` has no steepness branch here — `if (walkable != null) { ... precipice_slide(...) }` unconditionally | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`, the `L.4-walkable-steep` block) | Filed 2026-07-30 splitting TS-1's retirement (Campaign P Slice P2). The permissive `LandingZ` walkable-acceptance threshold that lets a steep roof become "walkable" in the first place IS confirmed retail-faithful (`BSPTREE::find_collisions` unconditional `walkable_allowance = LandingZ`, pc:323740-323783, TS-4's own citation) — so a steep-roof walkable polygon is a real state retail also reaches. What is NOT independently verified is whether retail's outer `transitional_insert` caller absorbs a same-polygon-standing `COLLIDED_TS` from `precipice_slide` (its raw `find_crossed_edge` returning false while standing on, not crossing, the polygon) some other way that avoids the acdream "stuck in a Collided revert loop" this reroute prevents | If retail's outer retry loop turns out to already handle the no-crossed-edge-while-standing-on-a-steep-poly case without a reroute, this branch is an unnecessary compensating layer that could route a genuinely PrecipiceSlide-bound case (a shallow polygon edge that happens to sit at exactly `FloorZ`) into CliffSlide instead | `SPHEREPATH::edge_slide` pc:273001-273090 (0050b3d0, direct walkable branch quoted at pc:364-370 in the P2 research doc); `BSPTREE::find_collisions` pc:323740-323783 (0053a730, unconditional `LandingZ`); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2 gap #3 | +| ~~AD-53~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `Transition.CliffSlide` now consumes only `collision_info.last_known_contact_plane.N`, exactly as retail does. The invented `LastWalkablePlane -> LastKnownContactPlane -> UnitZ` fallback chain is gone; invalid/default or parallel data takes retail's degenerate `OK_TS` return. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07) | +| ~~AD-54~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** Every stored walkable polygon now routes unconditionally to `PrecipiceSlide`, including a plane steeper than `FloorZ`; the invented steep-walkable reroute to `CliffSlide` is gone. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` pc:273001-273090 (0050b3d0) | | AD-55 | `calc_friction`'s Sledding slope-flatness test compares `GroundNormal.Z > 0.99999536f` (≈0.175° from flat); the raw retail decomp literally computes `__fcos(0.17453292519943295)` (= cos(10°) ≈ 0.984808) and compares that against `contact_plane.N.z` — physically very different tests (0.175° accepts only essentially-perfectly-flat ground; 10° accepts any modest slope) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`, the Sledding near-flat branch) | Filed 2026-07-30 splitting AP-7's retirement (Campaign P Slice P2). Two hypotheses, neither confirmed this pass: (a) BN misdecompiled a raw float-constant load as an `__fcos()` call (a known BN artifact class), or (b) ACE's own port made an independent error and cos(10°) is correct. `0.99999536f` is kept provisionally — least churn, since it is what acdream's own prior (structurally unreachable) dead code already had — pending a live Ghidra decompile of `0050ee70` checking whether the FCOS opcode is real or a raw `FLD` of one of these two constants | Currently harmless in production: nothing sets `PhysicsState.Sledding` client-side (see #166 research), so this branch is unreachable either way. The moment a data-authored Sledding toggle exists, the wrong constant changes which slopes get the light 0.2f sled-friction override vs. the heavier default | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70), the `__fcos(0.17453292519943295)` slope-flatness comparison; ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141 (`0.99999536f`); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1, §7 item 3 | | AD-46 | **LIVE. Reframed at Campaign V slice V11 (2026-07-29), when GL was deleted and the comparison that discovered this row ceased to exist.** Dense alpha-blended distant scenery (the treeline) may read slightly denser than retail's, because the anisotropic TAP PATTERN is implementation-defined and acdream's Vulkan driver does not tap identically to retail's D3D9 one. Both request the same sampler state — trilinear, clamp-and-repeat, the device's maximum anisotropy. **What changed at V11 is only the left-hand side of the comparison**: this was measured GL-vs-Vulkan (~15% of the pixels in the band), and it is now a Vulkan-vs-retail question against the D3D oracle in the last column. The measurement below is retained as the evidence that the residual is a tap pattern and not a bug, even though one of its two arms no longer exists. | `src/AcDream.App/Rendering/Wb/WorldTextureArray.cs` (`RhiWorldTextureArray.WorldArrayAnisotropy`); measured in plan §5.5.19, reframed §5.5.24 | Not assumed — narrowed by measurement while both backends still existed, on an offline capture with no session, no entities and both clocks pinned. Anisotropy 1 → 41,509 differing pixels in the tree band; anisotropy 16 (GL's value, and retail's `m_D3DCaps.MaxAnisotropy`) → 22,266, and the rest of the frame fell to 497 px of 563,200, i.e. 8.8e-04, inside the campaign's 0.001 threshold. The residual was not a sub-pixel shift (an integer shift search found none), not a sharpness change (high-frequency energy matched within 5%), and not depth precision (forcing Vulkan's window-depth range to GL's compressed [0.5, 1] moved it by 3%). Monotone improvement toward GL's own anisotropy with no knob left is what made it a driver property rather than a bug. | Distant foliage shimmers or reads denser than retail's. The class is confined to alpha-blended dense overlap: opaque terrain, roofs, walls, water, statics, the character and the whole retained UI are inside threshold. **Now unfalsifiable by self-differential** — with GL gone, the only way to retire this row is a side-by-side against the retail client, not against another acdream backend. | `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`, whose `SetSamplerState(stage, 0xA /* D3DSAMP_MAXANISOTROPY */, m_D3DCaps.MaxAnisotropy)` at `0x005a4230` is the value acdream requests | | AD-47 | **Filed at Campaign V slice V11 (2026-07-29); the campaign's risk register scheduled this row here.** Multisample resolve sample POSITIONS are unspecified by both the Vulkan and D3D9 specifications, so acdream's MSAA-on silhouette edges do not match retail's pixel-for-pixel even at the same sample count. acdream's strict pixel gates therefore run with MSAA forced OFF on every arm, and MSAA-on gets only a relaxed visual smoke. | `src/AcDream.App/RuntimeOptions.cs` (`ACDREAM_MSAA_SAMPLES`); forced to 0 in `tools/run-offline-pixel-gate.ps1` | Measured, not assumed: plan §5.5.16 compared two backends at 4x and found **8.83% of the frame differing — 81,359 px of 921,600 — essentially all of it hugging foliage and silhouette edges**, which is ninety-fold over the 0.001 gate threshold. That is two implementations' sample patterns, not a renderer divergence, which is why forcing MSAA off is what makes the remaining difference attributable rather than a threshold relaxation. | Edge quality on thin geometry (fence rails, foliage, distant railings) differs from retail at the sub-pixel level whenever MSAA is on, which is the ordinary player configuration. Because the gates run MSAA off, **a real regression confined to the multisample path would not be caught by them** — that is the actual exposure this row records. | D3D9 `D3DRS_MULTISAMPLEANTIALIAS` / `D3DMULTISAMPLE_TYPE` as set by `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`; retail's sample pattern is the driver's, exactly as ours is | @@ -118,7 +118,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 90 active rows (AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 88 active rows (AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -128,8 +128,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. |---|---|---|---|---|---| | AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position | -| AP-3 | Step-down chain also runs for a valid contact plane when that plane is steeper than walkable; retail's `transitional_insert` OK-path returns immediately for every valid contact plane and enters the step-down tail only when contact is invalid | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`) | The added steep-contact entry preserves the current cliff-slide compensation while the response-layer state/order family remains open (AP-4/AD-53/AD-54/TS-4) | A steep valid contact can enter step-down/edge response where retail restores or validates state through its normal contact path, producing different retry and slide behavior | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | -| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) | +| ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | +| ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 | | AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | | ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` | | ~~AP-10~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the retail 0.1 m dry-corner water sink-in is restored.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs`) now returns 0.1 for a partially-water cell's dry corner instead of the collapsed 0. The row's own "destabilizes the touch check" justification turned out to be structurally true of retail too (a skipped `SetContactPlane` reassertion is not a fall in ANY of retail/ACE/acdream, because `Contact`/`OnWalkable` are STICKY — `PhysicsEngine.ResolveWithTransition`'s `onGround` computation ORs the fresh per-call `ContactPlaneValid` with the seeded, persistent `PhysicsBody.TransientState.OnWalkable` bit) — traced and confirmed in this slice; see `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.2. `PhysicsEngine.SampleTerrainWalkable`'s `isWater = waterDepth >= 0.45f` threshold means the restore does not flip the dry corner's water classification (0.1 still < 0.45) — only the sink-in depth changes. Full Release suite green (no regression) proves the sticky-bit argument held in practice, not just in theory. | `src/AcDream.Core/Physics/TerrainSurface.cs` (`SampleWaterDepth`) | — | — | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port); `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.1-5.2 | diff --git a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md index ff83cf7c..b284efa4 100644 --- a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md +++ b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md @@ -1,7 +1,7 @@ # P2 — Collision response-layer edge family: port-ready pseudocode -**Status: RESEARCH PASS COMPLETE (2026-07-30); IMPLEMENTATION PASS PARTIAL -(2026-07-30).** Originally a research-only doc for Campaign P Slice P2 +**Status: RESEARCH PASS COMPLETE (2026-07-30); RETAIL RESPONSE ORDER PORTED +(2026-07-31).** Originally a research-only doc for Campaign P Slice P2 (`docs/plans/2026-07-29-physics-parity-campaign.md` §P2); a same-day implementation session landed TS-1's retirement and AP-7's fix, attempted TS-4 per this doc's own §6 Step 3 fixture-first order, reproduced the @@ -24,7 +24,10 @@ unreachable from Path 6's unconditional `SetCollide`, which returns `Adjusted` without repositioning the sphere) and what a future attempt needs to check first; #116 remains a genuine oracle-first research item needing live cdb/Ghidra, not an implementation item (see §5). Read §6 (port -order) before starting further implementation on this family. +order) before starting further implementation on this family. Campaign P +Slice 1B subsequently performed that fresh `transitional_insert` read and +removed AP-3, AP-4, AD-53, and AD-54; the exact closeout and controls are in +§8. TS-4 remains deliberately unchanged. Every claim below is tagged **FACT** (grep/read-verified against the named-retail decomp, the register, ISSUES.md, or current acdream source @@ -72,12 +75,11 @@ These bind the P2 implementer. Do not re-attempt any of these shapes. once already (2026-06-12) for the EPSILON-vs-EpsilonSq bug; Ghidra MCP is DOWN for this research pass — mark any residual x87-ambiguous claim Ghidra-verify, cite ACE as the fallback tiebreaker, do not silently guess. -9. **AP-4 (CliffSlide check moved before retail's Branch-1 gate)** is a - live, load-bearing reordering compensating for acdream's incomplete - OnWalkable bookkeeping — touches the same code region as TS-1. Do not - revert AP-4's reordering without re-verifying OnWalkable is complete; - read AP-4's full row before changing `TransitionTypes.cs:1316` control - flow. +9. **SUPERSEDED 2026-07-31 by Campaign P Slice 1B.** AP-4's CliffSlide-first + compensation was removed only after the complete retail + `transitional_insert`/`edge_slide` order was read and branch-order plus + graph/flat multi-frame roof/ledge controls passed. Do not reintroduce the + compensation; see §8 and the retired AP-4 row. 10. **TS-46 (two-scalar sphere reconstruction) is OUT OF SCOPE for P2** (it's P3) but shares files (`TransitionTypes.cs` `InitPath`) — do not fold TS-46 sphere-list work into a P2 commit. @@ -1066,3 +1068,46 @@ it blocks. behavior." --- + +## 8. Campaign P Slice 1B closeout — exact response ordering (2026-07-31) + +The follow-up read used the complete named-retail bodies, not the earlier +excerpt summaries: + +- `CTransition::transitional_insert` at `0x0050B6F0` + (pseudo-C:273137 onward) returns `OK_TS` as soon as + `contact_plane_valid != 0`. Only an invalid contact reaches the ordinary + StepDown tail, whose remaining gates are Contact, + `!sphere_path.step_down`, a non-null check cell, and ObjectInfo.StepDown. +- Its StepDown schedule is asymmetric by authored sphere count. For a + one-sphere mover whose requested height exceeds the foot diameter, retail + clamps the probe to half the foot radius and performs one probe. Otherwise + a request within the diameter probes once; an over-diameter request on a + two-sphere mover is halved and probes twice in sequence. +- `CTransition::edge_slide` at `0x0050B3D0` + (pseudo-C:273001-273090) runs `!OnWalkable || !EdgeSlide` restore-and-OK + before its steep-contact CliffSlide branch. Any stored walkable polygon + routes to PrecipiceSlide without a steepness test. +- `CTransition::cliff_slide` at `0x0050A6D0` + (pseudo-C:272397 onward) crosses the supplied contact normal only with + `collision_info.last_known_contact_plane.N`. It has no remembered-walkable + or world-up substitute. A default, invalid, parallel, or otherwise + degenerate cross naturally returns `OK_TS` through the retail normalization + guard. + +`TransitionTypes.cs` now follows that order exactly. AP-3, AP-4, AD-53, and +AD-54 are retired together. The implementation deliberately preserves the +existing ordinary-tail `runPlacement: false` choice and does not alter TS-4's +Path-6 steep-polygon shortcut. + +`RetailEdgeResponseOrderingTests` pins every distinguishing branch: valid +steep-contact early return, the one/two-sphere probe schedule, +not-OnWalkable-before-CliffSlide, last-known-only source selection, degenerate +last-known handling, and stored-steep-walkable-to-Precipice routing. It also +runs multi-frame steep-roof and flat-roof-edge controls through both parsed +graph and prepared-flat collision traversal, requires exact trace parity, and +rejects a greater-than-15-tick frozen streak. The earlier dedicated +`Ts4SteepRoofWedgeCaptureTests` remains green, so retiring these four +compensations did not require weakening or deleting the TS-4 control. + +--- diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index f98776a0..6889b0af 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1733,6 +1733,40 @@ public sealed class Transition PhysicsEngine engine) => TransitionalInsert(numAttempts, engine); + /// + /// Retail's one-versus-two-sphere step-down schedule from + /// CTransition::transitional_insert (0050b889-0050b8f0). + /// A one-sphere mover whose requested drop exceeds its diameter is + /// clamped to half its radius and probes once. A two-sphere mover keeps + /// the requested height and splits an over-diameter drop into two equal + /// probes. + /// + internal static (float ProbeHeight, int ProbeCount) GetStepDownProbePlan( + int numSpheres, + float sphereRadius, + float requestedHeight) + { + float diameter = sphereRadius * 2f; + float probeHeight = requestedHeight; + + if (numSpheres < 2 && diameter < probeHeight) + probeHeight = sphereRadius * 0.5f; + + if (diameter >= probeHeight) + return (probeHeight, 1); + + return (probeHeight * 0.5f, 2); + } + + internal TransitionState EdgeSlideAfterStepDownFailedForTest( + PhysicsEngine engine, + float stepDownHeight, + float zVal) + => EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal); + + internal TransitionState CliffSlideForTest(Plane contactPlane) + => CliffSlide(contactPlane); + private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine) { if (SpherePath.CheckCellId == 0) return TransitionState.OK; @@ -1987,33 +2021,14 @@ public sealed class Transition } } - // Handle step-down when in contact but no ground plane found. - // This happens when the player is on a slope edge: they're marked - // as in contact with the ground, but the current CheckPos has no - // terrain contact (walked off an edge). Attempt a step-down to - // maintain ground contact. - // - // L.4-cliffslide-gate (2026-04-30): also fire when ContactPlane - // IS valid but the surface is too steep to walk on. This is the - // "player standing on a steep roof / steep terrain" case. Phase 1 - // sets ContactPlane on the slope (geometric touch is enough — no - // walkable check), so without this clause the step-down branch - // skips and EdgeSlideAfterStepDownFailed never gets the chance to - // call CliffSlide. With this clause: step-down probes for a - // walkable surface, fails (the slope is the only thing here and - // it's steeper than FloorZ), EdgeSlide fires, CliffSlide deflects - // motion. Then gravity does the rest of the downhill drift. - // - // Retail's transitional_insert OK-path always runs the step-down - // chain (per agent reports of acclient_2013_pseudo_c.txt:273191). - // We approximate that by triggering it whenever the current contact - // is invalid OR steeper than walkable. - bool contactInvalidOrSteep = !ci.ContactPlaneValid - || ci.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ; - // L.4-diag (2026-04-30): trace why we don't slide down roofs. - DumpStepDownBranchGate(contactInvalidOrSteep); - if (contactInvalidOrSteep && oi.Contact && !sp.StepDown - && sp.CheckCellId != 0 && oi.StepDown) + // Retail returns immediately for every valid contact plane, + // including a steep one. The ordinary step-down tail is reached + // only when contact is invalid (0050b818-0050b844). + if (ci.ContactPlaneValid) + return TransitionState.OK; + + DumpStepDownBranchGate(contactInvalid: true); + if (oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown) { // L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable, // LandingZ when not. acdream was unconditionally LandingZ — @@ -2028,7 +2043,11 @@ public sealed class Transition sp.WalkableAllowance = zVal; sp.SaveCheckPos(); - float radsum = sp.GlobalSphere[0].Radius * 2f; + (float probeHeight, int probeCount) = GetStepDownProbePlan( + sp.NumSphere, + sp.GlobalSphere[0].Radius, + stepDownHeight); + stepDownHeight = probeHeight; // L.2.3h (2026-04-29): pass runPlacement=false. This // branch's job is to maintain ground contact during normal @@ -2038,23 +2057,20 @@ public sealed class Transition // would fail Placement and trigger the L.2.3e edge-block, // leaving the player stuck near walls. DoStepUp still runs // Placement for the step-UP-through-walls protection. - if (radsum >= stepDownHeight) + bool steppedDown = false; + for (int probe = 0; probe < probeCount; probe++) { if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)) { - sp.ClearWalkable(); - return TransitionState.OK; + steppedDown = true; + break; } } - else + + if (steppedDown) { - stepDownHeight *= 0.5f; - if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false) - || DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)) - { - sp.ClearWalkable(); - return TransitionState.OK; - } + sp.ClearWalkable(); + return TransitionState.OK; } // L.2c (2026-04-30): step-down failed — the move would put @@ -2205,38 +2221,9 @@ public sealed class Transition var ci = CollisionInfo; var oi = ObjectInfo; - // L.4-cliffslide-priority (2026-04-30): the steep-ContactPlane check - // moved BEFORE the OnWalkable/EdgeSlide gate. - // - // Why: by the time this dispatch runs on subsequent frames (player - // standing on a steep slope), ValidateTransition's L.2.3i FloorZ - // test has already CLEARED OnWalkable (steep slope → not a walkable - // surface). The original Branch 1 (`!OnWalkable → restore + OK`) - // therefore fires every frame, stopping the player dead — exactly - // the "stay on the roof" symptom the user reported. - // - // Re-ordering: if the surface is too steep AND we have a contact - // plane on it, run CliffSlide regardless of OnWalkable. The - // cross(currentNormal, lastKnownNormal) deflection plus gravity - // produces visible downhill drift each frame. - // - // Branch 1 (the !OnWalkable stop) still fires when we DON'T have - // a contact plane — the original "walked off into thin air" - // case, which should still stop or fall normally rather than - // CliffSlide on nothing. - if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal && oi.EdgeSlide) - { - var cliffPlane = ci.ContactPlane; - DumpEdgeSlideBranch("priority/steep-cliffslide", zVal); - sp.ClearWalkable(); - sp.RestoreCheckPos(); - ci.ContactPlaneValid = false; - ci.ContactPlaneIsWater = false; - return CliffSlide(cliffPlane); - } - - // Retail lets non-EdgeSlide movers continue over the boundary. Player - // movement carries EdgeSlide, so the local avatar takes the slide path. + // Retail Branch 1 is first: a mover that is not OnWalkable or does + // not carry EdgeSlide restores the saved candidate and returns OK. + // No steep-plane exception precedes this gate (0050b3d8-0050b3e7). if (!oi.OnWalkable || !oi.EdgeSlide) { DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal); @@ -2272,49 +2259,8 @@ public sealed class Transition // rapidly down the stairs. Do not restore stale history here. if (sp.HasWalkablePolygon) { - // L.4-walkable-steep (2026-04-30): the stored Walkable polygon - // can be a too-steep surface (e.g., a roof the player jumped - // onto — Path 4's airborne-landing branch uses LandingZ, the - // permissive 0.087 threshold, so steep roofs get accepted as - // "walkable" for the landing). On subsequent frames the player - // is STANDING ON that polygon, not crossing its edge, so - // PrecipiceSlide's find_crossed_edge returns false and the - // player gets stuck in a Collided revert loop. - // - // Detect the case: if the walkable polygon's plane is steeper - // than FloorZ, route to CliffSlide using that plane instead of - // PrecipiceSlide. CliffSlide deflects motion along the ridge - // between current-steep and last-known-walkable; gravity then - // produces visible downhill drift. - // - // TS-1 gap #3 (register AD-54, Campaign P Slice P2 2026-07-30): - // retail's raw SPHEREPATH::edge_slide has NO steepness branch here - // — `if (walkable != null) { ... precipice_slide(...) }` unconditionally - // (acclient_2013_pseudo_c.txt:364-370 per the P2 research quote). The - // LandingZ permissive acceptance itself IS retail-faithful — confirmed - // by TransitionalInsert's own Path-4 Collide branch - // (TransitionTypes.cs, `DoCheckWalkable(PhysicsGlobals.LandingZ, engine)` - // above) and TS-4's BSPTREE::find_collisions read - // (pc:323740-323783: `sphere_path.walkable_allowance = LandingZ` - // unconditionally, no slope test) — so a steep roof really is - // "walkable" in retail too. What is NOT independently verified from - // the raw decomp is whether retail's OUTER caller (transitional_insert) - // absorbs a same-polygon-standing Collided from precipice_slide via - // its own retry loop rather than needing this reroute; see - // docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §2 - // gap #3. - if (sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ) - { - var cliffPlane = sp.WalkablePlane; - DumpEdgeSlideBranch("walkable-poly-steep-cliffslide", zVal); - sp.ClearWalkable(); - sp.RestoreCheckPos(); - ci.ContactPlaneValid = false; - ci.ContactPlaneIsWater = false; - return CliffSlide(cliffPlane); - } - DumpEdgeSlideBranch("branch3/precipice-slide", zVal); + sp.RestoreCheckPos(); ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; return sp.PrecipiceSlide(this); @@ -2382,54 +2328,12 @@ public sealed class Transition var sp = SpherePath; var ci = CollisionInfo; - // L.4-cliffslide-fallback (2026-04-30): use the LAST WALKABLE plane - // as the cross-product reference, falling back to world-up when no - // walkable history is available. Without this, when the player has - // been on a steep slope for >1 frame, ValidateTransition's L.2.3i - // FloorZ test propagates the steep plane into LastKnownContactPlane, - // so cross(currentSteep, lastKnownSteep) = 0 → degenerate, no - // deflection. Using LastWalkable preserves the prior flat-ground - // plane across continuous-slope frames; world-up gives a guaranteed - // non-zero deflection when no walkable history exists at all. - // - // TS-1 gap #2 (register AD-53, Campaign P Slice P2 2026-07-30): retail's - // raw CTransition::cliff_slide (pc:272397, 0050a6d0) uses - // this->collision_info.last_known_contact_plane.N DIRECTLY as the second - // cross-product operand — no fallback chain. Confirmed by a fresh read of - // last_known_contact_plane's own maintenance - // (acclient_2013_pseudo_c.txt:272659-272668, pc ~0050ad07): retail - // overwrites last_known_contact_plane from contact_plane UNCONDITIONALLY - // on every validate_transition pass, the same "gets overwritten by - // whatever's current, including a steep plane" behavior this file's - // ContactPlane/LastKnownContactPlane tracking already has — retail does - // NOT maintain a separately-preserved flat-ground history there either. - // This three-source chain (LastWalkablePlane -> LastKnownContactPlane -> - // UnitZ) is therefore a genuine acdream invention, not a retail-matching - // read — kept because it compensates for AP-4's incomplete OnWalkable - // bookkeeping (see DO-NOT-RETRY item 9 in - // docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §0) - // and removing it reintroduces the degenerate-cross "stay on the roof" - // wedge the L.4 session (2026-04-30) fought. See that doc's §2 gap #2. - Vector3 referenceNormal; - string refSource; - if (sp.HasLastWalkablePolygon && sp.LastWalkablePlane.Normal.Z >= PhysicsGlobals.FloorZ) - { - referenceNormal = sp.LastWalkablePlane.Normal; - refSource = "last-walkable"; - } - else if (ci.LastKnownContactPlaneValid && ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ) - { - referenceNormal = ci.LastKnownContactPlane.Normal; - refSource = "last-known-walkable"; - } - else - { - // Fallback: world up. cross(steepNormal, UnitZ) gives the - // ridge direction (horizontal contour line of the slope). - // collideNormal then becomes the downhill horizontal axis. - referenceNormal = Vector3.UnitZ; - refSource = "world-up-fallback"; - } + // Retail CTransition::cliff_slide (0050a6d0) consumes the raw + // last-known contact normal directly. It does not substitute the + // remembered walkable plane or world-up. An invalid/default or + // parallel normal naturally takes normalize_check_small's + // degenerate OK return below. + Vector3 referenceNormal = ci.LastKnownContactPlane.Normal; Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal); contactNormal.Z = 0f; @@ -2437,7 +2341,7 @@ public sealed class Transition Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f); if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) { - DumpCliffSlide($"degenerate-cross/{refSource}", contactPlane, + DumpCliffSlide("degenerate-cross/last-known", contactPlane, new Plane(referenceNormal, 0f), contactNormal, 0f, false); return TransitionState.OK; } @@ -2446,7 +2350,7 @@ public sealed class Transition Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin; float angle = Vector3.Dot(collideNormal, offset); - DumpCliffSlide($"ok/{refSource}", contactPlane, + DumpCliffSlide("ok/last-known", contactPlane, new Plane(referenceNormal, 0f), collideNormal, angle, true); if (angle <= 0f) @@ -2481,7 +2385,7 @@ public sealed class Transition /// skipped the contact-recovery branch matters for whether CliffSlide /// has any chance of firing. /// - private void DumpStepDownBranchGate(bool contactInvalidOrSteep) + private void DumpStepDownBranchGate(bool contactInvalid) { if (!DumpEdgeSlideEnabled) return; @@ -2489,7 +2393,7 @@ public sealed class Transition var ci = CollisionInfo; var oi = ObjectInfo; - bool wouldEnter = contactInvalidOrSteep && oi.Contact && !sp.StepDown + bool wouldEnter = contactInvalid && oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown; if (!wouldEnter) return; // only log when entering, to keep noise low diff --git a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs new file mode 100644 index 00000000..d8a23835 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Pins the branch order in retail CTransition::transitional_insert, +/// CTransition::edge_slide, and CTransition::cliff_slide. +/// These cases distinguish the retail implementation from the four former +/// acdream compensations tracked as AP-3, AP-4, AD-53, and AD-54. +/// +public sealed class RetailEdgeResponseOrderingTests +{ + private const uint Cell = 0xA9B40001u; + + [Fact] + public void TransitionalInsert_ValidSteepContact_ReturnsBeforeOrdinaryStepDownTail() + { + Vector3 current = new(2f, 3f, 4f); + Vector3 target = current + new Vector3(0.1f, 0f, 0f); + var transition = BSPStepUpFixtures.MakeGroundedTransition(current, target, cellId: Cell); + transition.ObjectInfo.State |= ObjectInfoState.EdgeSlide; + transition.ObjectInfo.StepDown = true; + transition.ObjectInfo.StepDownHeight = 2f; + + Vector3 untouchedBackup = new(97f, 98f, 99f); + const uint untouchedBackupCell = 0xA9B40044u; + transition.SpherePath.BackupCheckPos = untouchedBackup; + transition.SpherePath.BackupCheckCellId = untouchedBackupCell; + + var steep = new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.25f)), 0f); + var engine = new PhysicsEngine + { + TransitionCellCollisionTestHook = (candidate, phase, _, actual) => + { + if (phase == TransitionCellCollisionPhase.Objects) + candidate.CollisionInfo.SetContactPlane(steep, Cell, isWater: true); + return actual; + }, + }; + + TransitionState result = transition.TransitionalInsertForTest(1, engine); + + Assert.Equal(TransitionState.OK, result); + Assert.True(transition.CollisionInfo.ContactPlaneValid); + Assert.True(transition.CollisionInfo.ContactPlaneIsWater); + Assert.Equal(steep, transition.CollisionInfo.ContactPlane); + Assert.Equal(untouchedBackup, transition.SpherePath.BackupCheckPos); + Assert.Equal(untouchedBackupCell, transition.SpherePath.BackupCheckCellId); + } + + [Theory] + [InlineData(1, 0.5f, 2.0f, 0.25f, 1)] + [InlineData(2, 0.5f, 2.0f, 1.00f, 2)] + [InlineData(1, 0.5f, 0.75f, 0.75f, 1)] + [InlineData(2, 0.5f, 0.75f, 0.75f, 1)] + public void StepDownProbePlan_PreservesRetailOneVersusTwoSphereSplit( + int sphereCount, + float radius, + float requestedHeight, + float expectedProbeHeight, + int expectedProbeCount) + { + (float probeHeight, int probeCount) = Transition.GetStepDownProbePlan( + sphereCount, + radius, + requestedHeight); + + Assert.Equal(expectedProbeHeight, probeHeight); + Assert.Equal(expectedProbeCount, probeCount); + } + + [Fact] + public void EdgeSlide_NotOnWalkableSteepContact_RestoresBeforeCliffSlide() + { + var transition = MakeFailedStepDownTransition(); + transition.ObjectInfo.State = ObjectInfoState.EdgeSlide; + transition.CollisionInfo.ContactPlaneValid = true; + transition.CollisionInfo.ContactPlane = + new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.25f)), 0f); + transition.CollisionInfo.ContactPlaneIsWater = true; + transition.CollisionInfo.LastKnownContactPlaneValid = true; + transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f); + + Vector3 failedCandidate = transition.SpherePath.BackupCheckPos; + TransitionState result = transition.EdgeSlideAfterStepDownFailedForTest( + new PhysicsEngine(), + stepDownHeight: 0.04f, + zVal: PhysicsGlobals.FloorZ); + + Assert.Equal(TransitionState.OK, result); + Assert.Equal(failedCandidate, transition.SpherePath.CheckPos); + Assert.False(transition.CollisionInfo.ContactPlaneValid); + Assert.False(transition.CollisionInfo.ContactPlaneIsWater); + Assert.False(transition.CollisionInfo.CollisionNormalValid); + } + + [Fact] + public void CliffSlide_UsesOnlyLastKnownContactPlaneNormal() + { + var transition = MakeFailedStepDownTransition(); + + // A qualifying remembered walkable normal deliberately points along Y. + // The former AD-53 fallback consumed it; retail consumes the explicit + // last-known contact normal below and therefore resolves along -X. + Plane rememberedWalkable = new(Vector3.Normalize(new Vector3(0f, 1f, 1f)), 0f); + transition.SpherePath.SetWalkable( + rememberedWalkable, + SquareOnPlaneZ0(), + Vector3.UnitZ); + transition.SpherePath.ClearWalkable(); + + transition.CollisionInfo.LastKnownContactPlaneValid = true; + transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f); + Plane steepContact = new(Vector3.Normalize(new Vector3(1f, 0f, 0.5f)), 0f); + + TransitionState result = transition.CliffSlideForTest(steepContact); + + Assert.Equal(TransitionState.Adjusted, result); + Assert.True(transition.CollisionInfo.CollisionNormalValid); + Assert.True(Vector3.Distance(-Vector3.UnitX, transition.CollisionInfo.CollisionNormal) < 0.0001f); + } + + [Fact] + public void CliffSlide_InvalidDefaultLastKnownPlane_TakesDegenerateOkReturn() + { + var transition = MakeFailedStepDownTransition(); + transition.CollisionInfo.LastKnownContactPlaneValid = false; + transition.CollisionInfo.LastKnownContactPlane = default; + Plane steepContact = new(Vector3.Normalize(new Vector3(1f, 0f, 0.5f)), 0f); + + TransitionState result = transition.CliffSlideForTest(steepContact); + + Assert.Equal(TransitionState.OK, result); + Assert.False(transition.CollisionInfo.CollisionNormalValid); + } + + [Fact] + public void EdgeSlide_StoredSteepWalkable_AlwaysRoutesToPrecipiceSlide() + { + var transition = MakeFailedStepDownTransition(); + transition.ObjectInfo.State = + ObjectInfoState.Contact | ObjectInfoState.OnWalkable | ObjectInfoState.EdgeSlide; + transition.CollisionInfo.ContactPlaneValid = false; + transition.CollisionInfo.LastKnownContactPlaneValid = true; + transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f); + + Vector3 steepNormal = Vector3.Normalize(new Vector3(-2f, 0f, 1f)); + var steepPlane = new Plane(steepNormal, 0f); + Vector3[] steepQuad = + [ + new(0f, -1f, 0f), + new(1f, -1f, 2f), + new(1f, 1f, 2f), + new(0f, 1f, 0f), + ]; + transition.SpherePath.SetWalkable(steepPlane, steepQuad, Vector3.UnitZ); + + Vector3 failedCandidate = new(0.5f, 0f, 1f); + transition.SpherePath.SetCheckPos(failedCandidate, Cell); + transition.SpherePath.SaveCheckPos(); + transition.SpherePath.AddOffsetToCheckPos(new Vector3(0f, 0f, -0.25f)); + + TransitionState result = transition.EdgeSlideAfterStepDownFailedForTest( + new PhysicsEngine(), + stepDownHeight: 0.04f, + zVal: PhysicsGlobals.FloorZ); + + // The restored point is inside the remembered polygon, so retail's + // unconditional PrecipiceSlide returns Collided. AD-54's steep-plane + // reroute instead returned Adjusted through CliffSlide. + Assert.Equal(TransitionState.Collided, result); + Assert.Equal(failedCandidate, transition.SpherePath.CheckPos); + Assert.False(transition.SpherePath.HasWalkablePolygon); + Assert.False(transition.CollisionInfo.CollisionNormalValid); + } + + [Fact] + public void MultiFrameSteepRoof_GraphAndFlatTraversalRemainExactAndDoNotWedge() + { + Vector3[] graph = RunSteepRoofTrace(preparedFlat: false); + Vector3[] flat = RunSteepRoofTrace(preparedFlat: true); + + Assert.Equal(graph, flat); + Assert.Contains(graph, position => + position.X < 0f + && position.Z <= BSPStepUpFixtures.SphereRadius + 0.05f); + AssertNoLongFrozenStreak(graph, maximumTicks: 15); + } + + [Fact] + public void MultiFrameFlatRoofLedge_GraphAndFlatTraversalRemainExactAndSlideAlongEdge() + { + Vector3[] graph = RunFlatRoofLedgeTrace(preparedFlat: false); + Vector3[] flat = RunFlatRoofLedgeTrace(preparedFlat: true); + + Assert.Equal(graph, flat); + AssertNoLongFrozenStreak(graph, maximumTicks: 15); + Assert.True(graph[^1].Y > graph[0].Y + 0.25f, + $"The roof-edge control made no along-edge progress: {graph[0]} -> {graph[^1]}."); + } + + private static Transition MakeFailedStepDownTransition() + { + Vector3 current = Vector3.Zero; + Vector3 failedCandidate = new(1f, 0f, 0f); + var transition = BSPStepUpFixtures.MakeGroundedTransition( + current, + failedCandidate, + cellId: Cell); + transition.ObjectInfo.State |= ObjectInfoState.EdgeSlide; + transition.SpherePath.SetCheckPos(failedCandidate, Cell); + transition.SpherePath.SaveCheckPos(); + transition.SpherePath.AddOffsetToCheckPos(new Vector3(0f, 0f, -0.25f)); + return transition; + } + + private static Vector3[] SquareOnPlaneZ0() => + [ + new(-2f, -2f, 0f), + new( 2f, -2f, 0f), + new( 2f, 2f, 0f), + new(-2f, 2f, 0f), + ]; + + private static Vector3[] RunSteepRoofTrace(bool preparedFlat) + { + var fixture = BSPStepUpFixtures.SlopedUnwalkable(); + PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E101u); + float radius = BSPStepUpFixtures.SphereRadius; + const float dt = 1f / 30f; + const float gravity = -9.8f; + var body = new PhysicsBody { TransientState = TransientStateFlags.Active }; + Vector3 position = new(0.5f, 0f, 3f); + float velocityZ = 0f; + var trace = new List(91) { position }; + + for (int tick = 0; tick < 90; tick++) + { + velocityZ += gravity * dt; + ResolveResult result = engine.ResolveWithTransition( + position, + position + new Vector3(0f, 0f, velocityZ * dt), + Cell, + radius, + radius * 2f, + stepUpHeight: 0.30f, + stepDownHeight: 0.04f, + isOnGround: false, + body, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000000u); + + position = result.Position; + body.Position = position; + if (result.IsOnGround) + velocityZ = 0f; + trace.Add(position); + + if (position.X < 0f && position.Z <= radius + 0.05f) + break; + } + + return trace.ToArray(); + } + + private static Vector3[] RunFlatRoofLedgeTrace(bool preparedFlat) + { + var fixture = BSPStepUpFixtures.FlatRoof(); + PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E102u); + ResolvedPolygon roof = fixture.Resolved[BSPStepUpFixtures.FlatRoof_RoofId]; + float radius = BSPStepUpFixtures.SphereRadius; + Vector3 position = new(1.55f, -0.75f, 3f); + var body = new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + ContactPlaneValid = true, + ContactPlane = roof.Plane, + ContactPlaneCellId = Cell, + WalkablePolygonValid = true, + WalkablePlane = roof.Plane, + WalkableVertices = roof.Vertices, + WalkableUp = Vector3.UnitZ, + TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact + | TransientStateFlags.OnWalkable, + }; + var trace = new List(13) { position }; + + for (int tick = 0; tick < 12; tick++) + { + ResolveResult result = engine.ResolveWithTransition( + position, + position + new Vector3(0.12f, 0.08f, 0f), + Cell, + radius, + radius * 2f, + stepUpHeight: 0.30f, + stepDownHeight: 0.04f, + isOnGround: true, + body, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000001u); + + position = result.Position; + body.Position = position; + body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); + if (result.InContact) + body.TransientState |= TransientStateFlags.Contact; + if (result.OnWalkable) + body.TransientState |= TransientStateFlags.OnWalkable; + trace.Add(position); + } + + return trace.ToArray(); + } + + private static PhysicsEngine BuildCollisionEngine( + (PhysicsBSPNode Root, Dictionary Resolved) fixture, + bool preparedFlat, + uint gfxObjId) + { + var normalized = new Dictionary(fixture.Resolved.Count); + foreach ((ushort id, ResolvedPolygon polygon) in fixture.Resolved) + { + normalized.Add(id, new ResolvedPolygon + { + Id = id, + Vertices = polygon.Vertices, + Plane = polygon.Plane, + NumPoints = polygon.NumPoints, + SidesType = polygon.SidesType, + }); + } + + var physics = new GfxObjPhysics + { + SourceId = gfxObjId, + BSP = new PhysicsBSPTree { Root = fixture.Root }, + Resolved = normalized, + BoundingSphere = fixture.Root.BoundingSphere, + }; + var cache = new PhysicsDataCache(); + if (preparedFlat) + { + cache.CollisionTraversalMode = CollisionTraversalMode.Flat; + cache.CacheGfxObj(gfxObjId, FlatCollisionAssetBuilder.FlattenGfxObj(physics)); + } + else + { + cache.RegisterGfxObjForTest(gfxObjId, physics); + } + + var heights = new byte[81]; + var heightTable = new float[256]; + Array.Fill(heightTable, -1000f); + var engine = new PhysicsEngine { DataCache = cache }; + engine.AddLandblock( + 0xA9B40000u, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + engine.ShadowObjects.Register( + gfxObjId, + gfxObjId, + Vector3.Zero, + Quaternion.Identity, + fixture.Root.BoundingSphere.Radius, + 0f, + 0f, + 0xA9B4FFFFu, + ShadowCollisionType.BSP, + 1f); + return engine; + } + + private static void AssertNoLongFrozenStreak(Vector3[] trace, int maximumTicks) + { + int streak = 0; + for (int i = 1; i < trace.Length; i++) + { + streak = Vector3.Distance(trace[i - 1], trace[i]) < 0.001f + ? streak + 1 + : 0; + Assert.True(streak <= maximumTicks, + $"Trace froze for {streak} ticks at {trace[i]}."); + } + } +} From 4fbd93ecdbff3e0bf522b4760f406667fbee99c1 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 13:03:46 +0200 Subject: [PATCH 15/73] fix(physics): preserve retail edge-slide stop semantics --- ...0-response-layer-edge-family-pseudocode.md | 23 + src/AcDream.Core/Physics/TransitionTypes.cs | 48 +- .../RetailEdgeResponseOrderingTests.cs | 433 +++++++++++++++--- 3 files changed, 427 insertions(+), 77 deletions(-) diff --git a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md index b284efa4..2e4a3939 100644 --- a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md +++ b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md @@ -1110,4 +1110,27 @@ rejects a greater-than-15-tick frozen streak. The earlier dedicated `Ts4SteepRoofWedgeCaptureTests` remains green, so retiring these four compensations did not require weakening or deleting the TS-4 control. +### Corrective review: edge_slide has two outputs + +The first Slice 1B commit collapsed `CTransition::edge_slide`'s function +return into its out `TransitionState`. That loses a material retail case: +the steep-contact branch writes the result of `cliff_slide` to the out state +but returns false independently. A degenerate/parallel CliffSlide therefore +writes `OK_TS` **and still tells `transitional_insert` to continue its outer +retry**. Branch 1 and the contact-without-remembered-walkable branch stop with +`OK_TS`; a `COLLIDED_TS` Precipice result stops; `SLID_TS` and `ADJUSTED_TS` +retain their ordinary retry handling. + +The corrective implementation preserves this exact bool-plus-out-state seam. +Its end-to-end test drives an invalid-contact StepDown probe into a steep, +parallel-last-known CliffSlide, then proves a second outer object pass occurs +before success. The roof controls were also hardened: the flat-roof fixture +must first land and publish its persistent contact/walkable chronology, fails +its control arm when the roof is removed, and proves outward-X rejection plus +continued edge tangency. The steep-roof fixture now rejects non-finite or +oversized frame steps and signed-plane penetration until polygon exit. Parsed +graph and prepared-flat runs compare every `ResolveResult` field and every +persistent `PhysicsBody` field by raw float/double bits, including the ordered +walkable vertex payload. + --- diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 6889b0af..c943be88 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1758,11 +1758,12 @@ public sealed class Transition return (probeHeight * 0.5f, 2); } - internal TransitionState EdgeSlideAfterStepDownFailedForTest( + internal bool EdgeSlideAfterStepDownFailedForTest( PhysicsEngine engine, float stepDownHeight, - float zVal) - => EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal); + float zVal, + out TransitionState result) + => EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal, out result); internal TransitionState CliffSlideForTest(Plane contactPlane) => CliffSlide(contactPlane); @@ -2087,7 +2088,14 @@ public sealed class Transition // merely the EdgeSlide flag. DumpEdgeSlideStepDownFailed(stepDownHeight, zVal); - var edgeState = EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal); + bool stop = EdgeSlideAfterStepDownFailed( + engine, + stepDownHeight, + zVal, + out TransitionState edgeState); + if (stop) + return edgeState; + if (edgeState == TransitionState.Slid) { transitState = edgeState; @@ -2104,7 +2112,12 @@ public sealed class Transition continue; } - return edgeState; + // Retail edge_slide has a bool return separate from its out + // TransitionState. In particular, a degenerate CliffSlide + // writes OK_TS but returns false, so transitional_insert must + // continue its outer retry rather than treating OK as a stop. + transitState = edgeState; + continue; } return TransitionState.OK; @@ -2212,10 +2225,11 @@ public sealed class Transition ? hook(this, phase, cellId, actual) : actual; - private TransitionState EdgeSlideAfterStepDownFailed( + private bool EdgeSlideAfterStepDownFailed( PhysicsEngine engine, float stepDownHeight, - float zVal) + float zVal, + out TransitionState result) { var sp = SpherePath; var ci = CollisionInfo; @@ -2231,7 +2245,8 @@ public sealed class Transition sp.RestoreCheckPos(); ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; - return TransitionState.OK; + result = TransitionState.OK; + return true; } if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal) @@ -2242,7 +2257,8 @@ public sealed class Transition sp.RestoreCheckPos(); ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; - return CliffSlide(cliffPlane); + result = CliffSlide(cliffPlane); + return false; } // Retail tests only SPHEREPATH::walkable here. When the failed @@ -2263,7 +2279,8 @@ public sealed class Transition sp.RestoreCheckPos(); ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; - return sp.PrecipiceSlide(this); + result = sp.PrecipiceSlide(this); + return result == TransitionState.Collided; } if (ci.ContactPlaneValid) @@ -2273,7 +2290,8 @@ public sealed class Transition sp.RestoreCheckPos(); ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; - return TransitionState.OK; + result = TransitionState.OK; + return true; } // Retail back-probes from the current sphere center to rediscover the @@ -2317,10 +2335,14 @@ public sealed class Transition // Retail returns Collided when the back-probe found no walkable. // In particular, it does not substitute a retained earlier polygon. if (sp.HasWalkablePolygon) - return sp.PrecipiceSlide(this); + { + result = sp.PrecipiceSlide(this); + return result == TransitionState.Collided; + } sp.ClearWalkable(); - return TransitionState.Collided; + result = TransitionState.Collided; + return true; } private TransitionState CliffSlide(Plane contactPlane) diff --git a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs index d8a23835..e75cbe45 100644 --- a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Text; using AcDream.Core.Physics; using DatReaderWriter.Types; using Xunit; @@ -87,11 +88,13 @@ public sealed class RetailEdgeResponseOrderingTests transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f); Vector3 failedCandidate = transition.SpherePath.BackupCheckPos; - TransitionState result = transition.EdgeSlideAfterStepDownFailedForTest( + bool stop = transition.EdgeSlideAfterStepDownFailedForTest( new PhysicsEngine(), stepDownHeight: 0.04f, - zVal: PhysicsGlobals.FloorZ); + zVal: PhysicsGlobals.FloorZ, + out TransitionState result); + Assert.True(stop); Assert.Equal(TransitionState.OK, result); Assert.Equal(failedCandidate, transition.SpherePath.CheckPos); Assert.False(transition.CollisionInfo.ContactPlaneValid); @@ -165,43 +168,158 @@ public sealed class RetailEdgeResponseOrderingTests transition.SpherePath.SaveCheckPos(); transition.SpherePath.AddOffsetToCheckPos(new Vector3(0f, 0f, -0.25f)); - TransitionState result = transition.EdgeSlideAfterStepDownFailedForTest( + bool stop = transition.EdgeSlideAfterStepDownFailedForTest( new PhysicsEngine(), stepDownHeight: 0.04f, - zVal: PhysicsGlobals.FloorZ); + zVal: PhysicsGlobals.FloorZ, + out TransitionState result); // The restored point is inside the remembered polygon, so retail's // unconditional PrecipiceSlide returns Collided. AD-54's steep-plane // reroute instead returned Adjusted through CliffSlide. + Assert.True(stop); Assert.Equal(TransitionState.Collided, result); Assert.Equal(failedCandidate, transition.SpherePath.CheckPos); Assert.False(transition.SpherePath.HasWalkablePolygon); Assert.False(transition.CollisionInfo.CollisionNormalValid); } + [Fact] + public void TransitionalInsert_DegenerateCliffSlideOk_ContinuesOuterRetry() + { + Vector3 current = new(2f, 3f, 4f); + Vector3 target = current + new Vector3(0.1f, 0f, 0f); + var transition = BSPStepUpFixtures.MakeGroundedTransition(current, target, cellId: Cell); + transition.ObjectInfo.State |= ObjectInfoState.EdgeSlide; + transition.ObjectInfo.StepDown = true; + transition.ObjectInfo.StepDownHeight = 0.04f; + + Plane steep = new(Vector3.Normalize(new Vector3(1f, 0f, 0.25f)), 0f); + int outerObjectPasses = 0; + var engine = new PhysicsEngine + { + TransitionCellCollisionTestHook = (candidate, phase, _, actual) => + { + if (phase != TransitionCellCollisionPhase.Objects) + return actual; + + if (candidate.SpherePath.StepDown) + { + // The nested downward probe finds a steep contact. It is + // rejected as walkable, and because SetContactPlane also + // latches the same last-known plane, CliffSlide's cross is + // parallel/degenerate and writes OK_TS with stop=false. + candidate.CollisionInfo.SetContactPlane(steep, Cell); + } + else + { + outerObjectPasses++; + if (outerObjectPasses == 2) + candidate.CollisionInfo.SetContactPlane(new Plane(Vector3.UnitZ, 0f), Cell); + } + + return actual; + }, + }; + + TransitionState result = transition.TransitionalInsertForTest(2, engine); + + Assert.Equal(TransitionState.OK, result); + Assert.Equal(2, outerObjectPasses); + Assert.True(transition.CollisionInfo.ContactPlaneValid); + Assert.Equal(Vector3.UnitZ, transition.CollisionInfo.ContactPlane.Normal); + } + [Fact] public void MultiFrameSteepRoof_GraphAndFlatTraversalRemainExactAndDoNotWedge() { - Vector3[] graph = RunSteepRoofTrace(preparedFlat: false); - Vector3[] flat = RunSteepRoofTrace(preparedFlat: true); + TraceRun graph = RunSteepRoofTrace(preparedFlat: false); + TraceRun flat = RunSteepRoofTrace(preparedFlat: true); - Assert.Equal(graph, flat); - Assert.Contains(graph, position => - position.X < 0f - && position.Z <= BSPStepUpFixtures.SphereRadius + 0.05f); - AssertNoLongFrozenStreak(graph, maximumTicks: 15); + AssertTraceParity(graph, flat); + Assert.Contains(graph.Frames, frame => + frame.Result.Position.X < 0f + && frame.Result.Position.Z <= BSPStepUpFixtures.SphereRadius + 0.05f); + AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 15); + + Plane slope = BSPStepUpFixtures.SlopedUnwalkable().Resolved[ + BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane; + float radius = BSPStepUpFixtures.SphereRadius; + for (int i = 0; i < graph.Frames.Count; i++) + { + Vector3 position = graph.Frames[i].Result.Position; + AssertFinite(position, $"steep-roof frame {i}"); + if (i > 0) + { + float distance = Vector3.Distance( + graph.Frames[i - 1].Result.Position, + position); + Assert.InRange(distance, 0f, 1.1f); + } + + // While the foot center remains over the authored slope's XY + // footprint, it must stay on/outside the plane by at least its + // radius. Once X leaves [0,1], the body has exited the polygon and + // may fall toward the separate flat reference floor. + if (position.X is >= 0f and <= 1f && MathF.Abs(position.Y) <= 1f) + { + Vector3 footCenter = position + new Vector3(0f, 0f, radius); + float signedDistance = Vector3.Dot(slope.Normal, footCenter) + slope.D; + Assert.True(signedDistance >= radius - 0.015f, + $"Steep-roof penetration at frame {i}: distance={signedDistance}, " + + $"radius={radius}, position={position}."); + } + } } [Fact] public void MultiFrameFlatRoofLedge_GraphAndFlatTraversalRemainExactAndSlideAlongEdge() { - Vector3[] graph = RunFlatRoofLedgeTrace(preparedFlat: false); - Vector3[] flat = RunFlatRoofLedgeTrace(preparedFlat: true); + TraceRun graph = RunFlatRoofLedgeTrace(preparedFlat: false, includeRoof: true); + TraceRun flat = RunFlatRoofLedgeTrace(preparedFlat: true, includeRoof: true); + TraceRun noRoof = RunFlatRoofLedgeTrace(preparedFlat: false, includeRoof: false); - Assert.Equal(graph, flat); - AssertNoLongFrozenStreak(graph, maximumTicks: 15); - Assert.True(graph[^1].Y > graph[0].Y + 0.25f, - $"The roof-edge control made no along-edge progress: {graph[0]} -> {graph[^1]}."); + AssertTraceParity(graph, flat); + Assert.True(graph.LandedFrame > 0, "The collision-backed control never landed on the roof."); + Assert.Equal(-1, noRoof.LandedFrame); + Assert.All(graph.Frames.GetRange(0, graph.LandedFrame), frame => + Assert.False(frame.Result.OnWalkable)); + TraceFrame landing = graph.Frames[graph.LandedFrame]; + Assert.True(landing.Result.Ok); + Assert.True(landing.Result.IsOnGround); + Assert.True(landing.Result.InContact); + Assert.True(landing.Result.OnWalkable); + Assert.True(landing.BodyContactPlaneValid); + Assert.True(landing.BodyWalkablePolygonValid); + Assert.Equal(Vector3.UnitZ, landing.BodyContactPlane.Normal); + Assert.Equal( + TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + landing.BodyTransientState + & (TransientStateFlags.Contact | TransientStateFlags.OnWalkable)); + + List ledge = graph.Frames.GetRange( + graph.LedgeStartFrame, + graph.Frames.Count - graph.LedgeStartFrame); + AssertNoLongFrozenStreak(ledge, maximumTicks: 15); + int outwardRejectionFrame = ledge.FindIndex(frame => + frame.Result.CollisionNormalValid + && frame.Result.CollisionNormal.X < -0.9f); + Assert.True(outwardRejectionFrame >= 0, + "The roof-edge control never produced the expected outward-X rejection normal."); + float rejectedX = ledge[outwardRejectionFrame].Result.Position.X; + Assert.All(ledge.GetRange( + outwardRejectionFrame, + ledge.Count - outwardRejectionFrame), + frame => Assert.True(frame.Result.Position.X <= rejectedX + 0.001f, + $"Outward X resumed after rejection: {frame.Result.Position.X} > {rejectedX}.")); + float unobstructedFinalX = ledge[0].Result.Position.X + 0.12f * (ledge.Count - 1); + Assert.True(ledge[^1].Result.Position.X < unobstructedFinalX - 0.25f, + $"Roof edge failed to remove outward travel: final={ledge[^1].Result.Position.X}, " + + $"unobstructed={unobstructedFinalX}."); + Assert.True(ledge[^1].Result.Position.Y + > ledge[outwardRejectionFrame].Result.Position.Y + 0.25f, + $"The roof edge rejected all tangency: {ledge[outwardRejectionFrame].Result.Position} -> " + + $"{ledge[^1].Result.Position}."); } private static Transition MakeFailedStepDownTransition() @@ -227,7 +345,7 @@ public sealed class RetailEdgeResponseOrderingTests new(-2f, 2f, 0f), ]; - private static Vector3[] RunSteepRoofTrace(bool preparedFlat) + private static TraceRun RunSteepRoofTrace(bool preparedFlat) { var fixture = BSPStepUpFixtures.SlopedUnwalkable(); PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E101u); @@ -237,7 +355,7 @@ public sealed class RetailEdgeResponseOrderingTests var body = new PhysicsBody { TransientState = TransientStateFlags.Active }; Vector3 position = new(0.5f, 0f, 3f); float velocityZ = 0f; - var trace = new List(91) { position }; + var trace = new List(90); for (int tick = 0; tick < 90; tick++) { @@ -259,71 +377,102 @@ public sealed class RetailEdgeResponseOrderingTests body.Position = position; if (result.IsOnGround) velocityZ = 0f; - trace.Add(position); + ApplyContactResult(body, result); + trace.Add(CaptureFrame(result, body)); if (position.X < 0f && position.Z <= radius + 0.05f) break; } - return trace.ToArray(); + return new TraceRun(trace, LandedFrame: -1, LedgeStartFrame: -1); } - private static Vector3[] RunFlatRoofLedgeTrace(bool preparedFlat) + private static TraceRun RunFlatRoofLedgeTrace(bool preparedFlat, bool includeRoof) { var fixture = BSPStepUpFixtures.FlatRoof(); - PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E102u); - ResolvedPolygon roof = fixture.Resolved[BSPStepUpFixtures.FlatRoof_RoofId]; + PhysicsEngine engine = BuildCollisionEngine( + fixture, + preparedFlat, + 0x0100E102u, + includeGeometry: includeRoof); float radius = BSPStepUpFixtures.SphereRadius; - Vector3 position = new(1.55f, -0.75f, 3f); + const float dt = 1f / 30f; + Vector3 position = new(1.55f, -0.75f, 3.6f); + float velocityZ = 0f; var body = new PhysicsBody { Position = position, Orientation = Quaternion.Identity, - ContactPlaneValid = true, - ContactPlane = roof.Plane, - ContactPlaneCellId = Cell, - WalkablePolygonValid = true, - WalkablePlane = roof.Plane, - WalkableVertices = roof.Vertices, - WalkableUp = Vector3.UnitZ, - TransientState = TransientStateFlags.Active - | TransientStateFlags.Contact - | TransientStateFlags.OnWalkable, + State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.Active, }; - var trace = new List(13) { position }; + var trace = new List(72); + int landedFrame = -1; - for (int tick = 0; tick < 12; tick++) + for (int tick = 0; tick < 60; tick++) { + velocityZ += PhysicsBody.Gravity * dt; + body.Velocity = new Vector3(0f, 0f, velocityZ); ResolveResult result = engine.ResolveWithTransition( position, - position + new Vector3(0.12f, 0.08f, 0f), + position + new Vector3(0f, 0f, velocityZ * dt), Cell, radius, radius * 2f, stepUpHeight: 0.30f, stepDownHeight: 0.04f, - isOnGround: true, + isOnGround: body.OnWalkable, body, ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0x01000001u); position = result.Position; body.Position = position; - body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); - if (result.InContact) - body.TransientState |= TransientStateFlags.Contact; - if (result.OnWalkable) - body.TransientState |= TransientStateFlags.OnWalkable; - trace.Add(position); + ApplyContactResult(body, result); + trace.Add(CaptureFrame(result, body)); + + if (result.InContact && result.OnWalkable) + { + landedFrame = trace.Count - 1; + velocityZ = 0f; + body.Velocity = Vector3.Zero; + break; + } } - return trace.ToArray(); + int ledgeStartFrame = trace.Count; + if (landedFrame >= 0) + { + for (int tick = 0; tick < 12; tick++) + { + ResolveResult result = engine.ResolveWithTransition( + position, + position + new Vector3(0.12f, 0.08f, 0f), + Cell, + radius, + radius * 2f, + stepUpHeight: 0.30f, + stepDownHeight: 0.04f, + isOnGround: body.OnWalkable, + body, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000001u); + + position = result.Position; + body.Position = position; + ApplyContactResult(body, result); + trace.Add(CaptureFrame(result, body)); + } + } + + return new TraceRun(trace, landedFrame, ledgeStartFrame); } private static PhysicsEngine BuildCollisionEngine( (PhysicsBSPNode Root, Dictionary Resolved) fixture, bool preparedFlat, - uint gfxObjId) + uint gfxObjId, + bool includeGeometry = true) { var normalized = new Dictionary(fixture.Resolved.Count); foreach ((ushort id, ResolvedPolygon polygon) in fixture.Resolved) @@ -346,12 +495,12 @@ public sealed class RetailEdgeResponseOrderingTests BoundingSphere = fixture.Root.BoundingSphere, }; var cache = new PhysicsDataCache(); - if (preparedFlat) + if (includeGeometry && preparedFlat) { cache.CollisionTraversalMode = CollisionTraversalMode.Flat; cache.CacheGfxObj(gfxObjId, FlatCollisionAssetBuilder.FlattenGfxObj(physics)); } - else + else if (includeGeometry) { cache.RegisterGfxObjForTest(gfxObjId, physics); } @@ -367,30 +516,186 @@ public sealed class RetailEdgeResponseOrderingTests Array.Empty(), 0f, 0f); - engine.ShadowObjects.Register( - gfxObjId, - gfxObjId, - Vector3.Zero, - Quaternion.Identity, - fixture.Root.BoundingSphere.Radius, - 0f, - 0f, - 0xA9B4FFFFu, - ShadowCollisionType.BSP, - 1f); + if (includeGeometry) + { + engine.ShadowObjects.Register( + gfxObjId, + gfxObjId, + Vector3.Zero, + Quaternion.Identity, + fixture.Root.BoundingSphere.Radius, + 0f, + 0f, + 0xA9B4FFFFu, + ShadowCollisionType.BSP, + 1f); + } return engine; } - private static void AssertNoLongFrozenStreak(Vector3[] trace, int maximumTicks) + private static void ApplyContactResult(PhysicsBody body, ResolveResult result) + { + body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); + if (result.InContact) + body.TransientState |= TransientStateFlags.Contact; + if (result.OnWalkable) + body.TransientState |= TransientStateFlags.OnWalkable; + } + + private static void AssertTraceParity(TraceRun expected, TraceRun actual) + { + Assert.Equal(expected.LandedFrame, actual.LandedFrame); + Assert.Equal(expected.LedgeStartFrame, actual.LedgeStartFrame); + Assert.Equal(expected.Frames.Count, actual.Frames.Count); + for (int i = 0; i < expected.Frames.Count; i++) + { + Assert.Equal(expected.Frames[i].ResolveBits, actual.Frames[i].ResolveBits); + Assert.Equal(expected.Frames[i].BodyBits, actual.Frames[i].BodyBits); + } + } + + private static void AssertNoLongFrozenStreak( + IReadOnlyList trace, + int maximumTicks) { int streak = 0; - for (int i = 1; i < trace.Length; i++) + for (int i = 1; i < trace.Count; i++) { - streak = Vector3.Distance(trace[i - 1], trace[i]) < 0.001f + streak = Vector3.Distance( + trace[i - 1].Result.Position, + trace[i].Result.Position) < 0.001f ? streak + 1 : 0; Assert.True(streak <= maximumTicks, - $"Trace froze for {streak} ticks at {trace[i]}."); + $"Trace froze for {streak} ticks at {trace[i].Result.Position}."); } } + + private static TraceFrame CaptureFrame(ResolveResult result, PhysicsBody body) => + new( + result, + ResolveSignature(result), + BodySignature(body), + body.ContactPlaneValid, + body.ContactPlane, + body.WalkablePolygonValid, + body.TransientState); + + private static string ResolveSignature(ResolveResult result) + { + var signature = new StringBuilder(256); + Append(signature, result.Position); + Append(signature, result.CellId); + Append(signature, result.IsOnGround); + Append(signature, result.CollisionNormalValid); + Append(signature, result.CollisionNormal); + Append(signature, result.Ok); + Append(signature, result.Orientation); + Append(signature, result.InContact); + Append(signature, result.OnWalkable); + Append(signature, result.ContactPlane); + Append(signature, result.ContactPlaneCellId); + Append(signature, result.ContactPlaneIsWater); + return signature.ToString(); + } + + private static string BodySignature(PhysicsBody body) + { + var signature = new StringBuilder(512); + Append(signature, body.Position); + Append(signature, body.CellPosition.ObjCellId); + Append(signature, body.CellPosition.Frame.Origin); + Append(signature, body.CellPosition.Frame.Orientation); + Append(signature, body.InWorld); + Append(signature, body.Orientation); + Append(signature, body.Velocity); + Append(signature, body.CachedVelocity); + Append(signature, body.FramesStationaryFall); + Append(signature, body.Acceleration); + Append(signature, body.Omega); + Append(signature, body.GroundNormal); + Append(signature, body.SlidingNormal); + Append(signature, body.ContactPlaneValid); + Append(signature, body.ContactPlane); + Append(signature, body.ContactPlaneCellId); + Append(signature, body.ContactPlaneIsWater); + Append(signature, body.WalkablePolygonValid); + Append(signature, body.WalkablePlane); + if (body.WalkableVertices is null) + { + Append(signature, -1); + } + else + { + Append(signature, body.WalkableVertices.Length); + foreach (Vector3 vertex in body.WalkableVertices) + Append(signature, vertex); + } + Append(signature, body.WalkableUp); + Append(signature, body.Elasticity); + Append(signature, body.Friction); + Append(signature, (uint)body.State); + Append(signature, (uint)body.TransientState); + Append(signature, BitConverter.DoubleToUInt64Bits(body.LastUpdateTime)); + Append(signature, body.IsFullyConstrained); + Append(signature, body.LastMoveWasAutonomous); + return signature.ToString(); + } + + private static void Append(StringBuilder target, bool value) => + target.Append(value ? "1|" : "0|"); + + private static void Append(StringBuilder target, int value) => + target.Append(value).Append('|'); + + private static void Append(StringBuilder target, uint value) => + target.Append(value.ToString("X8")).Append('|'); + + private static void Append(StringBuilder target, ulong value) => + target.Append(value.ToString("X16")).Append('|'); + + private static void Append(StringBuilder target, float value) => + Append(target, BitConverter.SingleToUInt32Bits(value)); + + private static void Append(StringBuilder target, Vector3 value) + { + Append(target, value.X); + Append(target, value.Y); + Append(target, value.Z); + } + + private static void Append(StringBuilder target, Quaternion value) + { + Append(target, value.X); + Append(target, value.Y); + Append(target, value.Z); + Append(target, value.W); + } + + private static void Append(StringBuilder target, Plane value) + { + Append(target, value.Normal); + Append(target, value.D); + } + + private static void AssertFinite(Vector3 value, string context) + { + Assert.True( + float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z), + $"Non-finite position in {context}: {value}."); + } + + private sealed record TraceRun( + List Frames, + int LandedFrame, + int LedgeStartFrame); + + private sealed record TraceFrame( + ResolveResult Result, + string ResolveBits, + string BodyBits, + bool BodyContactPlaneValid, + Plane BodyContactPlane, + bool BodyWalkablePolygonValid, + TransientStateFlags BodyTransientState); } From 1fd5da67b4baf92780a8a22f423241e258ba3b87 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 13:13:09 +0200 Subject: [PATCH 16/73] fix(physics): restore retail step-down placement validation --- .../retail-divergence-register.md | 4 +- ...0-response-layer-edge-family-pseudocode.md | 42 ++- src/AcDream.Core/Physics/TransitionTypes.cs | 54 ++-- .../Physics/RetailStepDownPlacementTests.cs | 271 ++++++++++++++++++ 4 files changed, 333 insertions(+), 38 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0b588c86..37f3b26b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -118,7 +118,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 88 active rows (AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 87 active rows (AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -130,7 +130,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | | ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 | -| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | +| ~~AP-5~~ | **RETIRED 2026-07-31 (Campaign P Slice 2A).** `DoStepDown` no longer accepts a caller-controlled `runPlacement` bypass. After the transitional support probe and retail `check_walkables` gate succeed, ordinary contact maintenance, edge-slide back-probes, and StepUp all switch to `PLACEMENT_INSERT`, reset `walk_interp` to 1, run the final insertion, restore the prior insert type, and accept only `OK_TS`. The former wall-slide justification is addressed at the actual placement dispatcher boundary: its retail epsilon-shaved overlap test permits exact wall tangency but rejects real penetration; no StepDown path skips validation. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`DoStepDown`); `src/AcDream.Core/Physics/BSPQuery.cs` / `FlatBspQuery.cs` (Placement dispatcher); `tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs` | — | — | `CTransition::step_down` 0x0050B2A0 pc:272946–272998; `BSPTREE::find_collisions` Placement branch 0x0053A440 pc:323742; `CSphere::intersects_sphere` 0x00537A80; `CCylSphere::intersects_sphere` 0x0053B440 | | ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` | | ~~AP-10~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the retail 0.1 m dry-corner water sink-in is restored.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs`) now returns 0.1 for a partially-water cell's dry corner instead of the collapsed 0. The row's own "destabilizes the touch check" justification turned out to be structurally true of retail too (a skipped `SetContactPlane` reassertion is not a fall in ANY of retail/ACE/acdream, because `Contact`/`OnWalkable` are STICKY — `PhysicsEngine.ResolveWithTransition`'s `onGround` computation ORs the fresh per-call `ContactPlaneValid` with the seeded, persistent `PhysicsBody.TransientState.OnWalkable` bit) — traced and confirmed in this slice; see `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.2. `PhysicsEngine.SampleTerrainWalkable`'s `isWater = waterDepth >= 0.45f` threshold means the restore does not flip the dry corner's water classification (0.1 still < 0.45) — only the sink-in depth changes. Full Release suite green (no regression) proves the sticky-bit argument held in practice, not just in theory. | `src/AcDream.Core/Physics/TerrainSurface.cs` (`SampleWaterDepth`) | — | — | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port); `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.1-5.2 | | AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 | diff --git a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md index 2e4a3939..21ee508d 100644 --- a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md +++ b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md @@ -1096,9 +1096,9 @@ excerpt summaries: guard. `TransitionTypes.cs` now follows that order exactly. AP-3, AP-4, AD-53, and -AD-54 are retired together. The implementation deliberately preserves the -existing ordinary-tail `runPlacement: false` choice and does not alter TS-4's -Path-6 steep-polygon shortcut. +AD-54 are retired together. Slice 1B deliberately left the ordinary-tail +`runPlacement: false` choice for the following AP-5-specific slice and did not +alter TS-4's Path-6 steep-polygon shortcut. Slice 2A below closes AP-5. `RetailEdgeResponseOrderingTests` pins every distinguishing branch: valid steep-contact early return, the one/two-sphere probe schedule, @@ -1133,4 +1133,40 @@ graph and prepared-flat runs compare every `ResolveResult` field and every persistent `PhysicsBody` field by raw float/double bits, including the ordered walkable vertex payload. +## 9. Campaign P Slice 2A closeout — mandatory StepDown placement (2026-07-31) + +The complete `CTransition::step_down` body at `0x0050B2A0` +(pseudo-C:272946–272998) has no caller-controlled validation choice. It resets +`walk_interp` to 1, performs the transitional downward/support probe, applies +the `EdgeSlide && !StepUp` walkable-support gate, and then always: + +1. saves `sphere_path.insert_type`; +2. installs `PLACEMENT_INSERT`; +3. calls `CTransition::transitional_insert(this, 1)`; +4. restores the saved insert type; and +5. returns true only for `OK_TS`. + +This is the same tail for normal grounded contact maintenance, the +`edge_slide` current-position back-probe, and StepUp. The former acdream +`runPlacement: false` argument on the first two callers was therefore a real +behavioral divergence, not a caller-specific retail mode. + +The historical justification for the bypass was a wall-slide candidate that +Placement rejected. The correct boundary is the Placement dispatcher, not +StepDown. Retail's BSP, sphere, and cylinder Placement branches are pure +occupancy tests and shave `PhysicsGlobals.EPSILON` from their effective reach. +The current graph and prepared-flat dispatchers already implement that rule: +an exactly tangent sphere remains valid while penetration beyond the retail +epsilon is rejected. With the response-order fixes from §8 in place, restoring +the mandatory placement tail does not reproduce the old wall stall. + +`RetailStepDownPlacementTests` pins the mechanism end to end for one- and +two-sphere movers, ordinary contact maintenance, StepUp, and a supported +candidate that overlaps only during final Placement. It also compares graph +and prepared-flat Placement at exact wall tangency and beyond-epsilon overlap. +The existing multi-frame roof/edge fixtures continue to compare the complete +`ResolveResult` and persistent `PhysicsBody` state by raw bits, and the #273, +#271, #185, StepUp, transition-retry, and TS-4 controls remain unchanged. AP-5 +is retired; TS-4 is intentionally untouched. + --- diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index c943be88..f38ff1ec 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -2050,18 +2050,13 @@ public sealed class Transition stepDownHeight); stepDownHeight = probeHeight; - // L.2.3h (2026-04-29): pass runPlacement=false. This - // branch's job is to maintain ground contact during normal - // movement (e.g., walking over small bumps or near walls). - // The Placement check inside DoStepDown is too strict for - // this use — minor wall overlap from a prior wall-slide - // would fail Placement and trigger the L.2.3e edge-block, - // leaving the player stuck near walls. DoStepUp still runs - // Placement for the step-UP-through-walls protection. + // Retail always finishes a successful step-down with a + // PLACEMENT_INSERT. That final pass validates that the + // supported candidate can actually contain the mover. bool steppedDown = false; for (int probe = 0; probe < probeCount; probe++) { - if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)) + if (DoStepDown(stepDownHeight, zVal, engine)) { steppedDown = true; break; @@ -2326,7 +2321,7 @@ public sealed class Transition Vector3 backToCurrent = sp.GlobalCurrCenter[0].Origin - sp.GlobalSphere[0].Origin; sp.AddOffsetToCheckPos(backToCurrent); - _ = DoStepDown(stepDownHeight, zVal, engine, runPlacement: false); + _ = DoStepDown(stepDownHeight, zVal, engine); ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; @@ -5140,8 +5135,7 @@ public sealed class Transition /// Ported from pseudocode section 5 (StepDown). /// ACE: Transition.StepDown(float stepDownHeight, float zVal). /// - private bool DoStepDown(float stepDownHeight, float walkableZ, PhysicsEngine engine, - bool runPlacement = true) + private bool DoStepDown(float stepDownHeight, float walkableZ, PhysicsEngine engine) { var sp = SpherePath; @@ -5156,7 +5150,7 @@ public sealed class Transition PhysicsDiagnostics.LogStepWalk( "stepdown-enter", -1, 0, sp, CollisionInfo, ObjectInfo, Vector3.Zero, Vector3.Zero, - detail: $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + detail: $"height={stepDownHeight:F4} walkableZ={walkableZ:F4}"); } // If NOT in step-up mode, apply the downward offset. @@ -5170,7 +5164,7 @@ public sealed class Transition PhysicsDiagnostics.LogStepWalk( "stepdown-after-offset", -1, 0, sp, CollisionInfo, ObjectInfo, downOffset, downOffset, - detail: $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + detail: $"height={stepDownHeight:F4} walkableZ={walkableZ:F4}"); } } @@ -5183,7 +5177,7 @@ public sealed class Transition "stepdown-after-insert", -1, 0, sp, CollisionInfo, ObjectInfo, Vector3.Zero, Vector3.Zero, transitState, - $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + $"height={stepDownHeight:F4} walkableZ={walkableZ:F4}"); } sp.StepDown = false; @@ -5234,23 +5228,11 @@ public sealed class Transition return false; } - // L.2.3h (2026-04-29): Placement validation is for the - // DoStepUp use case (prevents climbing through walls by - // stepping up onto ground beyond a tall wall). For the - // "maintain contact during normal movement" use case (called - // from TransitionalInsert's contact-recovery branch), the - // Placement check is over-strict — slight wall overlap from - // a prior wall-slide makes Placement reject, then the caller - // returns Collided (L.2.3e) and the player gets stuck near - // walls without ever touching them. - // - // ACE Transition.cs:731-741 runs Placement here unconditionally, - // but ACE's pre-step-down state is cleaner — we have residual - // wall-slide artifacts that make Placement misfire. - if (!runPlacement) - return true; - - // Placement validation: can we actually stand here? + // Retail CTransition::step_down (0x0050B2A0) always finishes a + // successful transitional support probe with PLACEMENT_INSERT. + // This rejects candidates that found support while still + // overlapping solid geometry, for both ordinary contact + // maintenance and StepUp. // // A6.P3 slice 4 (2026-05-22) — reset WalkInterp to 1.0 before // the placement_insert. The prior TransitionalInsert(5) probe @@ -5324,12 +5306,18 @@ public sealed class Transition "stepdown-reject", -1, 0, sp, CollisionInfo, ObjectInfo, Vector3.Zero, Vector3.Zero, transitState, - $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + $"height={stepDownHeight:F4} walkableZ={walkableZ:F4}"); } return false; } + internal bool DoStepDownForTest( + float stepDownHeight, + float walkableZ, + PhysicsEngine engine) + => DoStepDown(stepDownHeight, walkableZ, engine); + // ----------------------------------------------------------------------- // Step-up // ----------------------------------------------------------------------- diff --git a/tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs b/tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs new file mode 100644 index 00000000..ac0412a2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs @@ -0,0 +1,271 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Pins retail CTransition::step_down (0x0050B2A0): every +/// successfully supported candidate is re-tested with +/// , regardless of whether the caller is +/// ordinary contact maintenance or StepUp. +/// +public sealed class RetailStepDownPlacementTests +{ + private const uint Cell = 0xA9B40001u; + private const float Radius = 0.48f; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OrdinaryContactMaintenance_AlwaysRunsFinalPlacement(bool twoSpheres) + { + Transition transition = MakeGroundedTransition(twoSpheres); + int supportPasses = 0; + int placementPasses = 0; + var engine = new PhysicsEngine + { + TransitionCellCollisionTestHook = (candidate, phase, _, actual) => + { + if (phase != TransitionCellCollisionPhase.Environment) + return actual; + + if (candidate.SpherePath.StepDown) + { + supportPasses++; + candidate.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), Cell); + } + else if (candidate.SpherePath.InsertType == InsertType.Placement) + { + placementPasses++; + } + + return actual; + }, + }; + + TransitionState result = transition.TransitionalInsertForTest(1, engine); + + Assert.Equal(TransitionState.OK, result); + Assert.Equal(1, supportPasses); + Assert.Equal(1, placementPasses); + Assert.Equal(InsertType.Transition, transition.SpherePath.InsertType); + Assert.False(transition.SpherePath.StepDown); + } + + [Fact] + public void SupportedCandidate_OverlappingDuringPlacement_IsRejected() + { + Transition transition = MakeGroundedTransition(twoSpheres: true); + int placementPasses = 0; + var engine = new PhysicsEngine + { + TransitionCellCollisionTestHook = (candidate, phase, _, actual) => + { + if (phase != TransitionCellCollisionPhase.Environment) + return actual; + + if (candidate.SpherePath.StepDown) + { + candidate.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), Cell); + return actual; + } + + if (candidate.SpherePath.InsertType == InsertType.Placement) + { + placementPasses++; + return TransitionState.Collided; + } + + return actual; + }, + }; + + bool accepted = transition.DoStepDownForTest( + stepDownHeight: 0.04f, + walkableZ: PhysicsGlobals.FloorZ, + engine); + + Assert.False(accepted); + Assert.Equal(1, placementPasses); + Assert.Equal(InsertType.Transition, transition.SpherePath.InsertType); + Assert.False(transition.SpherePath.StepDown); + } + + [Fact] + public void StepUp_UsesTheSameFinalPlacementPass() + { + Transition transition = MakeGroundedTransition(twoSpheres: true); + int placementPasses = 0; + var engine = new PhysicsEngine + { + TransitionCellCollisionTestHook = (candidate, phase, _, actual) => + { + if (phase != TransitionCellCollisionPhase.Environment) + return actual; + + if (candidate.SpherePath.StepDown) + { + candidate.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), Cell); + } + else if (candidate.SpherePath.InsertType == InsertType.Placement) + { + placementPasses++; + } + + return actual; + }, + }; + + bool accepted = transition.DoStepUp(Vector3.UnitX, engine); + + Assert.True(accepted); + Assert.Equal(1, placementPasses); + Assert.Equal(InsertType.Transition, transition.SpherePath.InsertType); + Assert.False(transition.SpherePath.StepUp); + Assert.False(transition.SpherePath.StepDown); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PlacementDispatcher_AllowsExactWallTangency_AndRejectsOverlap( + bool twoSpheres) + { + (PhysicsBSPNode root, Dictionary resolved) = + BuildWall(); + FlatPhysicsBsp flat = + FlatCollisionAssetBuilder.FlattenPhysicsBsp(root, resolved); + + float tangentY = Radius; + TransitionState graphTangent = RunPlacement( + root, resolved, flat: null, tangentY, twoSpheres); + TransitionState flatTangent = RunPlacement( + root: null, resolved, flat, tangentY, twoSpheres); + TransitionState graphOverlap = RunPlacement( + root, resolved, flat: null, + Radius - PhysicsGlobals.EPSILON * 2f, + twoSpheres); + TransitionState flatOverlap = RunPlacement( + root: null, resolved, flat, + Radius - PhysicsGlobals.EPSILON * 2f, + twoSpheres); + + Assert.Equal(TransitionState.OK, graphTangent); + Assert.Equal(graphTangent, flatTangent); + Assert.Equal(TransitionState.Collided, graphOverlap); + Assert.Equal(graphOverlap, flatOverlap); + } + + private static TransitionState RunPlacement( + PhysicsBSPNode? root, + Dictionary resolved, + FlatPhysicsBsp? flat, + float centerY, + bool twoSpheres) + { + var foot = new Sphere + { + Origin = new Vector3(0f, centerY, 0f), + Radius = Radius, + }; + Sphere? head = twoSpheres + ? new Sphere + { + Origin = new Vector3(0f, centerY, 0.875f), + Radius = Radius, + } + : null; + var transition = new Transition(); + transition.SpherePath.InitPath( + begin: Vector3.Zero, + end: Vector3.Zero, + Cell, + Radius, + sphereHeight: twoSpheres ? 1.355f : 0f); + transition.SpherePath.InsertType = InsertType.Placement; + + return flat is null + ? BSPQuery.FindCollisions( + root, + resolved, + transition, + foot, + head, + foot.Origin, + Vector3.UnitZ, + 1f) + : FlatBspQuery.FindCollisions( + flat, + transition, + foot, + head, + foot.Origin, + Vector3.UnitZ, + 1f); + } + + private static Transition MakeGroundedTransition(bool twoSpheres) + { + Vector3 current = new(2f, 3f, 4f); + Vector3 target = current + new Vector3(0.1f, 0f, 0f); + var transition = new Transition(); + transition.SpherePath.InitPath( + current, + target, + Cell, + Radius, + sphereHeight: twoSpheres ? 1.835f : 0f); + transition.SpherePath.SetCheckPos(target, Cell); + transition.ObjectInfo.State = + ObjectInfoState.Contact | ObjectInfoState.OnWalkable; + transition.ObjectInfo.StepDown = true; + transition.ObjectInfo.StepDownHeight = 0.04f; + transition.ObjectInfo.StepUpHeight = 0.60f; + transition.CollisionInfo.LastKnownContactPlane = + new Plane(Vector3.UnitZ, 0f); + transition.CollisionInfo.LastKnownContactPlaneValid = true; + return transition; + } + + private static ( + PhysicsBSPNode Root, + Dictionary Resolved) BuildWall() + { + Vector3[] vertices = + [ + new(-2f, 0f, -2f), + new(-2f, 0f, 2f), + new( 2f, 0f, 2f), + new( 2f, 0f, -2f), + ]; + var root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = Vector3.Zero, + Radius = 4f, + }, + }; + root.Polygons.Add(1); + var resolved = new Dictionary + { + [1] = new ResolvedPolygon + { + Id = 1, + Vertices = vertices, + Plane = new Plane(Vector3.UnitY, 0f), + NumPoints = vertices.Length, + SidesType = CullMode.None, + }, + }; + return (root, resolved); + } +} From acec33eca8865e74b4bda59e7e69b3c3ae1ff9a6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 13:24:25 +0200 Subject: [PATCH 17/73] fix(physics): preserve retail step-down probe state --- .../retail-divergence-register.md | 2 +- ...0-response-layer-edge-family-pseudocode.md | 26 +++- src/AcDream.Core/Physics/TransitionTypes.cs | 44 +++--- .../RetailEdgeResponseOrderingTests.cs | 125 ++++++++++++++++++ .../Physics/RetailStepDownPlacementTests.cs | 88 ++++++++++++ 5 files changed, 249 insertions(+), 36 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 37f3b26b..b595dc1c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -130,7 +130,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | | ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 | -| ~~AP-5~~ | **RETIRED 2026-07-31 (Campaign P Slice 2A).** `DoStepDown` no longer accepts a caller-controlled `runPlacement` bypass. After the transitional support probe and retail `check_walkables` gate succeed, ordinary contact maintenance, edge-slide back-probes, and StepUp all switch to `PLACEMENT_INSERT`, reset `walk_interp` to 1, run the final insertion, restore the prior insert type, and accept only `OK_TS`. The former wall-slide justification is addressed at the actual placement dispatcher boundary: its retail epsilon-shaved overlap test permits exact wall tangency but rejects real penetration; no StepDown path skips validation. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`DoStepDown`); `src/AcDream.Core/Physics/BSPQuery.cs` / `FlatBspQuery.cs` (Placement dispatcher); `tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs` | — | — | `CTransition::step_down` 0x0050B2A0 pc:272946–272998; `BSPTREE::find_collisions` Placement branch 0x0053A440 pc:323742; `CSphere::intersects_sphere` 0x00537A80; `CCylSphere::intersects_sphere` 0x0053B440 | +| ~~AP-5~~ | **RETIRED 2026-07-31 (Campaign P Slice 2A).** `DoStepDown` no longer accepts a caller-controlled `runPlacement` bypass. It resets `walk_interp` once at entry; after the transitional support probe and retail `check_walkables` gate succeed, ordinary contact maintenance, edge-slide back-probes, and StepUp all switch to `PLACEMENT_INSERT` with the exact carried interpolation value, run the final insertion, restore the prior insert type, and accept only `OK_TS`. Nested `DoCheckWalkable` uses local current-position saves and preserves the outer `SPHEREPATH` backup pair needed by edge-slide. The former wall-slide justification is addressed at the actual placement dispatcher boundary: its retail epsilon-shaved overlap test permits exact wall tangency but rejects real penetration; no StepDown path skips validation. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`DoStepDown`, `DoCheckWalkable`); `src/AcDream.Core/Physics/BSPQuery.cs` / `FlatBspQuery.cs` (Placement dispatcher); `tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs`; `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::check_walkable` 0x0050AFF0 pc:272811–272856; `CTransition::step_down` 0x0050B2A0 pc:272946–272998; `BSPTREE::find_collisions` Placement branch 0x0053A440 pc:323742; `CSphere::intersects_sphere` 0x00537A80; `CCylSphere::intersects_sphere` 0x0053B440 | | ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` | | ~~AP-10~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the retail 0.1 m dry-corner water sink-in is restored.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs`) now returns 0.1 for a partially-water cell's dry corner instead of the collapsed 0. The row's own "destabilizes the touch check" justification turned out to be structurally true of retail too (a skipped `SetContactPlane` reassertion is not a fall in ANY of retail/ACE/acdream, because `Contact`/`OnWalkable` are STICKY — `PhysicsEngine.ResolveWithTransition`'s `onGround` computation ORs the fresh per-call `ContactPlaneValid` with the seeded, persistent `PhysicsBody.TransientState.OnWalkable` bit) — traced and confirmed in this slice; see `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.2. `PhysicsEngine.SampleTerrainWalkable`'s `isWater = waterDepth >= 0.45f` threshold means the restore does not flip the dry corner's water classification (0.1 still < 0.45) — only the sink-in depth changes. Full Release suite green (no regression) proves the sticky-bit argument held in practice, not just in theory. | `src/AcDream.Core/Physics/TerrainSurface.cs` (`SampleWaterDepth`) | — | — | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port); `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.1-5.2 | | AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 | diff --git a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md index 21ee508d..fe44a7d1 100644 --- a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md +++ b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md @@ -1146,6 +1146,14 @@ the `EdgeSlide && !StepUp` walkable-support gate, and then always: 4. restores the saved insert type; and 5. returns true only for `OK_TS`. +The `walk_interp = 1` assignment occurs once at `step_down` entry. Retail does +not reset it again before the final Placement insertion: that insertion sees +and carries the exact interpolation value left by the support probe. Likewise, +`CTransition::check_walkable` (`0x0050AFF0`, pseudo-C:272811–272856) saves its +temporary `check_pos` and cell in stack locals. It does not overwrite +`SPHEREPATH::backup_check_pos`/`backup_cell`, because the enclosing edge-slide +still needs that outer failed candidate after the nested support probe. + This is the same tail for normal grounded contact maintenance, the `edge_slide` current-position back-probe, and StepUp. The former acdream `runPlacement: false` argument on the first two callers was therefore a real @@ -1161,12 +1169,16 @@ epsilon is rejected. With the response-order fixes from §8 in place, restoring the mandatory placement tail does not reproduce the old wall stall. `RetailStepDownPlacementTests` pins the mechanism end to end for one- and -two-sphere movers, ordinary contact maintenance, StepUp, and a supported -candidate that overlaps only during final Placement. It also compares graph -and prepared-flat Placement at exact wall tangency and beyond-epsilon overlap. -The existing multi-frame roof/edge fixtures continue to compare the complete -`ResolveResult` and persistent `PhysicsBody` state by raw bits, and the #273, -#271, #185, StepUp, transition-retry, and TS-4 controls remain unchanged. AP-5 -is retired; TS-4 is intentionally untouched. +two-sphere movers, including raw-bit `walk_interp` carry, nested +`check_walkable` backup preservation, ordinary contact maintenance, StepUp, +and a supported candidate that overlaps only during final Placement. It also +compares graph and prepared-flat Placement at exact wall tangency and +beyond-epsilon overlap. The full-engine floor-plus-wall replay drives ten +grounded maintenance frames for both sphere counts, requires one Placement for +every successful support maintenance, proves no freeze or penetration plus +tangential progress, and compares complete `ResolveResult` and persistent +`PhysicsBody` state by raw bits between parsed-graph and prepared-flat paths. +The #273, #271, #185, StepUp, transition-retry, and TS-4 controls remain +unchanged. AP-5 is retired; TS-4 is intentionally untouched. --- diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index f38ff1ec..1c459731 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -5234,29 +5234,12 @@ public sealed class Transition // overlapping solid geometry, for both ordinary contact // maintenance and StepUp. // - // A6.P3 slice 4 (2026-05-22) — reset WalkInterp to 1.0 before - // the placement_insert. The prior TransitionalInsert(5) probe - // above may have consumed WalkInterp down to 0 (e.g. on a - // step-up onto a sloped ramp — AdjustSphereToPlane lifted the - // sphere by step_down_amt and ate all the interp). With - // WalkInterp=0, subsequent AdjustSphereToPlane calls' `interp - // >= path.WalkInterp` check fires immediately (0 >= 0) and - // rejects any push-back — so any geometric overlap that needs - // push-out resolution returns false → placement fails → step-up - // returns failure → step_up_slide loop → player stuck. - // - // Retail's CTransition::step_down (acclient_2013_pseudo_c.txt:272952) - // resets walk_interp = 1 at function entry. The placement_insert - // (line 272989-272990) runs after that initial reset; we believe - // placement-insert mode doesn't consume walk_interp the same way - // because it's a "can we fit here" check, not a movement sweep. - // - // This fix is the cellar-up target (issue #98). May also help - // other "step-up onto sloped surface" scenarios. + // Retail resets walk_interp once at step_down entry. The final + // placement consumes the exact value left by the support and + // check-walkable probes; there is no second reset here. var savedInsert = sp.InsertType; float winterpBeforePlacement = sp.WalkInterp; sp.InsertType = InsertType.Placement; - sp.WalkInterp = 1.0f; var placeState = TransitionalInsert(1, engine); @@ -5453,12 +5436,9 @@ public sealed class Transition /// TransitionalInsert before re-testing as Placement. /// /// - /// Returns true if a walkable surface was found within reach (i.e. the - /// sphere can land here). Returns false if: - /// - ObjectInfo.OnWalkable is NOT set (always walkable by convention). - /// - CheckWalkables() already confirmed a walkable (skip the probe). - /// - The downward probe returned OK (meaning: no walkable was found - /// within reach, so we CANNOT land → transitState == OK → return false). + /// Returns true when no support check is required or when a walkable + /// surface is found within reach. Returns false when the downward probe + /// returns OK (no walkable collision was found). /// /// /// ACE: Transition.CheckWalkable (Transition.cs:206-235). @@ -5478,7 +5458,13 @@ public sealed class Transition if (sp.CheckWalkables()) return true; - sp.SaveCheckPos(); + // Retail CTransition::check_walkable (0x0050AFF0) uses stack locals + // for this nested probe. Preserve SPHEREPATH's outer backup pair for + // edge_slide if the support check fails. + Vector3 savedCheckPos = sp.CheckPos; + uint savedCheckCellId = sp.CheckCellId; + Vector3 savedBackupCheckPos = sp.BackupCheckPos; + uint savedBackupCheckCellId = sp.BackupCheckCellId; float stepHeight = oi.StepDownHeight; var globSphere = sp.GlobalSphere[0]; @@ -5496,7 +5482,9 @@ public sealed class Transition var transitState = TransitionalInsert(1, engine); sp.CheckWalkable = false; - sp.RestoreCheckPos(); + sp.SetCheckPos(savedCheckPos, savedCheckCellId); + sp.BackupCheckPos = savedBackupCheckPos; + sp.BackupCheckCellId = savedBackupCheckCellId; // ACE returns (transitState != OK) — i.e. true when we DID find a // walkable (collision probe returned Adjusted/Collided). diff --git a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs index e75cbe45..410afc3e 100644 --- a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs @@ -322,6 +322,52 @@ public sealed class RetailEdgeResponseOrderingTests $"{ledge[^1].Result.Position}."); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MultiFrameGroundedFloorWallSlide_FinalPlacementIsMandatoryAndGraphFlatExact( + bool twoSpheres) + { + WallMaintenanceTrace graph = RunGroundedFloorWallSlide( + preparedFlat: false, + twoSpheres); + WallMaintenanceTrace flat = RunGroundedFloorWallSlide( + preparedFlat: true, + twoSpheres); + + Assert.Equal(graph.SupportPasses, flat.SupportPasses); + Assert.Equal(graph.PlacementPasses, flat.PlacementPasses); + Assert.True(graph.SupportPasses > 0, + "The full-engine replay never entered grounded support maintenance."); + Assert.Equal(graph.Frames.Count, graph.SupportPasses); + Assert.Equal(graph.SupportPasses, graph.PlacementPasses); + Assert.Equal(graph.Frames.Count, flat.Frames.Count); + for (int i = 0; i < graph.Frames.Count; i++) + { + Assert.Equal(graph.Frames[i].ResolveBits, flat.Frames[i].ResolveBits); + Assert.Equal(graph.Frames[i].BodyBits, flat.Frames[i].BodyBits); + Assert.True(graph.Frames[i].Result.Ok, + $"Grounded wall slide failed at frame {i}."); + Assert.True(graph.Frames[i].Result.InContact, + $"Ground contact was lost at frame {i}."); + Assert.True(graph.Frames[i].Result.OnWalkable, + $"Walkable state was lost at frame {i}."); + + float wallLimit = 0.5f - BSPStepUpFixtures.SphereRadius + + PhysicsGlobals.EPSILON * 10f; + Assert.True(graph.Frames[i].Result.Position.X <= wallLimit, + $"Wall penetration at frame {i}: X={graph.Frames[i].Result.Position.X}, " + + $"limit={wallLimit}."); + } + + AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 1); + Assert.True( + graph.Frames[^1].Result.Position.Y + > graph.Frames[0].Result.Position.Y + 0.25f, + $"Wall response removed tangential progress: " + + $"{graph.Frames[0].Result.Position} -> {graph.Frames[^1].Result.Position}."); + } + private static Transition MakeFailedStepDownTransition() { Vector3 current = Vector3.Zero; @@ -468,6 +514,80 @@ public sealed class RetailEdgeResponseOrderingTests return new TraceRun(trace, landedFrame, ledgeStartFrame); } + private static WallMaintenanceTrace RunGroundedFloorWallSlide( + bool preparedFlat, + bool twoSpheres) + { + var fixture = BSPStepUpFixtures.TallWall(); + PhysicsEngine engine = BuildCollisionEngine( + fixture, + preparedFlat, + 0x0100E103u); + int supportPasses = 0; + int placementPasses = 0; + engine.TransitionCellCollisionTestHook = (candidate, phase, _, actual) => + { + if (phase == TransitionCellCollisionPhase.Environment) + { + if (candidate.SpherePath.StepDown + && actual == TransitionState.OK + && candidate.CollisionInfo.ContactPlaneValid + && candidate.CollisionInfo.ContactPlane.Normal.Z + >= candidate.SpherePath.WalkableAllowance) + supportPasses++; + else if (candidate.SpherePath.InsertType == InsertType.Placement) + placementPasses++; + } + + return actual; + }; + + float radius = BSPStepUpFixtures.SphereRadius; + Vector3 position = new(0.5f - radius, -0.55f, 0f); + var floor = fixture.Resolved[BSPStepUpFixtures.TallWall_FloorId]; + var body = new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + GroundNormal = Vector3.UnitZ, + ContactPlaneValid = true, + ContactPlane = floor.Plane, + ContactPlaneCellId = Cell, + WalkablePolygonValid = true, + WalkablePlane = floor.Plane, + WalkableVertices = floor.Vertices, + WalkableUp = Vector3.UnitZ, + TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact + | TransientStateFlags.OnWalkable, + }; + var trace = new List(10); + + for (int tick = 0; tick < 10; tick++) + { + body.Velocity = new Vector3(1.8f, 2.1f, 0f); + ResolveResult result = engine.ResolveWithTransition( + position, + position + new Vector3(0.06f, 0.07f, 0f), + Cell, + radius, + sphereHeight: twoSpheres ? 1.835f : 0f, + stepUpHeight: 0.04f, + stepDownHeight: 0.04f, + isOnGround: true, + body, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000002u); + + position = result.Position; + body.Position = position; + ApplyContactResult(body, result); + trace.Add(CaptureFrame(result, body)); + } + + return new WallMaintenanceTrace(trace, supportPasses, placementPasses); + } + private static PhysicsEngine BuildCollisionEngine( (PhysicsBSPNode Root, Dictionary Resolved) fixture, bool preparedFlat, @@ -690,6 +810,11 @@ public sealed class RetailEdgeResponseOrderingTests int LandedFrame, int LedgeStartFrame); + private sealed record WallMaintenanceTrace( + List Frames, + int SupportPasses, + int PlacementPasses); + private sealed record TraceFrame( ResolveResult Result, string ResolveBits, diff --git a/tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs b/tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs index ac0412a2..5a3af8ae 100644 --- a/tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs @@ -24,8 +24,10 @@ public sealed class RetailStepDownPlacementTests public void OrdinaryContactMaintenance_AlwaysRunsFinalPlacement(bool twoSpheres) { Transition transition = MakeGroundedTransition(twoSpheres); + const float supportWalkInterp = 0.375f; int supportPasses = 0; int placementPasses = 0; + uint placementWalkInterpBits = 0; var engine = new PhysicsEngine { TransitionCellCollisionTestHook = (candidate, phase, _, actual) => @@ -36,12 +38,15 @@ public sealed class RetailStepDownPlacementTests if (candidate.SpherePath.StepDown) { supportPasses++; + candidate.SpherePath.WalkInterp = supportWalkInterp; candidate.CollisionInfo.SetContactPlane( new Plane(Vector3.UnitZ, 0f), Cell); } else if (candidate.SpherePath.InsertType == InsertType.Placement) { placementPasses++; + placementWalkInterpBits = BitConverter.SingleToUInt32Bits( + candidate.SpherePath.WalkInterp); } return actual; @@ -53,10 +58,80 @@ public sealed class RetailStepDownPlacementTests Assert.Equal(TransitionState.OK, result); Assert.Equal(1, supportPasses); Assert.Equal(1, placementPasses); + Assert.Equal( + BitConverter.SingleToUInt32Bits(supportWalkInterp), + placementWalkInterpBits); + Assert.Equal( + BitConverter.SingleToUInt32Bits(supportWalkInterp), + BitConverter.SingleToUInt32Bits(transition.SpherePath.WalkInterp)); Assert.Equal(InsertType.Transition, transition.SpherePath.InsertType); Assert.False(transition.SpherePath.StepDown); } + [Fact] + public void CheckWalkable_FailedNestedProbe_PreservesOuterBackupForEdgeSlide() + { + Transition transition = MakeGroundedTransition(twoSpheres: true); + var sp = transition.SpherePath; + Vector3 outerBackup = new(91.125f, -17.25f, 333.5f); + const uint outerBackupCell = 0xA9B40077u; + Vector3 nestedOrigin = new(2.125f, 3.25f, 4.5f); + + // Force check_walkables to fail so retail's nested downward probe is + // exercised instead of the remembered-support early return. + sp.SetCheckPos(nestedOrigin, Cell); + sp.SetWalkable( + new Plane(Vector3.UnitZ, 0f), + [ + new(100f, 100f, 0f), + new(101f, 100f, 0f), + new(101f, 101f, 0f), + new(100f, 101f, 0f), + ], + Vector3.UnitZ); + sp.BackupCheckPos = outerBackup; + sp.BackupCheckCellId = outerBackupCell; + + int nestedProbes = 0; + var engine = new PhysicsEngine + { + TransitionCellCollisionTestHook = (candidate, phase, _, actual) => + { + if (phase == TransitionCellCollisionPhase.Environment + && candidate.SpherePath.CheckWalkable) + { + nestedProbes++; + } + + return actual; + }, + }; + + bool walkable = transition.DoCheckWalkable(PhysicsGlobals.FloorZ, engine); + + Assert.False(walkable); + Assert.True(nestedProbes > 0); + AssertVectorBits(nestedOrigin, sp.CheckPos); + Assert.Equal(Cell, sp.CheckCellId); + AssertVectorBits(outerBackup, sp.BackupCheckPos); + Assert.Equal(outerBackupCell, sp.BackupCheckCellId); + + // Branch 1 is the first retail edge-slide branch. Its restore must use + // the distinctive outer failed candidate, not the nested probe origin. + transition.ObjectInfo.State &= ~ObjectInfoState.EdgeSlide; + sp.SetCheckPos(new Vector3(-8f, -9f, -10f), Cell); + bool stop = transition.EdgeSlideAfterStepDownFailedForTest( + engine, + stepDownHeight: 0.04f, + zVal: PhysicsGlobals.FloorZ, + out TransitionState state); + + Assert.True(stop); + Assert.Equal(TransitionState.OK, state); + AssertVectorBits(outerBackup, sp.CheckPos); + Assert.Equal(outerBackupCell, sp.CheckCellId); + } + [Fact] public void SupportedCandidate_OverlappingDuringPlacement_IsRejected() { @@ -234,6 +309,19 @@ public sealed class RetailStepDownPlacementTests return transition; } + private static void AssertVectorBits(Vector3 expected, Vector3 actual) + { + Assert.Equal( + BitConverter.SingleToUInt32Bits(expected.X), + BitConverter.SingleToUInt32Bits(actual.X)); + Assert.Equal( + BitConverter.SingleToUInt32Bits(expected.Y), + BitConverter.SingleToUInt32Bits(actual.Y)); + Assert.Equal( + BitConverter.SingleToUInt32Bits(expected.Z), + BitConverter.SingleToUInt32Bits(actual.Z)); + } + private static ( PhysicsBSPNode Root, Dictionary Resolved) BuildWall() From 75b6f6b6c92db9bd9dd84c5067517c03a0bc1d12 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 13:44:55 +0200 Subject: [PATCH 18/73] fix(physics): restore retail path-6 collision response --- .../retail-divergence-register.md | 4 +- ...0-response-layer-edge-family-pseudocode.md | 89 +++-- src/AcDream.Core/Physics/BSPQuery.cs | 57 +--- src/AcDream.Core/Physics/FlatBspQuery.cs | 40 +-- .../Physics/BSPStepUpTests.cs | 40 +-- .../RetailEdgeResponseOrderingTests.cs | 105 +++++- .../Physics/Ts4Path6ConformanceTests.cs | 321 ++++++++++++++++++ .../Physics/Ts4SteepRoofWedgeCaptureTests.cs | 90 ++--- 8 files changed, 523 insertions(+), 223 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b595dc1c..f845dff9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -236,12 +236,12 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~AP-127~~ | **RETIRED 2026-07-31 (#268).** `PlayerSkillMath` now owns retail `CACQualities::InqSkill` ordering for both panel values and Runtime run/jump prediction: intrinsic + positive 0x16D all-skills + the exact +10 category switch, then `EnchantSkill`, then 0x146 Jack of All Trades +5 and specialized-only `2 × 0x158`. Live player PropertyInt changes refresh the immutable Runtime augmentation snapshot. The separately described current-stamina local-copy nuance was re-audited: the query reads current stamina, but ordinary max-vital buffs target the max-secondary key and do not create stamina when current is zero; no independently observable residual remains. | `src/AcDream.Core/Player/PlayerSkillMath.cs`; `src/AcDream.Core/Player/LocalPlayerState.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs`; `src/AcDream.Runtime/Session/LiveSessionEventRouter.cs` | — | — | `CACQualities::InqSkill @ 0x00592660`; `CACQualities::InqRunRate @ 0x00592800`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0` | | AP-128 | **PK-timer jump-cost clock basis unconfirmed** (filed at the P3 Opus review, 2026-07-30): `PlayerWeenie.JumpStaminaCost` evaluates retail's 20-second PK-recency window (`LastPkAttackTimestamp` PropertyFloat 0x91 + 20.0 >= now) against `Environment.TickCount64` process-uptime seconds. The magnitude argument is sound (a 32-bit float cannot hold a Unix epoch with sub-second precision — a conformance test caught the ±128 s swallow), but the wire timestamp's own basis is the SERVER's, so a cross-base compare is latent. INERT today: ACE models neither property, so `_lastPkAttackTimestamp` is never pushed and the branch never fires. | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost` remarks) | Branch unreachable against every ACE-family server; non-PK cost is bit-identical to pre-P3. The basis question is cdb-answerable (`Timer::cur_time` epoch) if a PK server is ever targeted. | Against a hypothetical server that sends PropertyFloat 0x91, the PK cost bump fires arbitrarily (always/never) instead of on the 20-second window. | `CACQualities::JumpStaminaCost 0x00591b90` pc 412934-412968; `Timer::cur_time`; stat-coupled pseudocode doc §12b | -## 4. Temporary stopgap (TS) — 34 active rows (TS-8 retired 2026-07-31 — live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately; Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-4/TS-5/TS-23/TS-46 retired by ports — ZERO goal-enumerated physics stopgaps remain; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 34 active rows (TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 | -| TS-4 | **RE-OPENED 2026-07-30 after the matrix live gate**: the fixture-gated removal shipped and the user immediately hit the wedge live ("stuck sliding on an edge") plus a non-retail uphill-jump bounce and lost roof slides — the horizontal-velocity convergence claim under-modeled real trajectories. Removal reverted; the oracle plan §7 degenerate analysis needs live-capture-driven rework before any retry. Original row: Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | +| ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and both BSP-layer `SetSlidingNormal` writes are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. Production-shaped multi-frame vertical/inward/tangential/uphill/downhill roof/wall/ledge traces carry accepted body state between frames, match graph/flat by raw result/body bits, reject penetration and uphill launch/bounce, and pass the complete historical collision matrix without compensation. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | | ~~TS-8~~ | **RETIRED 2026-07-31 (#268 stat-chain closeout).** `EnchantmentWireReader` parses the complete 0x02C2 payload and `GameEventWiring` publishes its StatMod type/key/value and bucket through the same `ActiveEnchantmentRecord` used at login. An end-to-end dispatch test proves a mid-session skill modifier changes `LocalPlayerState.GetEffectiveSkill` immediately. | `src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` | — | — | `CEnchantmentRegistry::EnchantAttribute @ 0x00594570`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0`; holtburger `messages/magic/types.rs` | diff --git a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md index fe44a7d1..cbd10b33 100644 --- a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md +++ b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md @@ -1,14 +1,17 @@ # P2 — Collision response-layer edge family: port-ready pseudocode -**Status: RESEARCH PASS COMPLETE (2026-07-30); RETAIL RESPONSE ORDER PORTED -(2026-07-31).** Originally a research-only doc for Campaign P Slice P2 -(`docs/plans/2026-07-29-physics-parity-campaign.md` §P2); a same-day -implementation session landed TS-1's retirement and AP-7's fix, attempted -TS-4 per this doc's own §6 Step 3 fixture-first order, reproduced the -historical wedge, and stopped — see §7 item 6 for the full capture and -root-cause diagnosis. TS-4 is NOT retired; its shortcut stays in place. +**Status: RETAIL RESPONSE ORDER COMPLETE (2026-07-31).** Originally a +research-only doc for Campaign P Slice P2 +(`docs/plans/2026-07-29-physics-parity-campaign.md` §P2). The first TS-4 +attempt reproduced a steep-roof fixed point because its fixture discarded +the accepted contact state between frames; §7 item 6 preserves that useful +failure analysis. Slice 1B then completed the exact nested edge/StepDown +dispatcher, Slice 2A restored StepDown's mandatory Placement tail, and Slice +2B repeated the removal with production-shaped state carry and the complete +direction matrix. TS-4 is now retired; see §10. #166 got a reattribution note in ISSUES.md rather than new code (per §3). -#116 remains untouched (oracle-first, out of implementation scope). +#116's former skipped wall control is now active with the retail Path-6 +first-frame hard stop and next-frame slide chronology. **Headline findings that change the plan's assumptions:** TS-1 was already substantially ported (the register row and plan phrasing were stale — see §2); the one real gap needed no code change (acdream's unified world-space @@ -17,17 +20,11 @@ substantially ported (the register row and plan phrasing were stale — see NOT about a literal `PhysicsState.Sledding` auto-toggle at all (see §3); AP-7's L.3c regression does not reproduce on the production graphical root-motion path post-R6, and now ports retail's confirmed 0.25f threshold -(see §1); TS-4's shortcut removal is coupled to TS-1's completion and -reproduces a wedge even after TS-1 lands — see the §7 item 6 update for the -precise mechanism (Phase 3 of `TransitionalInsert` is structurally -unreachable from Path 6's unconditional `SetCollide`, which returns -`Adjusted` without repositioning the sphere) and what a future attempt -needs to check first; #116 remains a genuine oracle-first research item -needing live cdb/Ghidra, not an implementation item (see §5). Read §6 (port -order) before starting further implementation on this family. Campaign P -Slice 1B subsequently performed that fresh `transitional_insert` read and -removed AP-3, AP-4, AD-53, and AD-54; the exact closeout and controls are in -§8. TS-4 remains deliberately unchanged. +(see §1). The apparent TS-4 wedge was a harness-state defect, not a reason to +retain a product compensation: production-shaped vertical, inward, +tangential, uphill, and downhill histories now pass graph/flat raw-bit +parity without the shortcut. Campaign P Slice 1B removed AP-3, AP-4, AD-53, +and AD-54 (§8); Slice 2A retired AP-5 (§9); Slice 2B retired TS-4 (§10). Every claim below is tagged **FACT** (grep/read-verified against the named-retail decomp, the register, ISSUES.md, or current acdream source @@ -599,12 +596,13 @@ manufacture a caller that sets it. ## 4. TS-4 — Path-6 steep-poly shortcut removal -### The current shortcut (FACT, `src/AcDream.Core/Physics/BSPQuery.cs:2149-2266`) +### The former shortcut (HISTORICAL FACT; removed by Slice 2B) -Path-6 (the default `sphere_intersects_poly → collide_with_pt / SetCollide` -dispatch) tests each hit polygon's world-space normal. For BOTH sphere0 -(feet) and sphere1 (head), if `worldNormal.Z < PhysicsGlobals.FloorZ` -(steeper than ~49° from horizontal), acdream takes a SPECIAL BRANCH: +Before Slice 2B, Path-6 (the default +`sphere_intersects_poly → collide_with_pt / SetCollide` dispatch) tested each +hit polygon's world-space normal. For BOTH sphere0 (feet) and sphere1 (head), +if `worldNormal.Z < PhysicsGlobals.FloorZ` (steeper than ~49° from +horizontal), acdream took a SPECIAL BRANCH: projects the move along the steep face, writes `collisions.SetCollisionNormal(worldNormal)` **and** `collisions.SetSlidingNormal(worldNormal)`, and returns @@ -653,7 +651,7 @@ and — when that surface turns out too steep to be walkable — `EdgeSlideAfterStepDownFailed` → `CliffSlide`/`PrecipiceSlide` (TS-1's domain, §2 above). -### TS-4 port shape (FACT-grounded, mechanically simple) +### TS-4 port shape (COMPLETED 2026-07-31) Delete both `if (worldNormal{0,1}.Z < PhysicsGlobals.FloorZ) { ... return TransitionState.Slid; }` blocks (`BSPQuery.cs:2200-2215` and @@ -666,7 +664,7 @@ retires both `SetSlidingNormal` write sites (satisfying DO-NOT-RETRY §0 item 1 permanently — deleted, not just avoided) with no replacement logic needed at this layer. -### ⚠️ Port-order coupling with TS-1 (INFERENCE, but directly evidenced by the shortcut's own commit history) +### Port-order coupling with TS-1 (historical guard, now satisfied) **This is the single most important sequencing fact in this whole document.** The shortcut's comment proves TS-1's retail-faithful chain @@ -1182,3 +1180,42 @@ The #273, #271, #185, StepUp, transition-retry, and TS-4 controls remain unchanged. AP-5 is retired; TS-4 is intentionally untouched. --- + +## 10. Campaign P Slice 2B closeout — TS-4 retired (2026-07-31) + +A fresh read of `BSPTREE::find_collisions` (`0x0053A440`) confirms the exact +Path-6 split. A primary/foot-sphere polygon hit transforms the polygon normal, +calls `SPHEREPATH::set_collide`, writes `LandingZ`, and returns `ADJUSTED_TS` +(`0x0053A7B3..0x0053A7DC`) regardless of steepness. Only when that sphere is +clear does a secondary/head-sphere hit write `collision_normal` and return +`COLLIDED_TS` (`0x0053A793..0x0053A7A4`). Neither branch writes +`sliding_normal`. + +Both parsed-graph and prepared-flat Path-6 implementations now follow that +split exactly. The steepness branches, in-place tangent projection, and BSP- +layer `SetSlidingNormal` writes are deleted. Exact site tests compare the two +representations by raw bits and pin every mutated and preserved field: + +- foot: `SetCollide`, candidate backup, transformed `StepUpNormal`, + `WalkInterp=1`, `WalkableAllowance=LandingZ`, `Adjusted`; +- head: `CollisionNormal`, `Collided`, with no `SetCollide` state mutation; +- both: a pre-existing sliding normal is preserved byte-for-byte. + +The failed first removal was a test-harness lesson, not a retail exception. +Its gravity-only replay always passed `isOnGround:false` and discarded each +accepted result's Contact/OnWalkable bits, making the nested edge/StepDown +chain impossible to exercise on the next frame. The replacement replay uses +the same retained `PhysicsBody` chronology as production. Parsed graph and +prepared flat now match by raw result/body bits for vertical roof descent, +downhill input, uphill pressure (including a no-launch/no-bounce assertion), +tangential roof travel, inward-plus-tangent wall travel, and flat-roof ledge +rejection. Every trace asserts finite bounded motion and signed-plane non- +penetration. The former #116 D4 control is active: its primary-sphere hit +hard-stops frame one through SetCollide, then the accepted persistent normal +permits the downward slide on frame two. + +The complete historical #273/#271/#269/#265/#185/#137/#116/cellar/roof +matrix and the full Core Release suite pass without a replacement +compensation. TS-4 is retired. + +--- diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 2d08eb6e..ee73ebaf 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -2213,59 +2213,10 @@ public static class BSPQuery } var worldNormal0 = L2W(hitPoly0!.Plane.Normal); - - // L.4 slide-tangent for steep airborne hits (2026-04-30). - // - // For polygons too steep to walk on (worldNormal.Z < FloorZ), - // skip the SetCollide → Path-4 → ContactPlane landing chain. - // That chain commits the body to the steep surface, leading - // to the "stuck in falling animation on the roof" bug — once - // grounded with a steep ContactPlane, our step_up_slide / - // cliff_slide / edge_slide chain can't produce smooth - // descent and the body wedges or "falls a bit at a time" - // when bumped. - // - // Instead: project the move along the steep face (remove - // the into-wall displacement), set CollisionNormal + - // SlidingNormal, return Slid. Same shape as Path 5's - // step-up fallback (line 1545-1547) and CylinderCollision - // (TransitionTypes.cs:1518-1522). Position is updated in- - // place; on the next resolver iteration the sphere is - // outside the poly, FindCollisions returns OK, and - // ValidateTransition commits the new position. Body stays - // airborne, falling animation continues, and gravity's - // tangent component drifts the body downhill until it - // slides off the slope's edge. - // - // This is a deliberate deviation from retail (retail uses - // SetCollide unconditionally and lets find_walkable + - // step_up_slide produce the slide). Validated against - // retail debugger trace 2026-04-30: retail body did not - // wedge; our retail-faithful port DID wedge because we're - // missing implementation details of the step_up_slide / - // cliff_slide chain on grounded-steep movement. The - // slide-tangent here produces user-acceptable behavior - // (slides off naturally) while the deeper chain port is - // researched. Filed as L.5+ followup for retail-strict. - if (worldNormal0.Z < PhysicsGlobals.FloorZ) - { - Vector3 currWorld = path.GlobalCurrCenter[0].Origin; - Vector3 endWorld = path.GlobalSphere[0].Origin; - Vector3 gDelta = endWorld - currWorld; - float diff = Vector3.Dot(worldNormal0, gDelta); - if (diff < 0f) - path.AddOffsetToCheckPos(-worldNormal0 * diff); - - collisions.SetCollisionNormal(worldNormal0); - collisions.SetSlidingNormal(worldNormal0); - // L.2d slice 1 (2026-05-13): diagnostic side-channel. - if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) - PhysicsDiagnostics.LastBspHitPoly = hitPoly0; - return TransitionState.Slid; - } - - // ─── SetCollide response (shallow / walkable) ─────────── - // Per retail (acclient_2013_pseudo_c.txt:323783-323821). + // Retail Path 6 always defers a primary/foot-sphere hit to + // the outer Collide handler, regardless of polygon steepness. + // The BSP layer neither projects a tangent nor writes the + // persistent sliding normal (0x0053A7B3-0x0053A7DC). path.SetCollide(worldNormal0); path.WalkableAllowance = PhysicsGlobals.LandingZ; // L.2d slice 1 (2026-05-13): diagnostic side-channel. diff --git a/src/AcDream.Core/Physics/FlatBspQuery.cs b/src/AcDream.Core/Physics/FlatBspQuery.cs index dd39e821..658c50b5 100644 --- a/src/AcDream.Core/Physics/FlatBspQuery.cs +++ b/src/AcDream.Core/Physics/FlatBspQuery.cs @@ -2036,23 +2036,6 @@ internal static class FlatBspQuery Vector3 worldNormal0 = LocalToWorld( tree.PolygonTable.Polygons[defaultHitPolygonIndex0].Plane.Normal); - if (worldNormal0.Z < PhysicsGlobals.FloorZ) - { - Vector3 currentWorld = - path.GlobalCurrCenter[0].Origin; - Vector3 endWorld = - path.GlobalSphere[0].Origin; - Vector3 globalDelta = endWorld - currentWorld; - float difference = Vector3.Dot(worldNormal0, globalDelta); - if (difference < 0f) - path.AddOffsetToCheckPos(-worldNormal0 * difference); - - collisions.SetCollisionNormal(worldNormal0); - collisions.SetSlidingNormal(worldNormal0); - RecordDiagnosticHit(tree, defaultHitPolygonIndex0); - return TransitionState.Slid; - } - path.SetCollide(worldNormal0); path.WalkableAllowance = PhysicsGlobals.LandingZ; RecordDiagnosticHit(tree, defaultHitPolygonIndex0); @@ -2075,28 +2058,9 @@ internal static class FlatBspQuery { Vector3 worldNormal1 = LocalToWorld( tree.PolygonTable.Polygons[defaultHitPolygonIndex1].Plane.Normal); - if (worldNormal1.Z < PhysicsGlobals.FloorZ) - { - Vector3 currentWorld = - path.GlobalCurrCenter[0].Origin; - Vector3 endWorld = - path.GlobalSphere[0].Origin; - Vector3 globalDelta = endWorld - currentWorld; - float difference = - Vector3.Dot(worldNormal1, globalDelta); - if (difference < 0f) - path.AddOffsetToCheckPos(-worldNormal1 * difference); - - collisions.SetCollisionNormal(worldNormal1); - collisions.SetSlidingNormal(worldNormal1); - RecordDiagnosticHit(tree, defaultHitPolygonIndex1); - return TransitionState.Slid; - } - - path.SetCollide(worldNormal1); - path.WalkableAllowance = PhysicsGlobals.LandingZ; + collisions.SetCollisionNormal(worldNormal1); RecordDiagnosticHit(tree, defaultHitPolygonIndex1); - return TransitionState.Adjusted; + return TransitionState.Collided; } } diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs index ce837e81..c0655a62 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -396,18 +396,12 @@ public class BSPStepUpTests /// /// Airborne mover descending toward a steep slope (normal.Z < FloorZ): - /// Path 6 returns and does NOT set - /// the Collide flag — the steep-normal slide-tangent branch (L.4, - /// commit b1af56e, 2026-04-30) intercepts the hit before SetCollide is - /// called and projects the move along the steep face instead, keeping the - /// body airborne with the falling animation. - /// - /// This is a documented intentional deviation from retail (retail calls - /// set_collide unconditionally; our interim port uses slide-tangent while - /// the retail step_up_slide / cliff_slide chain port is completed). + /// retail Path 6 still calls SetCollide, installs LandingZ, and returns + /// Adjusted. Polygon steepness is handled by the outer transition chain, + /// not by a BSP-layer tangent shortcut. /// [Fact] - public void C3_Path6_AirborneMoverHitsSteepSlope_ReturnsSlid() + public void C3_Path6_AirborneMoverHitsSteepSlope_DefersThroughSetCollide() { var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable(); @@ -427,13 +421,11 @@ public class BSPStepUpTests root, resolved, t, localSphere, null, currPos, Vector3.UnitZ, 1.0f); - // L.4 slide-tangent (b1af56e, 2026-04-30): steep polygon hit by - // airborne sphere returns Slid (not Adjusted) and does NOT set - // the Collide flag — the into-wall displacement is removed and - // CollisionNormal/SlidingNormal are set instead. - Assert.Equal(TransitionState.Slid, result); - Assert.False(t.SpherePath.Collide, - "Collide must NOT be set when the L.4 steep-slope slide-tangent fires"); + Assert.Equal(TransitionState.Adjusted, result); + Assert.True(t.SpherePath.Collide); + Assert.Equal(PhysicsGlobals.LandingZ, t.SpherePath.WalkableAllowance); + Assert.False(t.CollisionInfo.CollisionNormalValid); + Assert.False(t.CollisionInfo.SlidingNormalValid); } // ========================================================================= @@ -594,17 +586,7 @@ public class BSPStepUpTests /// every frame replays the same hard stop and the character hangs in falling /// animation until another correction breaks the loop. /// - [Fact(Skip = "Issue #116 shape-2 — the engine slides IN-FRAME to Z=1.92 " + - "on the first airborne wall frame; this pin expects an L.2c hard stop " + - "at Z=2.0. Ghidra (2026-06-12) confirms retail CSphere::slide_sphere " + - "(0x00537440) applies the slide IN-FRAME (add_offset_to_check_pos → " + - "SLID_TS), so our 1.92 is faithful TO slide_sphere and the Z=2.0 " + - "expectation is the SUSPECT half — but whether retail's first " + - "airborne frame REACHES slide_sphere (→1.92) or hard-stops upstream " + - "(collide_with_environment dispatch / no last-known plane) needs a " + - "cdb trace of an airborne wall hit before flipping the assertion. The " + - "#116 threshold fix (EpsilonSq→F_EPSILON) did NOT change this — the D4 " + - "offset is a real slide, not degenerate. See docs/ISSUES.md #116.")] + [Fact] public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames() { var (root, resolved) = BSPStepUpFixtures.TallWall(); @@ -630,6 +612,8 @@ public class BSPStepUpTests Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding), "First airborne wall hit should cache SlidingNormal for the next frame."); + // Path 6's primary-sphere SetCollide hard-stops this first frame; the + // persisted normal then permits the downward tangent on frame two. Assert.Equal(2.0f, frame1.Position.Z, precision: 3); var frame2 = engine.ResolveWithTransition( diff --git a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs index 410afc3e..89946344 100644 --- a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs @@ -231,16 +231,18 @@ public sealed class RetailEdgeResponseOrderingTests } [Fact] - public void MultiFrameSteepRoof_GraphAndFlatTraversalRemainExactAndDoNotWedge() + public void MultiFrameSteepRoof_PureVertical_GraphAndFlatSlideDownhillWithoutWedge() { - TraceRun graph = RunSteepRoofTrace(preparedFlat: false); - TraceRun flat = RunSteepRoofTrace(preparedFlat: true); + TraceRun graph = RunSteepRoofTrace(preparedFlat: false, Vector2.Zero); + TraceRun flat = RunSteepRoofTrace(preparedFlat: true, Vector2.Zero); AssertTraceParity(graph, flat); - Assert.Contains(graph.Frames, frame => - frame.Result.Position.X < 0f - && frame.Result.Position.Z <= BSPStepUpFixtures.SphereRadius + 0.05f); + Assert.Contains(graph.Frames, frame => frame.Result.InContact); AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 15); + Assert.True(graph.Frames[^1].Result.Position.X + < graph.Frames[0].Result.Position.X - 0.20f, + $"The vertical trace did not descend the roof: " + + $"{graph.Frames[0].Result.Position} -> {graph.Frames[^1].Result.Position}."); Plane slope = BSPStepUpFixtures.SlopedUnwalkable().Resolved[ BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane; @@ -272,6 +274,69 @@ public sealed class RetailEdgeResponseOrderingTests } } + [Theory] + [InlineData(-0.30f, 0f, "downhill")] + [InlineData( 0.30f, 0f, "uphill")] + [InlineData( 0f, 0.30f, "tangential")] + public void MultiFrameSteepRoof_DirectionalMotion_RemainsExactAndPreservesRetailResponse( + float velocityX, + float velocityY, + string direction) + { + Vector2 horizontalVelocity = new(velocityX, velocityY); + TraceRun graph = RunSteepRoofTrace(preparedFlat: false, horizontalVelocity); + TraceRun flat = RunSteepRoofTrace(preparedFlat: true, horizontalVelocity); + + AssertTraceParity(graph, flat); + Assert.Contains(graph.Frames, frame => frame.Result.InContact); + AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 15); + + Vector3 first = graph.Frames[0].Result.Position; + Vector3 last = graph.Frames[^1].Result.Position; + Vector2 progress = new(last.X - first.X, last.Y - first.Y); + if (direction == "uphill") + { + int contactFrame = graph.Frames.FindIndex(frame => frame.Result.InContact); + Assert.True(contactFrame >= 0); + float peakAfterContact = graph.Frames + .GetRange(contactFrame, graph.Frames.Count - contactFrame) + .Max(frame => frame.Result.Position.Z); + Assert.True(peakAfterContact <= graph.Frames[contactFrame].Result.Position.Z + 0.001f, + $"The uphill trace launched/bounced from the roof: " + + $"contactZ={graph.Frames[contactFrame].Result.Position.Z}, peak={peakAfterContact}."); + } + else + { + Assert.True(Vector2.Dot(progress, Vector2.Normalize(horizontalVelocity)) > 0.10f, + $"The {direction} trace lost requested progress: {first} -> {last}."); + } + + Plane slope = BSPStepUpFixtures.SlopedUnwalkable().Resolved[ + BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane; + float radius = BSPStepUpFixtures.SphereRadius; + for (int i = 0; i < graph.Frames.Count; i++) + { + Vector3 position = graph.Frames[i].Result.Position; + AssertFinite(position, $"steep-roof {direction} frame {i}"); + if (i > 0) + { + float distance = Vector3.Distance( + graph.Frames[i - 1].Result.Position, + position); + Assert.InRange(distance, 0f, 1.1f); + } + + if (position.X is >= 0f and <= 1f && MathF.Abs(position.Y) <= 1f) + { + Vector3 footCenter = position + new Vector3(0f, 0f, radius); + float signedDistance = Vector3.Dot(slope.Normal, footCenter) + slope.D; + Assert.True(signedDistance >= radius - 0.015f, + $"Steep-roof {direction} penetration at frame {i}: " + + $"distance={signedDistance}, radius={radius}, position={position}."); + } + } + } + [Fact] public void MultiFrameFlatRoofLedge_GraphAndFlatTraversalRemainExactAndSlideAlongEdge() { @@ -325,7 +390,7 @@ public sealed class RetailEdgeResponseOrderingTests [Theory] [InlineData(false)] [InlineData(true)] - public void MultiFrameGroundedFloorWallSlide_FinalPlacementIsMandatoryAndGraphFlatExact( + public void MultiFrameGroundedFloorWallSlide_InwardTangentialMotionIsGraphFlatExact( bool twoSpheres) { WallMaintenanceTrace graph = RunGroundedFloorWallSlide( @@ -391,14 +456,22 @@ public sealed class RetailEdgeResponseOrderingTests new(-2f, 2f, 0f), ]; - private static TraceRun RunSteepRoofTrace(bool preparedFlat) + private static TraceRun RunSteepRoofTrace( + bool preparedFlat, + Vector2 horizontalVelocity) { var fixture = BSPStepUpFixtures.SlopedUnwalkable(); PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E101u); float radius = BSPStepUpFixtures.SphereRadius; const float dt = 1f / 30f; const float gravity = -9.8f; - var body = new PhysicsBody { TransientState = TransientStateFlags.Active }; + var body = new PhysicsBody + { + Position = new Vector3(0.5f, 0f, 3f), + Orientation = Quaternion.Identity, + State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.Active, + }; Vector3 position = new(0.5f, 0f, 3f); float velocityZ = 0f; var trace = new List(90); @@ -406,15 +479,19 @@ public sealed class RetailEdgeResponseOrderingTests for (int tick = 0; tick < 90; tick++) { velocityZ += gravity * dt; + body.Velocity = new Vector3( + horizontalVelocity.X, + horizontalVelocity.Y, + velocityZ); ResolveResult result = engine.ResolveWithTransition( position, - position + new Vector3(0f, 0f, velocityZ * dt), + position + body.Velocity * dt, Cell, radius, radius * 2f, stepUpHeight: 0.30f, stepDownHeight: 0.04f, - isOnGround: false, + isOnGround: body.OnWalkable, body, ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0x01000000u); @@ -422,7 +499,13 @@ public sealed class RetailEdgeResponseOrderingTests position = result.Position; body.Position = position; if (result.IsOnGround) + { velocityZ = 0f; + body.Velocity = new Vector3( + horizontalVelocity.X, + horizontalVelocity.Y, + 0f); + } ApplyContactResult(body, result); trace.Add(CaptureFrame(result, body)); diff --git a/tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs new file mode 100644 index 00000000..ae5e1af5 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs @@ -0,0 +1,321 @@ +using System.Collections.Generic; +using System.Numerics; +using System.Text; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Exact site tests for retail BSPTREE::find_collisions Path 6 +/// (0x0053A793..0x0053A7DC). Primary-sphere hits defer through +/// SetCollide; secondary-only hits hard-stop. Neither BSP branch owns the +/// persistent sliding normal. +/// +public sealed class Ts4Path6ConformanceTests +{ + private const uint Cell = 0xA9B40001u; + private const float Radius = BSPStepUpFixtures.SphereRadius; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PrimarySteepHit_GraphAndFlat_SetCollideWithExactState( + bool seedSlidingNormal) + { + var fixture = Normalize(BSPStepUpFixtures.SlopedUnwalkable()); + FlatPhysicsBsp flat = FlatCollisionAssetBuilder.FlattenPhysicsBsp( + fixture.Root, + fixture.Resolved); + Vector3 currentBody = new(0.5f, 0f, 1.1f); + Vector3 targetBody = new(0.5f, 0f, 0.9f); + Vector3 targetCenter = targetBody + new Vector3(0f, 0f, Radius); + Vector3 expectedNormal = fixture.Resolved[ + BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane.Normal; + Vector3 seededSliding = Vector3.UnitY; + + SiteOutcome graph = Run( + fixture.Root, + fixture.Resolved, + flat: null, + currentBody, + targetBody, + new Sphere { Origin = targetCenter, Radius = Radius }, + head: null, + seedSlidingNormal, + seededSliding); + SiteOutcome prepared = Run( + root: null, + fixture.Resolved, + flat, + currentBody, + targetBody, + new Sphere { Origin = targetCenter, Radius = Radius }, + head: null, + seedSlidingNormal, + seededSliding); + + Assert.Equal(graph.Bits, prepared.Bits); + Assert.Equal(TransitionState.Adjusted, graph.State); + Assert.True(graph.Collide); + AssertVectorBits(targetBody, graph.CheckPos); + Assert.Equal(Cell, graph.CheckCellId); + AssertVectorBits(targetBody, graph.BackupCheckPos); + Assert.Equal(Cell, graph.BackupCheckCellId); + AssertVectorBits(expectedNormal, graph.StepUpNormal); + AssertFloatBits(1f, graph.WalkInterp); + AssertFloatBits(PhysicsGlobals.LandingZ, graph.WalkableAllowance); + Assert.False(graph.CollisionNormalValid); + Assert.Equal(seedSlidingNormal, graph.SlidingNormalValid); + AssertVectorBits( + seedSlidingNormal ? seededSliding : Vector3.Zero, + graph.SlidingNormal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SecondaryOnlyHit_GraphAndFlat_HardStopsWithoutSetCollide( + bool seedSlidingNormal) + { + (PhysicsBSPNode root, Dictionary resolved) = + BuildRaisedWall(); + FlatPhysicsBsp flat = FlatCollisionAssetBuilder.FlattenPhysicsBsp( + root, + resolved); + Vector3 currentBody = new(0.1f, 0f, 0f); + Vector3 targetBody = new(0.35f, 0f, 0f); + var foot = new Sphere + { + Origin = targetBody + new Vector3(0f, 0f, Radius), + Radius = Radius, + }; + var head = new Sphere + { + Origin = targetBody + new Vector3(0f, 0f, 0.8f), + Radius = Radius, + }; + Vector3 seededSliding = Vector3.UnitY; + + SiteOutcome graph = Run( + root, + resolved, + flat: null, + currentBody, + targetBody, + foot, + head, + seedSlidingNormal, + seededSliding); + SiteOutcome prepared = Run( + root: null, + resolved, + flat, + currentBody, + targetBody, + foot, + head, + seedSlidingNormal, + seededSliding); + + Assert.Equal(graph.Bits, prepared.Bits); + Assert.Equal(TransitionState.Collided, graph.State); + Assert.False(graph.Collide); + AssertVectorBits(targetBody, graph.CheckPos); + Assert.Equal(Cell, graph.CheckCellId); + AssertVectorBits(new Vector3(91f, 92f, 93f), graph.BackupCheckPos); + Assert.Equal(0xA9B40077u, graph.BackupCheckCellId); + AssertVectorBits(Vector3.Zero, graph.StepUpNormal); + AssertFloatBits(0.625f, graph.WalkInterp); + AssertFloatBits(0.8125f, graph.WalkableAllowance); + Assert.True(graph.CollisionNormalValid); + AssertFloatBits(-1f, graph.CollisionNormal.X); + Assert.Equal(0f, graph.CollisionNormal.Y); + Assert.Equal(0f, graph.CollisionNormal.Z); + Assert.Equal(seedSlidingNormal, graph.SlidingNormalValid); + AssertVectorBits( + seedSlidingNormal ? seededSliding : Vector3.Zero, + graph.SlidingNormal); + } + + private static SiteOutcome Run( + PhysicsBSPNode? root, + Dictionary resolved, + FlatPhysicsBsp? flat, + Vector3 currentBody, + Vector3 targetBody, + Sphere foot, + Sphere? head, + bool seedSlidingNormal, + Vector3 seededSliding) + { + var transition = new Transition(); + transition.SpherePath.InitPath( + currentBody, + targetBody, + Cell, + Radius, + sphereHeight: head is null ? 0f : 1f); + transition.SpherePath.SetCheckPos(targetBody, Cell); + transition.SpherePath.BackupCheckPos = new Vector3(91f, 92f, 93f); + transition.SpherePath.BackupCheckCellId = 0xA9B40077u; + transition.SpherePath.WalkInterp = 0.625f; + transition.SpherePath.WalkableAllowance = 0.8125f; + if (seedSlidingNormal) + transition.CollisionInfo.SetSlidingNormal(seededSliding); + + TransitionState state = flat is null + ? BSPQuery.FindCollisions( + root, + resolved, + transition, + foot, + head, + currentBody, + Vector3.UnitZ, + 1f) + : FlatBspQuery.FindCollisions( + flat, + transition, + foot, + head, + currentBody, + Vector3.UnitZ, + 1f); + + SpherePath path = transition.SpherePath; + CollisionInfo collision = transition.CollisionInfo; + return new SiteOutcome( + state, + path.Collide, + path.CheckPos, + path.CheckCellId, + path.BackupCheckPos, + path.BackupCheckCellId, + path.StepUpNormal, + path.WalkInterp, + path.WalkableAllowance, + collision.CollisionNormalValid, + collision.CollisionNormal, + collision.SlidingNormalValid, + collision.SlidingNormal, + Signature(state, path, collision)); + } + + private static ( + PhysicsBSPNode Root, + Dictionary Resolved) BuildRaisedWall() + { + Vector3[] vertices = + [ + new(0.5f, -1f, 0.55f), + new(0.5f, -1f, 2.5f), + new(0.5f, 1f, 2.5f), + new(0.5f, 1f, 0.55f), + ]; + var root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = new Vector3(0.5f, 0f, 1.5f), + Radius = 4f, + }, + }; + root.Polygons.Add(1); + var resolved = new Dictionary + { + [1] = new ResolvedPolygon + { + Id = 1, + Vertices = vertices, + Plane = new Plane(-Vector3.UnitX, 0.5f), + NumPoints = vertices.Length, + SidesType = CullMode.None, + }, + }; + return (root, resolved); + } + + private static ( + PhysicsBSPNode Root, + Dictionary Resolved) Normalize( + (PhysicsBSPNode Root, Dictionary Resolved) fixture) + { + var resolved = new Dictionary(fixture.Resolved.Count); + foreach ((ushort id, ResolvedPolygon polygon) in fixture.Resolved) + { + resolved.Add(id, new ResolvedPolygon + { + Id = id, + Vertices = polygon.Vertices, + Plane = polygon.Plane, + NumPoints = polygon.NumPoints, + SidesType = polygon.SidesType, + }); + } + + return (fixture.Root, resolved); + } + + private static string Signature( + TransitionState state, + SpherePath path, + CollisionInfo collision) + { + var bits = new StringBuilder(256); + bits.Append((int)state).Append('|').Append(path.Collide ? 1 : 0).Append('|'); + Append(bits, path.CheckPos); + bits.Append(path.CheckCellId.ToString("X8")).Append('|'); + Append(bits, path.BackupCheckPos); + bits.Append(path.BackupCheckCellId.ToString("X8")).Append('|'); + Append(bits, path.StepUpNormal); + Append(bits, path.WalkInterp); + Append(bits, path.WalkableAllowance); + bits.Append(collision.CollisionNormalValid ? 1 : 0).Append('|'); + Append(bits, collision.CollisionNormal); + bits.Append(collision.SlidingNormalValid ? 1 : 0).Append('|'); + Append(bits, collision.SlidingNormal); + return bits.ToString(); + } + + private static void Append(StringBuilder target, float value) => + target.Append(BitConverter.SingleToUInt32Bits(value).ToString("X8")).Append('|'); + + private static void Append(StringBuilder target, Vector3 value) + { + Append(target, value.X); + Append(target, value.Y); + Append(target, value.Z); + } + + private static void AssertFloatBits(float expected, float actual) => + Assert.Equal( + BitConverter.SingleToUInt32Bits(expected), + BitConverter.SingleToUInt32Bits(actual)); + + private static void AssertVectorBits(Vector3 expected, Vector3 actual) + { + AssertFloatBits(expected.X, actual.X); + AssertFloatBits(expected.Y, actual.Y); + AssertFloatBits(expected.Z, actual.Z); + } + + private sealed record SiteOutcome( + TransitionState State, + bool Collide, + Vector3 CheckPos, + uint CheckCellId, + Vector3 BackupCheckPos, + uint BackupCheckCellId, + Vector3 StepUpNormal, + float WalkInterp, + float WalkableAllowance, + bool CollisionNormalValid, + Vector3 CollisionNormal, + bool SlidingNormalValid, + Vector3 SlidingNormal, + string Bits); +} diff --git a/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs index 29f55a22..3dcf6c22 100644 --- a/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using Xunit; @@ -7,51 +6,13 @@ using Xunit.Abstractions; namespace AcDream.Core.Tests.Physics; /// -/// Campaign P Slice P2, TS-4 (Section 6 Step 3): the 2026-04-30 "L.4" fixture -/// capture required before the Path-6 steep-poly slide-tangent shortcut may be -/// removed (docs/research/2026-07-30-response-layer-edge-family-pseudocode.md -/// §4, §6 Step 3). The original repro was a live-client jump onto a steep -/// roof that got the body "stuck in falling animation" for many frames; no -/// captured fixture from that live session survives in the repo (checked -/// docs/research/2026-04-30-* and the L.4 commit `b1af56e`), so this -/// test builds a dat-free multi-frame replay from the existing -/// geometry (a 63.4° -/// slope, normal.Z ≈ 0.447 — below PhysicsGlobals.FloorZ ≈ 0.6642 but -/// above PhysicsGlobals.LandingZ ≈ 0.0871, i.e. exactly the band the -/// L.4 commit's own steep-poly shortcut targets) using the same -/// PhysicsEngine.ResolveWithTransition multi-frame replay idiom as -/// Issue185OutdoorStairsSeamReplayTests. -/// -/// -/// A body falls from directly above the slope's mid-face, integrating -/// gravity between resolves exactly as PhysicsBody.UpdatePhysicsInternal -/// would, for up to 3 simulated seconds (90 ticks at 30 Hz — retail's physics -/// tick rate, #32 L.5). "Wedged" is defined precisely, matching the original -/// bug report ("stuck in falling animation on the roof" for many consecutive -/// frames): the body's position stops changing (within 1 mm) for more than -/// 15 consecutive ticks (0.5 s) while never reaching the flat reference -/// floor at x<0, z=0. A healthy resolution reaches the flat floor (Z ≈ -/// ) well before the 90-tick -/// budget expires, whether it does so by retail's own COLLIDED-then-fall -/// bounce (this file's own git history documents that as retail's actual -/// behavior for a clean Path-6 steep hit with no pre-existing contact plane) -/// or by committing to the steep "walkable" surface via the permissive -/// LandingZ threshold (matching CTransition::check_walkable, -/// pc:273202, 0.0871556997f) and then downhill-drifting off it via -/// the already-ported TS-1 CliffSlide chain. -/// -/// -/// -/// Run TWICE across this slice's git history: once with the Path-6 steep -/// shortcut ACTIVE (pins today's baseline — always green, since the -/// shortcut's own in-frame slide-tangent cannot wedge by construction), and -/// once with it REMOVED (the retail-strict candidate). If both pass, TS-4's -/// removal is evidenced safe and lands in the same commit that deletes the -/// shortcut and its SetSlidingNormal writes. If the removed-shortcut -/// run wedges, the shortcut stays and this file's result against ToT is the -/// recorded evidence — see the commit message / research doc open questions -/// for the outcome actually reached. -/// +/// Campaign P Slice 2B's production-shaped steep-roof control. It carries +/// contact state between 30 Hz resolves exactly as the live PhysicsBody path +/// does, so the nested edge/StepDown dispatcher can turn a vertical landing +/// into retail's downhill response instead of the old under-modeled fixed +/// point. The paired graph/flat direction matrix in +/// covers vertical, inward, +/// tangential, uphill, downhill, wall, roof, and ledge histories. /// public class Ts4SteepRoofWedgeCaptureTests { @@ -60,7 +21,7 @@ public class Ts4SteepRoofWedgeCaptureTests private const uint CellId = 0xA9B40001u; private const int TicksPerSecond = 30; // #32 L.5 retail physics tick rate - private const int MaxTicks = 3 * TicksPerSecond; + private const int MaxTicks = 6 * TicksPerSecond; private const int WedgeTickThreshold = 15; // 0.5 s of zero motion == wedged private const float WedgeEpsilon = 0.001f; // 1 mm @@ -112,12 +73,13 @@ public class Ts4SteepRoofWedgeCaptureTests } /// - /// Falls a player-flagged mover from directly above the 63.4° slope's - /// mid-face and asserts it reaches the flat floor (or at minimum keeps - /// making downward/downhill progress) without a >0.5s frozen stretch. + /// Falls a player-flagged mover from directly above the slope and carries + /// each frame's contact state into the next frame, matching the production + /// PhysicsBody path. The exact edge/step-down chain must move the body + /// downhill and onto the reference floor without a half-second wedge. /// [Fact] - public void FallOntoSteepSlope_NeverFreezesForOverHalfASecond_AndReachesFloor() + public void FallOntoSteepSlope_PureVertical_NeverWedgesAndReachesFloor() { var engine = MakeSlopeEngine(); float r = BSPStepUpFixtures.SphereRadius; @@ -135,7 +97,6 @@ public class Ts4SteepRoofWedgeCaptureTests float fallVelocityZ = 0f; uint cell = CellId; - var positions = new List(MaxTicks) { pos }; int frozenStreak = 0; bool reachedFloor = false; @@ -152,7 +113,7 @@ public class Ts4SteepRoofWedgeCaptureTests sphereHeight: r * 2f, stepUpHeight: 0.30f, stepDownHeight: 0.04f, - isOnGround: false, + isOnGround: body.OnWalkable, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0x01000000u); @@ -170,11 +131,6 @@ public class Ts4SteepRoofWedgeCaptureTests $"moved={moved:F4} onGround={result.IsOnGround} onWalkable={result.OnWalkable} " + $"contact={result.InContact} vz={fallVelocityZ:F2} frozen={frozenStreak}"); - Assert.True(frozenStreak <= WedgeTickThreshold, - $"Body frozen for {frozenStreak} consecutive ticks (>{WedgeTickThreshold} == " + - $">0.5s) at tick {tick}, position ({newPos.X:F3},{newPos.Y:F3},{newPos.Z:F3}) — " + - "this is the 'stuck in falling animation on the roof' wedge shape."); - pos = newPos; cell = result.CellId; body.Position = pos; @@ -182,9 +138,16 @@ public class Ts4SteepRoofWedgeCaptureTests if (result.IsOnGround) fallVelocityZ = 0f; - positions.Add(pos); + body.TransientState &= + ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); + if (result.InContact) + body.TransientState |= TransientStateFlags.Contact; + if (result.OnWalkable) + body.TransientState |= TransientStateFlags.OnWalkable; + + Assert.True(frozenStreak <= WedgeTickThreshold, + $"Body froze for {frozenStreak} ticks at {pos}."); - // Reached the flat reference floor (x<0, z ~ r) — resolved cleanly. if (pos.X < 0f && pos.Z <= r + 0.05f) { reachedFloor = true; @@ -193,10 +156,7 @@ public class Ts4SteepRoofWedgeCaptureTests } Assert.True(reachedFloor, - $"Body never reached the flat reference floor within {MaxTicks} ticks " + - $"({MaxTicks / (float)TicksPerSecond:F1}s); final position " + - $"({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) — this is the wedge the L.4 shortcut guards " + - "against (never resolving off the steep surface at all), distinct from a bounded " + - "per-tick freeze."); + $"The production-shaped vertical trace did not reach the floor within " + + $"{MaxTicks} ticks; final=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})."); } } From d3c0d9ec0e208d8d9f0791aaee809f83a5ac93ba Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 14:08:51 +0200 Subject: [PATCH 19/73] test(physics): harden TS-4 production chronology --- .../retail-divergence-register.md | 13 +- .../2026-07-29-physics-parity-campaign.md | 63 +- ...0-response-layer-edge-family-pseudocode.md | 103 ++- .../Ts4ProductionQuantumConformanceTests.cs | 695 ++++++++++++++++++ .../Physics/Ts4SteepRoofWedgeCaptureTests.cs | 34 +- 5 files changed, 807 insertions(+), 101 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f845dff9..94269d5f 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -241,7 +241,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 | -| ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and both BSP-layer `SetSlidingNormal` writes are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. Production-shaped multi-frame vertical/inward/tangential/uphill/downhill roof/wall/ledge traces carry accepted body state between frames, match graph/flat by raw result/body bits, reject penetration and uphill launch/bounce, and pass the complete historical collision matrix without compensation. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 | +| ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B; corrective acceptance complete).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and every BSP-layer `SetSlidingNormal` write are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. A corrective 90-tick already-airborne, zero-root-motion Core suite executes acceleration, body integration, transition resolution, exact commit, and `handle_all_collisions` while retaining every behavior-bearing collision/body field used by that specialized quantum. Vertical, inward, tangential, downhill, and positive-Z uphill-jump traces match graph/flat by raw bits, reject penetration/fixed points/second launches, and pin exact terminal velocity, contact, sliding, and contact-plane state. The older resolver-only capture is explicitly historical and restored to its three-second bound. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | | ~~TS-8~~ | **RETIRED 2026-07-31 (#268 stat-chain closeout).** `EnchantmentWireReader` parses the complete 0x02C2 payload and `GameEventWiring` publishes its StatMod type/key/value and bucket through the same `ActiveEnchantmentRecord` used at login. An end-to-end dispatch test proves a mid-session skill modifier changes `LocalPlayerState.GetEffectiveSkill` immediately. | `src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` | — | — | `CEnchantmentRegistry::EnchantAttribute @ 0x00594570`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0`; holtburger `messages/magic/types.rs` | @@ -306,12 +306,11 @@ phase-gated — they carry their trigger in their row and should land WITH that phase, not before. 1. **TS-27 — INBOUND retransmit handling** — the outbound sent-packet cache + resend landed with Campaign N Slice N1 (2026-07-29, class-doc gap list fixed same commit); the inbound sequence-aligned ISAAC + client NAK emission (N2/N4) remain the hard blocker for non-loopback play — one lost S2C packet still deafens the session permanently. -2. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis. -3. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). -4. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. -5. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. -6. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together. -7. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. +2. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). +3. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. +4. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. +5. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together. +6. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** M2 combat must land TS-25 diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index caa443cc..62f199c7 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -99,36 +99,30 @@ character state continuously. ### P2 — Response-layer edge family — retires TS-1, TS-4, AP-7; closes #166, #116 -**Status (2026-07-30, FINAL — Campaign P final physics slice):** TS-1 and -AP-7 retired same-day as originally recorded. **TS-4 is now ALSO -RETIRED** — the oracle follow-up pass -(`docs/research/2026-07-30-ts4-116-oracle-plan.md`) found the freeze the -first implementation attempt hit was one layer downstream of Path 6 -(inside `AdjustOffset`'s crease projection against a purely-vertical -offset — a genuine retail-identical degeneracy, not a bug) and ran the -plan's own decisive confirming test: `Ts4SteepRoofWedgeCaptureTests`'s -horizontal-velocity variant (matching the realistic live-play input that -originally validated the shortcut) converges cleanly with the shortcut -removed. The Path-6 steep-poly shortcut is deleted from both -`BSPQuery.cs` and `FlatBspQuery.cs`; the pure-vertical degenerate case is -pinned (not fixed) as register row AD-56. **#116 is narrowed, not -closed**: shape-2 (D4 first-airborne-frame hard-stop) is CLOSED — the -oracle plan's structural dispatch-routing hypothesis was confirmed by -instrumentation with no cdb session needed, and the D4 pin is un-skipped. -Shape-1 (tick-22760 lateral-slide loss) got a real, independently-decomp- -confirmed fix (Path 6's foot-clear/head-hit branch now returns `Collided` -+ `SetCollisionNormal` directly, matching pc:323824-323834/ACE -`BSPTree.cs:221-230`), but the confirming replay showed this does NOT -explain tick-22760 itself — that mover is grounded (dispatches through -Path 5, not Path 6) and the actual "no normal recorded" mechanism -(`SpherePath.PrecipiceSlide`'s `find_crossed_edge`-false fallback) is -independently confirmed byte-exact retail behavior too. The remaining -divergence is most likely this test's simplified door-registration -fixture, not the response layer — see ISSUES.md #116 and the oracle -plan's Addendum 2 for the full trace and the concrete next step (re-run -against the faithful Setup-based door registration). AD-55 (the sled -slope-flatness constant, split out of AP-7's retirement) is ALSO retired -this same slice — byte-proven `cos(10°)` per the oracle plan's Addendum. +**Status (2026-07-31, FINAL):** TS-1 and AP-7 retired as originally +recorded. TS-4's first 2026-07-30 removal was accepted by an incomplete +resolver-only fixture, failed the live matrix with a roof wedge/uphill-bounce +regression, and was reverted. The 2026-07-31 closure began from a fresh +`BSPTREE::find_collisions` read and ports the exact asymmetric Path-6 split: +primary/foot hits use `SetCollide` + `LandingZ` + `Adjusted`, while +secondary/head hits use `CollisionNormal` + `Collided`; neither writes a +sliding normal. Both graph and prepared-flat implementations match that +oracle. + +The corrective acceptance no longer calls the old horizontal-input fixture +"production-shaped." `Ts4ProductionQuantumConformanceTests` executes the +already-airborne, zero-root-motion 30 Hz Core collision tail — acceleration, +body integration, transition resolve, exact body/cell commit, then +`handle_all_collisions` — and retains its behavior-bearing cell, contact, +sliding, stationary-fall, and velocity state for 90 ticks. Graph and flat +match by raw bits for vertical/inward/tangential/downhill cases and a genuine +positive-Z uphill jump; exact terminal state, non-penetration, no fixed point, +and no second launch are pinned. No further product-code correction was +needed after that test became faithful, and there is no active AD-56 row. The +older resolver-only wedge test remains only as a historical three-second +signature control. #116 shape-2 remains closed; shape-1 remains narrowed as +recorded in its issue history. AD-55 remains retired by the raw-byte +`cos(10°)` proof. The collision *response* layer (what happens after a hit): ground friction, cliff edges, downhill landings, near-perpendicular wall @@ -145,10 +139,11 @@ binds every subagent here. stop-at-edge. 3. **#166:** port the landing "sled" (Sledding state set/clear sites; the sled friction constants already sit in `calc_friction`). -4. **TS-4:** replace the Path-6 steep-poly in-place-slide shortcut with - retail's `SetCollide → Path-4 → ContactPlane` landing chain, and - remove the two BSP-layer `SetSlidingNormal` writes (retail's only - in-transition writer is `validate_transition`). +4. **TS-4:** remove the Path-6 steep-poly shortcuts and port retail's exact + sphere split: primary/foot uses `SetCollide` + `LandingZ` + `Adjusted`; + secondary/head uses `CollisionNormal` + `Collided`. Remove every BSP-layer + `SetSlidingNormal` write (retail's only in-transition writer is + `validate_transition`). 5. **#116:** the near-perpendicular lateral-slide loss + first-airborne- frame divergence, driven by the existing tick-22760 replay and D4 pins. diff --git a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md index cbd10b33..631d744a 100644 --- a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md +++ b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md @@ -599,17 +599,16 @@ manufacture a caller that sets it. ### The former shortcut (HISTORICAL FACT; removed by Slice 2B) Before Slice 2B, Path-6 (the default -`sphere_intersects_poly → collide_with_pt / SetCollide` dispatch) tested each -hit polygon's world-space normal. For BOTH sphere0 (feet) and sphere1 (head), -if `worldNormal.Z < PhysicsGlobals.FloorZ` (steeper than ~49° from -horizontal), acdream took a SPECIAL BRANCH: -projects the move along the steep face, writes -`collisions.SetCollisionNormal(worldNormal)` **and** -`collisions.SetSlidingNormal(worldNormal)`, and returns -`TransitionState.Slid` immediately — bypassing `SetCollide` entirely for -steep hits. Only the shallow case (`worldNormal.Z >= FloorZ`) reaches -`path.SetCollide(worldNormal); path.WalkableAllowance = LandingZ; return -Adjusted;`. +`sphere_intersects_poly → collide_with_pt / SetCollide` dispatch) was not +symmetrically wrong. The parsed-graph primary/foot branch tested the hit +polygon's world-space normal and, below `PhysicsGlobals.FloorZ`, projected +the move along the face, wrote both collision and sliding normals, and +returned `Slid`. Its secondary/head branch had already been corrected by +#116 to retail's unconditional `CollisionNormal` + `Collided` response. The +prepared-flat port still applied the steep shortcut to both spheres and sent +both shallow cases through `SetCollide` + `Adjusted`; its head branch was +therefore wrong for every slope. This distinction matters: retail does not +apply one common response to both spheres. The in-code comment is unusually candid about why: **"This is a deliberate deviation from retail... Validated against retail debugger @@ -621,10 +620,11 @@ was shipped SAME-DAY as (and BECAUSE) the retail-faithful §2 above — also dated 2026-04-30, tagged "L.4") still wedged in testing when tried without this shortcut. -### Retail: NO steepness branch at the BSP layer (FACT, pseudo-C:323740-323783, `0053a730` region) +### Retail: NO steepness branch in either Path-6 sphere response (FACT, `0053a730` region) Read directly from the named decomp (the `sphere_intersects_poly` / -`set_collide` dispatch inside `BSPTREE::find_collisions`'s default path): +`set_collide` dispatch for the primary/foot sphere inside +`BSPTREE::find_collisions`'s default path): ``` if (sphere_intersects_poly(...) || eax_26 != 0) { @@ -635,11 +635,21 @@ if (sphere_intersects_poly(...) || eax_26 != 0) { } ``` -**There is no steepness test here at all.** Retail's BSP layer calls -`set_collide` and returns `ADJUSTED_TS` **unconditionally**, for a steep -roof exactly the same as a shallow ramp. `walkable_allowance` is always -set to `LandingZ` (the permissive landing threshold) at this layer, -regardless of the actual polygon slope. This directly confirms the P2 +**There is no steepness test in this foot branch.** It calls `set_collide` +and returns `ADJUSTED_TS` for a steep roof exactly as for a shallow ramp. +`walkable_allowance` is always set to `LandingZ` (the permissive landing +threshold), regardless of polygon slope. If the foot is clear but the +secondary/head sphere hits, retail instead performs the other slope-agnostic +response: + +``` +localtoglobalvec(sphere_path.localspace_pos, &normal, &head_poly->plane.N); +COLLISIONINFO::set_collision_normal(&collision_info, &normal); +return 2; // COLLIDED_TS +``` + +The head branch neither calls `set_collide` nor returns `ADJUSTED_TS`. +Neither sphere response has a steepness branch. This directly confirms the P2 plan's description and the digest's #137-mechanism-2 finding (`memory/project_physics_collision_digest.md:1008-1014`): **retail's BSP/sphere collision layer never writes `collision_info.sliding_normal` @@ -653,16 +663,13 @@ domain, §2 above). ### TS-4 port shape (COMPLETED 2026-07-31) -Delete both `if (worldNormal{0,1}.Z < PhysicsGlobals.FloorZ) { ... return -TransitionState.Slid; }` blocks (`BSPQuery.cs:2200-2215` and -`:2240-2255`) entirely. Both sphere0 and sphere1 hits should fall straight -through to the existing `path.SetCollide(worldNormal); path.WalkableAllowance -= PhysicsGlobals.LandingZ; return TransitionState.Adjusted;` — i.e., make -Path-6 do EXACTLY what its own shallow branch already does, for every -hit, matching retail's unconditional `set_collide`. This mechanically -retires both `SetSlidingNormal` write sites (satisfying DO-NOT-RETRY §0 -item 1 permanently — deleted, not just avoided) with no replacement logic -needed at this layer. +Delete the primary/foot steep shortcut in both representations. Every foot +hit must call `SetCollide`, set `WalkableAllowance=LandingZ`, and return +`Adjusted`. Preserve the parsed-graph head branch corrected by #116, and +replace the prepared-flat head steep/shallow split with the same unconditional +`SetCollisionNormal` + `Collided` response. No Path-6 branch writes a sliding +normal. This is the exact retail foot/head split, not a shared fallback to the +foot behavior. ### Port-order coupling with TS-1 (historical guard, now satisfied) @@ -1202,17 +1209,35 @@ representations by raw bits and pin every mutated and preserved field: - both: a pre-existing sliding normal is preserved byte-for-byte. The failed first removal was a test-harness lesson, not a retail exception. -Its gravity-only replay always passed `isOnGround:false` and discarded each -accepted result's Contact/OnWalkable bits, making the nested edge/StepDown -chain impossible to exercise on the next frame. The replacement replay uses -the same retained `PhysicsBody` chronology as production. Parsed graph and -prepared flat now match by raw result/body bits for vertical roof descent, -downhill input, uphill pressure (including a no-launch/no-bounce assertion), -tangential roof travel, inward-plus-tangent wall travel, and flat-roof ledge -rejection. Every trace asserts finite bounded motion and signed-plane non- -penetration. The former #116 D4 control is active: its primary-sphere hit -hard-stops frame one through SetCollide, then the accepted persistent normal -permits the downward slide on frame two. +Its gravity-only replay always passed `isOnGround:false`, manually zeroed +vertical velocity, and only copied part of the accepted contact state, so it +could not reproduce the live caller's next-frame chronology. The replacement +`Ts4ProductionQuantumConformanceTests` replay executes each already-airborne, +zero-root-motion 30 Hz Core collision quantum in the production order: +`calc_acceleration`, `UpdatePhysicsInternal`, +`ResolveWithTransition`, exact body/cell commit, then +`PhysicsObjUpdate.CommitSetPositionTransition` (the Core owner of retail's +Contact/OnWalkable replacement plus `handle_all_collisions`). It carries the +same body, cell, contact plane, walkable plane, sliding state, stationary-fall +counter, cached velocity, and transient flags for all 90 ticks. PositionManager +root composition, animation hooks, movement callbacks, and fresh-airborne +`LeaveGround`/`HitGround` edges are deliberately outside this already-airborne +dat-free fixture; it does not claim to replay those presentation/motion stages. + +Parsed graph and prepared flat match by raw result/body bits for vertical, +inward, tangential, and downhill roof motion plus a genuine positive-Z uphill +jump. The foot-contact jump's apex precedes roof contact and every later +candidate velocity remains non-positive in Z. A separate elevated head-only +collision reaches the wall while Z velocity is still positive and pins the +valid-normal, inward-dot retail 5%-elastic reflection without adding vertical +velocity. Every direction rejects a half-second fixed point and signed-plane +penetration, while exact terminal velocity, Contact/OnWalkable/Sliding bits, +sliding normal, and contact plane are fixed by raw float bits. The older +resolver-only wedge capture remains a deliberately weaker historical control +and is restored to its original three-second bound. The former #116 D4 +control remains active: its primary-sphere hit hard-stops frame one through +SetCollide, then the accepted persistent normal permits the downward slide on +frame two. The complete historical #273/#271/#269/#265/#185/#137/#116/cellar/roof matrix and the full Core Release suite pass without a replacement diff --git a/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs new file mode 100644 index 00000000..783d24a2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs @@ -0,0 +1,695 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Numerics; +using System.Text; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Production-quantum acceptance for retail Path 6. Unlike the historical +/// bare-resolver fixture, every frame executes the already-airborne, +/// zero-root-motion Core collision tail used by PlayerMovementController: +/// acceleration, PhysicsBody integration, sweep, complete position/contact +/// commit, and handle_all_collisions. +/// +public sealed class Ts4ProductionQuantumConformanceTests +{ + private const uint Cell = 0xA9B40001u; + private const uint GfxId = 0x0100E1B0u; + private const float Dt = 1f / 30f; + private const float Radius = 0.48f; + private const int TickCount = 90; + private const ushort RoofPolygonId = 1; + + private static readonly ImmutableArray HumanSpheres = + ImmutableArray.Create( + new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), Radius), + new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), Radius)); + + private readonly ITestOutputHelper _output; + + public Ts4ProductionQuantumConformanceTests(ITestOutputHelper output) => + _output = output; + + [Theory] + [InlineData("vertical", 0f, 0f, 0f, 0xC18415BEu, 0x00000000u, 0xC201B112u, 0x00000000u, 0x00000000u, 0xC1EB3325u)] + [InlineData("inward", 0.5366564f, 0f, -0.2683282f, 0xC18464EAu, 0x00000000u, 0xC202003Eu, 0x3F096250u, 0x00000000u, 0xC1ED58AEu)] + [InlineData("tangent", 0f, 0.30f, 0f, 0xC18415BEu, 0x3F63F3DBu, 0xC201B112u, 0x00000000u, 0x3E99999Au, 0xC1EB3325u)] + [InlineData("downhill", -0.2683282f, 0f, -0.5366564f, 0xC18A74DBu, 0x00000000u, 0xC208102Fu, 0xBE896250u, 0x00000000u, 0xC1EF7E37u)] + public void RoofDirections_ProductionQuantum_GraphFlatExactAndNeverWedge( + string direction, + float velocityX, + float velocityY, + float velocityZ, + uint terminalPositionXBits, + uint terminalPositionYBits, + uint terminalPositionZBits, + uint terminalVelocityXBits, + uint terminalVelocityYBits, + uint terminalVelocityZBits) + { + var initialPosition = new Vector3(0.5f, 0f, 3f); + var initialVelocity = new Vector3(velocityX, velocityY, velocityZ); + QuantumTrace graph = RunTrace( + preparedFlat: false, + initialPosition, + initialVelocity, + TickCount); + QuantumTrace flat = RunTrace( + preparedFlat: true, + initialPosition, + initialVelocity, + TickCount); + + AssertTraceExact(graph, flat); + AssertNoSteepSurfaceFixedPoint(graph, direction); + AssertNoSlopePenetration(graph, direction); + Assert.True(graph.FirstContactTick >= 0, + $"{direction} never contacted the authored steep roof."); + AssertCollisionResponseExact(graph.Frames[graph.FirstContactTick]); + AssertTerminalStateExact( + graph, + terminalPositionXBits, + terminalPositionYBits, + terminalPositionZBits, + terminalVelocityXBits, + terminalVelocityYBits, + terminalVelocityZBits); + + _output.WriteLine( + $"{direction}: peak={graph.PeakZ:R}, contactTick={graph.FirstContactTick}, " + + $"terminalPos={graph.Frames[^1].Position}, " + + $"terminalVelocity={graph.Frames[^1].Velocity}, " + + $"terminalFlags={graph.Frames[^1].TransientState}, " + + $"terminalSliding={graph.Frames[^1].SlidingNormal}"); + } + + [Fact] + public void UphillPositiveZJump_ProductionQuantum_GraphFlatExactWithoutLaunch() + { + // Positive-Z jump whose +X component points into/up the authored roof. + // Its authored separation keeps the real apex before Path-6 contact, + // then exercises SetCollide -> SetPositionInternal -> + // handle_all_collisions without manufacturing a second launch. + Vector3 initialPosition = new(-0.20f, 0f, 0.422f); + Vector3 initialVelocity = new(0.4472136f, 0f, 0.8944272f); + QuantumTrace graph = RunTrace( + preparedFlat: false, + initialPosition, + initialVelocity, + TickCount); + QuantumTrace flat = RunTrace( + preparedFlat: true, + initialPosition, + initialVelocity, + TickCount); + + AssertTraceExact(graph, flat); + Assert.True(graph.FirstContactTick >= 0, + $"The uphill jump never hit the roof; peak={graph.PeakZ:R}, " + + $"terminal={graph.Frames[^1].Position}, " + + $"collisionTick={graph.Frames.FindIndex(frame => frame.CollisionNormalValid)}."); + AssertNoSteepSurfaceFixedPoint(graph, "uphill-jump"); + AssertNoSlopePenetration(graph, "uphill-jump"); + + QuantumFrame hit = graph.Frames[graph.FirstContactTick]; + int peakFrame = graph.Frames.FindIndex(frame => frame.Position.Z == graph.PeakZ); + Assert.InRange(peakFrame, 1, graph.FirstContactTick - 1); + Assert.True(hit.CandidateVelocity.Z < 0f, + $"The jump must contact after its real apex: {hit.CandidateVelocity}."); + Assert.All( + graph.Frames.GetRange( + graph.FirstContactTick, + graph.Frames.Count - graph.FirstContactTick), + frame => Assert.True(frame.CandidateVelocity.Z <= 0f, + $"The foot-contact path created an upward relaunch at frame " + + $"{frame.Tick}: {frame.CandidateVelocity}.")); + float postHitPeak = graph.Frames + .GetRange(graph.FirstContactTick, graph.Frames.Count - graph.FirstContactTick) + .Max(frame => frame.Position.Z); + Assert.True(postHitPeak <= graph.PeakZ + 0.001f, + $"The collision created a second launch: firstPeak={graph.PeakZ:R}, " + + $"postHitPeak={postHitPeak:R}."); + AssertCollisionResponseExact(hit); + Assert.Equal(0x3EECC54Bu, BitConverter.SingleToUInt32Bits(graph.PeakZ)); + Assert.Equal(6, graph.FirstContactTick); + AssertTerminalStateExact( + graph, + 0xC18334B2u, + 0x00000000u, + 0xC200D006u, + 0x3EE4F92Eu, + 0x00000000u, + 0xC1E40B5Du); + + _output.WriteLine( + $"uphill-jump: peak={graph.PeakZ:R}, contactTick={graph.FirstContactTick}, " + + $"hitCandidateVelocity={hit.CandidateVelocity}, " + + $"hitPreResponseVelocity={hit.PreResponseVelocity}, " + + $"hitVelocity={hit.Velocity}, " + + $"terminalPos={graph.Frames[^1].Position}, " + + $"terminalVelocity={graph.Frames[^1].Velocity}, " + + $"terminalFlags={graph.Frames[^1].TransientState}, " + + $"terminalSliding={graph.Frames[^1].SlidingNormal}"); + } + + [Fact] + public void PositiveZJump_HeadOnlyCollision_UsesExactRetailElasticReflection() + { + var fixture = ElevatedHeadWall(); + Vector3 initialPosition = new(-0.60f, 0f, 0f); + Vector3 initialVelocity = new(2f, 0f, 2f); + QuantumTrace graph = RunTrace( + preparedFlat: false, + initialPosition, + initialVelocity, + ticks: 12, + fixture); + QuantumTrace flat = RunTrace( + preparedFlat: true, + initialPosition, + initialVelocity, + ticks: 12, + fixture); + + AssertTraceExact(graph, flat); + int hitIndex = graph.Frames.FindIndex(frame => frame.CollisionNormalValid); + Assert.InRange(hitIndex, 0, graph.Frames.Count - 1); + QuantumFrame hit = graph.Frames[hitIndex]; + Assert.True(hit.CandidateVelocity.Z > 0f, + $"The head-only collision did not occur during positive-Z travel: {hit.CandidateVelocity}."); + Assert.True(Vector3.Dot(hit.PreResponseVelocity, hit.CollisionNormal) < 0f, + $"The fixture did not exercise the inward reflection branch: " + + $"v={hit.PreResponseVelocity}, n={hit.CollisionNormal}."); + AssertCollisionResponseExact(hit); + AssertFloatBits(hit.PreResponseVelocity.Z, hit.Velocity.Z); + Assert.True(hit.Velocity.X < 0f, + $"The 5%-elastic retail reflection did not reverse the inward component: {hit.Velocity}."); + + for (int i = hitIndex + 1; i < graph.Frames.Count; i++) + { + Assert.True(graph.Frames[i].CandidateVelocity.Z + < graph.Frames[i - 1].CandidateVelocity.Z, + $"The head collision manufactured a later vertical launch at frame {i}."); + } + } + + private static QuantumTrace RunTrace( + bool preparedFlat, + Vector3 initialPosition, + Vector3 initialVelocity, + int ticks) => + RunTrace(preparedFlat, initialPosition, initialVelocity, ticks, WideSteepRoof()); + + private static QuantumTrace RunTrace( + bool preparedFlat, + Vector3 initialPosition, + Vector3 initialVelocity, + int ticks, + (PhysicsBSPNode Root, Dictionary Resolved) fixture) + { + PhysicsEngine engine = BuildEngine(fixture, preparedFlat); + var body = new PhysicsBody + { + Position = initialPosition, + Orientation = Quaternion.Identity, + Velocity = initialVelocity, + State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(Cell, initialPosition, initialPosition); + + var frames = new List(ticks); + float peakZ = body.Position.Z; + int firstContactTick = -1; + uint cell = Cell; + for (int tick = 0; tick < ticks; tick++) + { + Vector3 preIntegratePosition = body.Position; + + body.calc_acceleration(); + body.UpdatePhysicsInternal(Dt); + + Vector3 candidatePosition = body.Position; + Vector3 candidateVelocity = body.Velocity; + bool candidateMoved = candidatePosition != preIntegratePosition; + bool onGroundBeforeResolve = body.OnWalkable; + ResolveResult result = engine.ResolveWithTransition( + preIntegratePosition, + candidatePosition, + cell, + Radius, + sphereHeight: 1.835f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: onGroundBeforeResolve, + body, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000000u, + sphereList: HumanSpheres, + sphereScale: 1f); + + Vector3 preResponseVelocity = body.Velocity; + int preResponseStationaryFall = body.FramesStationaryFall; + + // Production captures these after ResolveWithTransition has + // published plane/sliding/fsf state but before SetPositionInternal + // replaces Contact/OnWalkable. Resolve never mutates these two bits. + bool previousContact = body.InContact; + bool previousOnWalkable = body.OnWalkable; + + body.CachedVelocity = candidateMoved + ? (result.Position - preIntegratePosition) / Dt + : Vector3.Zero; + body.CommitTransitionPosition(result.CellId, result.Position); + cell = result.CellId; + + bool commitApplied = result.Ok && candidateMoved; + if (commitApplied) + { + PhysicsObjUpdate.CommitSetPositionTransition( + body, + result.InContact, + result.OnWalkable, + result.CollisionNormalValid, + result.CollisionNormal, + previousContact, + previousOnWalkable); + } + + peakZ = MathF.Max(peakZ, body.Position.Z); + if (firstContactTick < 0 && result.InContact) + firstContactTick = tick; + frames.Add(CaptureFrame( + tick, + candidatePosition, + candidateVelocity, + preResponseVelocity, + preResponseStationaryFall, + previousOnWalkable, + candidateMoved, + commitApplied, + result, + body)); + } + + return new QuantumTrace(frames, peakZ, firstContactTick); + } + + private static ( + PhysicsBSPNode Root, + Dictionary Resolved) WideSteepRoof() + { + Vector3[] vertices = + [ + new(-64f, -64f, -128f), + new( 64f, -64f, 128f), + new( 64f, 64f, 128f), + new(-64f, 64f, -128f), + ]; + Vector3 normal = Vector3.Normalize( + Vector3.Cross(vertices[1] - vertices[0], vertices[3] - vertices[0])); + if (normal.X > 0f) + normal = -normal; + float d = -Vector3.Dot(normal, vertices[0]); + + var root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 192f }, + }; + root.Polygons.Add(RoofPolygonId); + return (root, new Dictionary + { + [RoofPolygonId] = new ResolvedPolygon + { + Id = RoofPolygonId, + Vertices = vertices, + Plane = new Plane(normal, d), + NumPoints = vertices.Length, + SidesType = CullMode.None, + }, + }); + } + + private static ( + PhysicsBSPNode Root, + Dictionary Resolved) ElevatedHeadWall() + { + Vector3[] vertices = + [ + new(0f, 64f, 1.10f), + new(0f, -64f, 1.10f), + new(0f, -64f, 64.00f), + new(0f, 64f, 64.00f), + ]; + var plane = new Plane(-Vector3.UnitX, 0f); + var root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = new Vector3(0f, 0f, 32f), + Radius = 96f, + }, + }; + root.Polygons.Add(RoofPolygonId); + return (root, new Dictionary + { + [RoofPolygonId] = new ResolvedPolygon + { + Id = RoofPolygonId, + Vertices = vertices, + Plane = plane, + NumPoints = vertices.Length, + SidesType = CullMode.None, + }, + }); + } + + private static PhysicsEngine BuildEngine( + (PhysicsBSPNode Root, Dictionary Resolved) fixture, + bool preparedFlat) + { + var normalized = new Dictionary(fixture.Resolved.Count); + foreach ((ushort id, ResolvedPolygon polygon) in fixture.Resolved) + { + normalized.Add(id, new ResolvedPolygon + { + Id = id, + Vertices = polygon.Vertices, + Plane = polygon.Plane, + NumPoints = polygon.NumPoints, + SidesType = polygon.SidesType, + }); + } + + var physics = new GfxObjPhysics + { + SourceId = GfxId, + BSP = new PhysicsBSPTree { Root = fixture.Root }, + Resolved = normalized, + BoundingSphere = fixture.Root.BoundingSphere, + }; + var cache = new PhysicsDataCache(); + if (preparedFlat) + { + cache.CollisionTraversalMode = CollisionTraversalMode.Flat; + cache.CacheGfxObj(GfxId, FlatCollisionAssetBuilder.FlattenGfxObj(physics)); + } + else + { + cache.RegisterGfxObjForTest(GfxId, physics); + } + + var heights = new byte[81]; + var heightTable = new float[256]; + Array.Fill(heightTable, -1000f); + var engine = new PhysicsEngine { DataCache = cache }; + engine.AddLandblock( + 0xA9B40000u, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + engine.ShadowObjects.Register( + GfxId, + GfxId, + Vector3.Zero, + Quaternion.Identity, + fixture.Root.BoundingSphere.Radius, + 0f, + 0f, + 0xA9B4FFFFu, + ShadowCollisionType.BSP, + 1f); + return engine; + } + + private static void AssertTraceExact(QuantumTrace graph, QuantumTrace flat) + { + Assert.Equal(graph.PeakZ, flat.PeakZ); + Assert.Equal(graph.FirstContactTick, flat.FirstContactTick); + Assert.Equal(graph.Frames.Count, flat.Frames.Count); + for (int i = 0; i < graph.Frames.Count; i++) + Assert.Equal(graph.Frames[i].Bits, flat.Frames[i].Bits); + } + + private static void AssertNoSteepSurfaceFixedPoint( + QuantumTrace trace, + string direction) + { + int frozenStreak = 0; + for (int i = 1; i < trace.Frames.Count; i++) + { + QuantumFrame previous = trace.Frames[i - 1]; + QuantumFrame current = trace.Frames[i]; + bool onSlope = IsOverSlope(current.Position) + && current.ContactPlaneValid + && current.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ; + frozenStreak = onSlope + && Vector3.Distance(previous.Position, current.Position) < 0.001f + ? frozenStreak + 1 + : 0; + Assert.True(frozenStreak <= 15, + $"{direction} fixed on the steep roof for {frozenStreak} ticks at " + + $"frame {i}, position={current.Position}."); + } + } + + private static void AssertNoSlopePenetration(QuantumTrace trace, string direction) + { + Plane slope = WideSteepRoof().Resolved[RoofPolygonId].Plane; + for (int i = 0; i < trace.Frames.Count; i++) + { + QuantumFrame frame = trace.Frames[i]; + Assert.True(float.IsFinite(frame.Position.X) + && float.IsFinite(frame.Position.Y) + && float.IsFinite(frame.Position.Z), + $"{direction} produced a non-finite position at frame {i}: {frame.Position}."); + if (!IsOverSlope(frame.Position)) + continue; + + Vector3 footCenter = frame.Position + HumanSpheres[0].Origin; + float signedDistance = Vector3.Dot(slope.Normal, footCenter) + slope.D; + Assert.True(signedDistance >= Radius - 0.015f, + $"{direction} penetrated the roof at frame {i}: " + + $"distance={signedDistance:R}, position={frame.Position}."); + } + } + + private static bool IsOverSlope(Vector3 position) => + position.X is >= -64f and <= 64f && MathF.Abs(position.Y) <= 64f; + + private static void AssertCollisionResponseExact(QuantumFrame hit) + { + Assert.True(hit.ResultOk, + $"Frame {hit.Tick} reported contact without an accepted transition."); + Assert.True(hit.CandidateMoved, + $"Frame {hit.Tick} reported contact without a moving candidate."); + Assert.True(hit.CommitApplied, + $"Frame {hit.Tick} did not execute the production commit/response gate."); + + Vector3 expected = hit.PreResponseVelocity; + bool shouldReflect = !hit.PreviousOnWalkable || !hit.BodyOnWalkable; + if (hit.PreResponseStationaryFall > 1) + { + expected = Vector3.Zero; + } + else if (shouldReflect && hit.CollisionNormalValid) + { + float dot = Vector3.Dot(expected, hit.CollisionNormal); + if (dot < 0f) + expected += hit.CollisionNormal * (-(dot * 1.05f)); + } + + AssertVectorBits(expected, hit.Velocity); + } + + private static void AssertTerminalStateExact( + QuantumTrace trace, + uint positionXBits, + uint positionYBits, + uint positionZBits, + uint velocityXBits, + uint velocityYBits, + uint velocityZBits) + { + QuantumFrame terminal = trace.Frames[^1]; + Assert.Equal(positionXBits, BitConverter.SingleToUInt32Bits(terminal.Position.X)); + Assert.Equal(positionYBits, BitConverter.SingleToUInt32Bits(terminal.Position.Y)); + Assert.Equal(positionZBits, BitConverter.SingleToUInt32Bits(terminal.Position.Z)); + Assert.Equal(velocityXBits, BitConverter.SingleToUInt32Bits(terminal.Velocity.X)); + Assert.Equal(velocityYBits, BitConverter.SingleToUInt32Bits(terminal.Velocity.Y)); + Assert.Equal(velocityZBits, BitConverter.SingleToUInt32Bits(terminal.Velocity.Z)); + Assert.Equal(TransientStateFlags.Active | TransientStateFlags.Contact, + terminal.TransientState); + Assert.True(terminal.BodyInContact); + Assert.False(terminal.BodyOnWalkable); + Assert.False(terminal.BodySliding); + Assert.Equal(Vector3.Zero, terminal.SlidingNormal); + Assert.False(terminal.CollisionNormalValid); + Assert.True(terminal.ContactPlaneValid); + + Assert.Equal(0xBF64F92Fu, + BitConverter.SingleToUInt32Bits(terminal.ContactPlane.Normal.X)); + Assert.Equal(0x00000000u, + BitConverter.SingleToUInt32Bits(terminal.ContactPlane.Normal.Y)); + Assert.Equal(0x3EE4F92Fu, + BitConverter.SingleToUInt32Bits(terminal.ContactPlane.Normal.Z)); + Assert.Equal(0x80000000u, + BitConverter.SingleToUInt32Bits(terminal.ContactPlane.D)); + } + + private static QuantumFrame CaptureFrame( + int tick, + Vector3 candidatePosition, + Vector3 candidateVelocity, + Vector3 preResponseVelocity, + int preResponseStationaryFall, + bool previousOnWalkable, + bool candidateMoved, + bool commitApplied, + ResolveResult result, + PhysicsBody body) + { + var bits = new StringBuilder(768); + Append(bits, tick); + Append(bits, candidatePosition); + Append(bits, candidateVelocity); + Append(bits, preResponseVelocity); + Append(bits, preResponseStationaryFall); + Append(bits, candidateMoved); + Append(bits, commitApplied); + Append(bits, result.Position); + Append(bits, result.CellId); + Append(bits, result.IsOnGround); + Append(bits, result.CollisionNormalValid); + Append(bits, result.CollisionNormal); + Append(bits, result.Ok); + Append(bits, result.Orientation); + Append(bits, result.InContact); + Append(bits, result.OnWalkable); + Append(bits, body.Position); + Append(bits, body.CellPosition.ObjCellId); + Append(bits, body.CellPosition.Frame.Origin); + Append(bits, body.CellPosition.Frame.Orientation); + Append(bits, body.Velocity); + Append(bits, body.CachedVelocity); + Append(bits, body.Acceleration); + Append(bits, body.GroundNormal); + Append(bits, body.SlidingNormal); + Append(bits, body.ContactPlaneValid); + Append(bits, body.ContactPlane); + Append(bits, body.ContactPlaneCellId); + Append(bits, body.ContactPlaneIsWater); + Append(bits, body.WalkablePolygonValid); + Append(bits, body.WalkablePlane); + Append(bits, body.WalkableUp); + Append(bits, body.FramesStationaryFall); + Append(bits, (uint)body.State); + Append(bits, (uint)body.TransientState); + + return new QuantumFrame( + tick, + candidatePosition, + candidateVelocity, + preResponseVelocity, + preResponseStationaryFall, + candidateMoved, + result.Ok, + commitApplied, + body.Position, + body.Velocity, + body.TransientState, + body.InContact, + body.OnWalkable, + (body.TransientState & TransientStateFlags.Sliding) != 0, + body.SlidingNormal, + result.CollisionNormalValid, + result.CollisionNormal, + previousOnWalkable, + body.ContactPlaneValid, + body.ContactPlane, + bits.ToString()); + } + + private static void Append(StringBuilder target, bool value) => + target.Append(value ? "1|" : "0|"); + + private static void Append(StringBuilder target, int value) => + target.Append(value).Append('|'); + + private static void Append(StringBuilder target, uint value) => + target.Append(value.ToString("X8")).Append('|'); + + private static void Append(StringBuilder target, float value) => + Append(target, BitConverter.SingleToUInt32Bits(value)); + + private static void Append(StringBuilder target, Vector3 value) + { + Append(target, value.X); + Append(target, value.Y); + Append(target, value.Z); + } + + private static void Append(StringBuilder target, Quaternion value) + { + Append(target, value.X); + Append(target, value.Y); + Append(target, value.Z); + Append(target, value.W); + } + + private static void Append(StringBuilder target, Plane value) + { + Append(target, value.Normal); + Append(target, value.D); + } + + private static void AssertFloatBits(float expected, float actual) => + Assert.Equal( + BitConverter.SingleToUInt32Bits(expected), + BitConverter.SingleToUInt32Bits(actual)); + + private static void AssertVectorBits(Vector3 expected, Vector3 actual) + { + AssertFloatBits(expected.X, actual.X); + AssertFloatBits(expected.Y, actual.Y); + AssertFloatBits(expected.Z, actual.Z); + } + + private sealed record QuantumTrace( + List Frames, + float PeakZ, + int FirstContactTick); + + private sealed record QuantumFrame( + int Tick, + Vector3 CandidatePosition, + Vector3 CandidateVelocity, + Vector3 PreResponseVelocity, + int PreResponseStationaryFall, + bool CandidateMoved, + bool ResultOk, + bool CommitApplied, + Vector3 Position, + Vector3 Velocity, + TransientStateFlags TransientState, + bool BodyInContact, + bool BodyOnWalkable, + bool BodySliding, + Vector3 SlidingNormal, + bool CollisionNormalValid, + Vector3 CollisionNormal, + bool PreviousOnWalkable, + bool ContactPlaneValid, + Plane ContactPlane, + string Bits); +} diff --git a/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs index 3dcf6c22..3d5959a0 100644 --- a/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs @@ -6,13 +6,10 @@ using Xunit.Abstractions; namespace AcDream.Core.Tests.Physics; /// -/// Campaign P Slice 2B's production-shaped steep-roof control. It carries -/// contact state between 30 Hz resolves exactly as the live PhysicsBody path -/// does, so the nested edge/StepDown dispatcher can turn a vertical landing -/// into retail's downhill response instead of the old under-modeled fixed -/// point. The paired graph/flat direction matrix in -/// covers vertical, inward, -/// tangential, uphill, downhill, wall, roof, and ledge histories. +/// Historical resolver-only steep-roof control retained for the original +/// half-second wedge signature. The authoritative production chronology and +/// graph/flat direction matrix live in +/// . /// public class Ts4SteepRoofWedgeCaptureTests { @@ -21,7 +18,7 @@ public class Ts4SteepRoofWedgeCaptureTests private const uint CellId = 0xA9B40001u; private const int TicksPerSecond = 30; // #32 L.5 retail physics tick rate - private const int MaxTicks = 6 * TicksPerSecond; + private const int MaxTicks = 3 * TicksPerSecond; private const int WedgeTickThreshold = 15; // 0.5 s of zero motion == wedged private const float WedgeEpsilon = 0.001f; // 1 mm @@ -74,12 +71,12 @@ public class Ts4SteepRoofWedgeCaptureTests /// /// Falls a player-flagged mover from directly above the slope and carries - /// each frame's contact state into the next frame, matching the production - /// PhysicsBody path. The exact edge/step-down chain must move the body - /// downhill and onto the reference floor without a half-second wedge. + /// resolver contact bits between frames. Within the original three-second + /// capture window it must keep making downhill progress and never enter + /// the reported half-second fixed point. /// [Fact] - public void FallOntoSteepSlope_PureVertical_NeverWedgesAndReachesFloor() + public void FallOntoSteepSlope_PureVertical_NeverWedgesWithinThreeSeconds() { var engine = MakeSlopeEngine(); float r = BSPStepUpFixtures.SphereRadius; @@ -97,8 +94,8 @@ public class Ts4SteepRoofWedgeCaptureTests float fallVelocityZ = 0f; uint cell = CellId; + Vector3 start = pos; int frozenStreak = 0; - bool reachedFloor = false; for (int tick = 0; tick < MaxTicks; tick++) { @@ -148,15 +145,10 @@ public class Ts4SteepRoofWedgeCaptureTests Assert.True(frozenStreak <= WedgeTickThreshold, $"Body froze for {frozenStreak} ticks at {pos}."); - if (pos.X < 0f && pos.Z <= r + 0.05f) - { - reachedFloor = true; - break; - } } - Assert.True(reachedFloor, - $"The production-shaped vertical trace did not reach the floor within " + - $"{MaxTicks} ticks; final=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})."); + Assert.True(pos.X < start.X - 0.10f, + $"The resolver-only trace made no downhill progress within " + + $"{MaxTicks} ticks; start={start}, final={pos}."); } } From 7716c2ee89c533d4f97593969246586451a26da3 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 14:22:45 +0200 Subject: [PATCH 20/73] fix(physics): restore retail cell availability semantics --- .../retail-divergence-register.md | 12 +- docs/architecture/worldbuilder-inventory.md | 9 + .../2026-07-31-cell-availability-semantics.md | 62 +++++++ src/AcDream.Core/Physics/CellTransit.cs | 22 +-- .../Physics/CollisionTraversal.cs | 20 ++- src/AcDream.Core/Physics/PhysicsDataCache.cs | 33 ++-- src/AcDream.Core/World/Cells/CellGraph.cs | 4 +- src/AcDream.Core/World/Cells/EnvCell.cs | 8 +- .../Physics/BuildShadowCellSetTests.cs | 154 ++++++++++++++++++ .../Physics/CellGraphPopulationTests.cs | 14 +- .../CellTransitCheckBuildingTransitTests.cs | 47 ++++-- .../Physics/CellTransitFindCellSetTests.cs | 7 +- .../PhysicsDataCacheProductionTests.cs | 46 ++++++ .../World/Cells/EnvCellTests.cs | 17 ++ 14 files changed, 393 insertions(+), 62 deletions(-) create mode 100644 docs/research/2026-07-31-cell-availability-semantics.md diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 94269d5f..169bb2d1 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,15 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 46 rows (AD-25 retired 2026-07-30 at Campaign P Slice P3 — the remote dead-reckoning post-resolve now calls the exact ported `PhysicsObjUpdate.HandleAllCollisions` (the same function the local player and every ordinary body already use) instead of its own hand-inlined, narrower reflect gate; the row's own premise ("the remote DR sweep hasn't been rebuilt yet") no longer holds; AD-55 filed 2026-07-30 at Campaign P Slice P2, split out of the retired AP-7 row — the open cos(10°)-vs-0.99999536f Sledding slope-flatness constant; AD-53/AD-54 filed the same slice, split out of the retired TS-1 row — CliffSlide's three-source reference-normal fallback chain and the walkable-steepness reroute to CliffSlide; AD-52 filed 2026-07-29 at Campaign N slice N6 — the fragment-assembler 60 s partial TTL + completed-sequence ring; AD-51 filed 2026-07-29 at Campaign N slice N4 — the reclaimed-word pool for ACE's fresh-sequence cleartext RejectRetransmit; AD-50 filed 2026-07-29 at Campaign N slice N2 — the inbound-watermark ACE init; AD-49 stays reserved for Campaign N §5's blob-layer ordering deferral, filed when its slice lands; AD-47 and AD-48 filed 2026-07-29 at Campaign V slice V11 — the MSAA sample-position and present-pacing rows the campaign's risk register scheduled for the GL deletion; AD-11 retired 2026-07-23 — exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) +## 2. Adaptation (AD) — 44 active rows + +Recent retirements: AD-3/AD-4 retired 2026-07-31 by the exact loaded-cell +availability and null-root containment port; AD-25 retired 2026-07-30 by the +shared `PhysicsObjUpdate.HandleAllCollisions` remote path; AD-11 retired +2026-07-23 by the exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 +by the DAT-authored portal-space viewport. Recent additions and splits: +AD-47/AD-48 (Vulkan sample/present behavior), AD-50..AD-52 (Campaign N), and +AD-53..AD-55 (Campaign P response-layer findings). | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -78,8 +86,6 @@ accepted-divergence entries (#96, #49, #50). | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | -| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) | -| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index c034005a..0b1cd17f 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -178,6 +178,15 @@ package schema, bake, DAT reader, collision formula, or render portal graph changed. Evidence: `docs/research/2026-07-26-prepared-indoor-transit-regression.md`. +**Cell availability semantics (2026-07-31).** Raw and prepared CellStruct +publication now retains a `CellPhysics` record even when authored physics or +containment roots are empty. Payload absence is the unavailable state; a loaded +null/-1 containment root keeps retail's universal-inside BSP base case. +Registration-side outdoor floods still add outside cells but skip transit when +the active CLandCell is unavailable, then recover through the existing reflood +after terrain/cell hydration. No package schema or DAT reader changed. +Evidence: `docs/research/2026-07-31-cell-availability-semantics.md`. + **Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter 2.1.7 models `CreateBlockingParticleHook` as the common hook header only, while retail inherits the complete `CreateParticleHook` payload. The narrow readers in diff --git a/docs/research/2026-07-31-cell-availability-semantics.md b/docs/research/2026-07-31-cell-availability-semantics.md new file mode 100644 index 00000000..a2bd019b --- /dev/null +++ b/docs/research/2026-07-31-cell-availability-semantics.md @@ -0,0 +1,62 @@ +# Retail cell availability and null-root containment — 2026-07-31 + +## Scope + +This note closes divergence rows AD-3 and AD-4. It does not begin AD-6's +atomic streaming-generation work. + +The bug was one collapsed state. acdream treated all three of these as +“containment unavailable”: + +1. no visible cell payload is loaded; +2. a loaded CellStruct has a null containment root; +3. a loaded CellStruct has an authored containment root. + +Retail distinguishes (1) from (2). A failed visible-cell lookup is +unavailable. A loaded CellStruct remains a real cell even when its BSPTREE +root is null, and the containment query's null-node base case is inside. + +## Retail oracle + +`CObjCell::find_cell_list @ 0x0052B4E0` in +`docs/research/named-retail/acclient_2013_pseudo_c.txt:308742` establishes the +availability gate: + +- `CEnvCell::GetVisible` / `CLandCell::GetVisible` resolves the active seed at + `0x0052B50C..0x0052B515`; +- the outdoor branch still calls `CLandCell::add_all_outside_cells` at + `0x0052B53F`, even when that seed lookup returned null; +- the complete growing-array transit walk and containing-cell pick are gated + by `seed != null && num_spheres != 0` at `0x0052B576`; +- each later candidate is independently skipped when its stored cell pointer + is null at `0x0052B58E`. + +`CCellStruct::point_in_cell @ 0x005338F0` delegates directly to +`BSPTREE::point_inside_cell_bsp @ 0x005398C0`. The already-ported graph and +flat BSP queries preserve the retail null-root base case: a negative/null root +returns true. Root presence is therefore not an availability predicate. + +## Ported behavior + +- `PhysicsDataCache` now publishes a `CellPhysics` record whenever an authored + raw or prepared CellStruct payload exists, even if its physics BSP and/or + containment BSP root is absent. +- `CollisionTraversal.HasCellContainment` tests representation payload + availability, not `Root` / `RootIndex`. `PointInsideCell` then lets the + graph or flat query return true for the null-root base case. +- `CellTransit.BuildShadowCellSet` still seeds all overlapped outdoor cells, + but skips the transit walk when the active outdoor seed cannot be resolved + from `CellGraph`. A separately cached building can no longer promote an + object through an unavailable landcell. +- The existing reflood lifecycle remains the recovery mechanism. Once terrain + or an indoor CellStruct publishes, the next reflood walks the same authored + portal/building relationships without reconstructing a different rule. + +## Gates + +Focused tests cover raw graph and prepared flat cache publication, absent +versus loaded-null-root containment, indoor and outdoor seeds, preservation of +outside-cell seeding, suppression of spurious building promotion, and +hydration/reflood recovery. Final Release gates passed: Core 4,162 / 1 skipped, +Runtime 440 / 0 skipped, App 4,002 / 3 skipped, plus the complete solution +build with zero errors. diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 3c5cc1ed..1c2855e8 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -577,10 +577,12 @@ public static class CellTransit else { AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates); - // Outdoor seeds always walk: retail's null-CLandCell case is - // "landblock not loaded at all", where our per-cell building - // lookups below come back null anyway (documented adaptation). - seedLoaded = true; + // Retail preserves the outside-cell additions above but skips + // the complete growing-array transit walk when GetVisible cannot + // resolve the ACTIVE seed CLandCell (0052b50e, 0052b576). A cached + // building alone must not promote an object through an unavailable + // landcell; the normal reflood after terrain publication retries. + seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null; } if (seedLoaded) @@ -664,11 +666,9 @@ public static class CellTransit /// /// /// - /// acdream adaptation (matches at line 518): a cell - /// with no hydrated cannot run - /// point_in_cell, so it is treated as NOT containing the point (skipped), - /// rather than letting 's null-node - /// "inside" default make it spuriously claim every point. + /// A missing record is unavailable and skipped. + /// A loaded record whose authored containment root is null retains retail's + /// universal-inside base case. /// /// public static uint FindVisibleChildCell( @@ -705,8 +705,8 @@ public static class CellTransit /// /// CEnvCell::point_in_cell (cell-BSP vtable[0x84]) against a world point: /// transform to the cell's local frame, then . - /// A cell with no hydrated returns false (see - /// 's adaptation note). + /// A missing cell payload returns false; a loaded payload with a null root + /// returns true through the retail BSP base case. /// private static bool PointInCell( PhysicsDataCache cache, diff --git a/src/AcDream.Core/Physics/CollisionTraversal.cs b/src/AcDream.Core/Physics/CollisionTraversal.cs index d75f4bea..a08a188b 100644 --- a/src/AcDream.Core/Physics/CollisionTraversal.cs +++ b/src/AcDream.Core/Physics/CollisionTraversal.cs @@ -25,9 +25,14 @@ internal static class CollisionTraversal { if (UseFlat(cache)) { - FlatCellContainmentBsp flat = cell.FlatContainmentBsp ?? + // Availability is the authored CellStruct payload, not the + // containment root. Retail's loaded BSPTREE may have a null + // root; BSPNODE::point_inside_cell_bsp treats that base case as + // universally inside. A missing flat payload is still a broken + // production publication and must fail loudly. + _ = cell.FlatContainmentBsp ?? throw MissingFlat("cell containment"); - bool flatAuthorityResult = flat.RootIndex >= 0; + const bool flatAuthorityResult = true; CollisionShadowVerifier? flatShadow = cache.CollisionShadow; if (flatShadow is null || !flatShadow.TrySample(out long flatAuthoritySample)) @@ -38,7 +43,7 @@ internal static class CollisionTraversal flatShadow.BeginGraphPass(); try { - graphRefereeResult = cell.CellBSP?.Root is not null; + graphRefereeResult = true; } catch (Exception fault) { @@ -74,15 +79,16 @@ internal static class CollisionTraversal CollisionShadowVerifier? shadow = cache.CollisionShadow; if (shadow is null || !shadow.TrySample(out long sample)) - return cell.CellBSP?.Root is not null; + return true; bool flatResult = false; Exception? flatFault = null; shadow.BeginFlatPass(); try { - flatResult = (cell.FlatContainmentBsp ?? - throw MissingFlat("cell containment")).RootIndex >= 0; + _ = cell.FlatContainmentBsp ?? + throw MissingFlat("cell containment"); + flatResult = true; } catch (Exception fault) { @@ -93,7 +99,7 @@ internal static class CollisionTraversal shadow.EndFlatPass(); } - bool graphResult = cell.CellBSP?.Root is not null; + const bool graphResult = true; if (flatFault is null) { shadow.RecordBoolean( diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index b7a520b2..43d2fbb4 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -82,8 +82,8 @@ public sealed class PhysicsDataCache /// /// The unified cell graph (UCG): the active id->cell resolver and registry. - /// Populated unconditionally in — BEFORE the - /// idempotency + null-BSP guards, so BSP-less cells are registered too — and + /// Populated unconditionally in so BSP-less + /// authored cells are registered too, and /// consumed across the engine: the player render/lighting root /// (CellGraph.CurrCell, written at the player chokepoint /// PhysicsEngine.UpdatePlayerCurrCell and read by the renderer), the @@ -364,9 +364,11 @@ public sealed class PhysicsDataCache } /// - /// Extract and cache the physics BSP + polygon data from a CellStruct - /// (indoor room geometry). No-ops if the id is already cached or the - /// CellStruct has no physics BSP. + /// Extract and cache the authored CellStruct payload (indoor room + /// geometry), including cells whose physics or containment BSP has a null + /// root. Retail keeps those loaded cells distinct from an unavailable + /// visible-cell lookup; the null containment root is universally inside. + /// No-ops only when the id is already cached. /// public void CacheCellStruct( uint envCellId, @@ -414,8 +416,7 @@ public sealed class PhysicsDataCache return; } - // UCG Stage 1: register in the unified graph for ALL cells — before the - // idempotency + null-BSP guards below, so BSP-less cells are still included. + // UCG Stage 1: register in the unified graph for every authored cell. if (!CellGraph.Contains(envCellId)) { CellGraph.Add(UcgEnvCell.FromDat( @@ -427,11 +428,12 @@ public sealed class PhysicsDataCache } if (_cellStruct.ContainsKey(envCellId)) return; - if (cellStruct.PhysicsBSP?.Root is null) return; Matrix4x4.Invert(worldTransform, out var inverseTransform); - var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray); + var resolved = cellStruct.PhysicsPolygons is null + ? new Dictionary() + : ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray); // Visible polygons — portals reference these (NOT PhysicsPolygons). var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray); @@ -628,11 +630,9 @@ public sealed class PhysicsDataCache preparedTopology)); } - // Preserve CacheCellStruct's existing distinction: BSP-less cells - // participate in the cell graph but do not masquerade as hydrated - // collision cells. - if (preparedStructure.PhysicsBsp.RootIndex < 0) - return; + // The prepared structure itself is the loaded CellStruct payload. + // Empty physics and containment roots remain meaningful authored + // values; neither means that the cell is unavailable. if (_cellStruct.ContainsKey(envCellId)) return; @@ -1023,8 +1023,9 @@ public sealed class CellPhysics /// (point-in-cell tests). Separate tree from /// (collision) and from the renderer's drawing-BSP. /// Source: cellStruct.CellBSP at cache time. - /// Nullable: cells without a CellBSP cannot participate in portal - /// containment and are skipped by . + /// A nullable root is an authored, universally-inside containment tree. + /// Cell availability is represented by presence of this + /// record, not by root presence. /// public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 873b00b1..d8eed135 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -8,8 +8,8 @@ namespace AcDream.Core.World.Cells; /// /// The unified cell graph: the active, authoritative id->cell resolver and registry. /// Populated unconditionally from -/// (before its -/// idempotency + null-BSP guards, so BSP-less cells are included) and consumed across +/// (including +/// authored cells with null physics or containment roots) and consumed across /// the engine: resolves any cell id, is /// the player render/lighting root, resolves the /// 3rd-person camera cell, and supplies the block-local diff --git a/src/AcDream.Core/World/Cells/EnvCell.cs b/src/AcDream.Core/World/Cells/EnvCell.cs index c4b96750..d18039c6 100644 --- a/src/AcDream.Core/World/Cells/EnvCell.cs +++ b/src/AcDream.Core/World/Cells/EnvCell.cs @@ -10,7 +10,11 @@ namespace AcDream.Core.World.Cells; /// Indoor room cell. Retail anchor: CEnvCell (acclient.h:32072). public sealed class EnvCell : ObjCell { - /// Cell-containment BSP (retail CellStruct.CellBSP). Null => AABB fallback. + /// + /// Cell-containment BSP (retail CellStruct.CellBSP). A present tree with a + /// null root is universally inside; an absent test/tooling payload uses the + /// legacy AABB fallback. + /// public CellBSPTree? ContainmentBsp { get; } /// @@ -37,7 +41,7 @@ public sealed class EnvCell : ObjCell var local = Vector3.Transform(worldPoint, InverseWorldTransform); if (FlatContainmentBsp is not null) return FlatBspQuery.PointInsideCellBsp(FlatContainmentBsp, local); - if (ContainmentBsp?.Root is not null) + if (ContainmentBsp is not null) return BSPQuery.PointInsideCellBsp(ContainmentBsp.Root, local); // BSPQuery.cs:1034 return local.X >= LocalBoundsMin.X && local.X <= LocalBoundsMax.X && local.Y >= LocalBoundsMin.Y && local.Y <= LocalBoundsMax.Y diff --git a/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs index 448f100d..274ad311 100644 --- a/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Numerics; using DatReaderWriter.Enums; @@ -74,6 +75,64 @@ public class BuildShadowCellSetTests }; } + private static CellPhysics MakeNullRootCell(Matrix4x4 worldTransform) + { + Matrix4x4.Invert(worldTransform, out var inv); + return new CellPhysics + { + WorldTransform = worldTransform, + InverseWorldTransform = inv, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree { Root = null }, + FlatContainmentBsp = new FlatCellContainmentBsp( + -1, + ImmutableArray.Empty), + }; + } + + private static CellPhysics MakeNullRootCellWithExteriorPortal( + Matrix4x4 worldTransform) + { + Matrix4x4.Invert(worldTransform, out var inv); + var portalPlane = new Plane(new Vector3(1f, 0f, 0f), -2.5f); + return new CellPhysics + { + WorldTransform = worldTransform, + InverseWorldTransform = inv, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree { Root = null }, + FlatContainmentBsp = new FlatCellContainmentBsp( + -1, + ImmutableArray.Empty), + PortalPolygons = new Dictionary + { + [10] = new ResolvedPolygon + { + Vertices = + [ + new Vector3(2.5f, -2.5f, 0f), + new Vector3(2.5f, 2.5f, 0f), + new Vector3(2.5f, 2.5f, 5f), + new Vector3(2.5f, -2.5f, 5f), + ], + Plane = portalPlane, + NumPoints = 4, + SidesType = CullMode.None, + }, + }, + Portals = + [ + new PortalInfo(otherCellId: 0xFFFF, polygonId: 10, flags: 0), + ], + }; + } + + private static void RegisterFlatTerrain(PhysicsDataCache cache) + => cache.CellGraph.RegisterTerrain( + 0xA9B40000u, + new TerrainSurface(new byte[81], new float[256]), + Vector3.Zero); + // ── Seeds ────────────────────────────────────────────────────────── [Fact] @@ -129,6 +188,42 @@ public class BuildShadowCellSetTests Assert.Equal(new[] { IndoorSeed }, set); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void IndoorSeed_RefloodsAfterNullRootPayloadHydrates( + bool useFlat) + { + var cache = new PhysicsDataCache + { + CollisionTraversalMode = useFlat + ? CollisionTraversalMode.Flat + : CollisionTraversalMode.Graph, + }; + Sphere[] sphere = One(new Vector3(2.4f, 0f, 2.5f), 0.5f); + + IReadOnlyList unavailable = CellTransit.BuildShadowCellSet( + cache, + IndoorSeed, + sphere, + 1, + isStatic: false); + Assert.Equal(new[] { IndoorSeed }, unavailable); + + cache.RegisterCellStructForTest( + IndoorSeed, + MakeNullRootCellWithExteriorPortal(Matrix4x4.Identity)); + IReadOnlyList hydrated = CellTransit.BuildShadowCellSet( + cache, + IndoorSeed, + sphere, + 1, + isStatic: false); + + Assert.Contains(IndoorSeed, hydrated); + Assert.Contains(hydrated, id => (id & 0xFFFFu) < 0x0100u); + } + [Fact] public void OutdoorSeed_FloodsOverlappedLandcells_BlockCrossingMath() { @@ -155,6 +250,7 @@ public class BuildShadowCellSetTests // vestibule's shadow_object_list at registration via // CLandCell::find_transit_cells → ... → check_building_transit. var cache = new PhysicsDataCache(); + RegisterFlatTerrain(cache); cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity)); var sphere = One(new Vector3(12f, 12f, 0f), 0.5f); @@ -188,6 +284,7 @@ public class BuildShadowCellSetTests // other_portal_id = -1 (wire 0xFFFF) never admits its interior cell // (CEnvCell::check_building_transit, 0x0052c5dc). var cache = new PhysicsDataCache(); + RegisterFlatTerrain(cache); cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity)); var sphere = One(new Vector3(12f, 12f, 0f), 0.5f); @@ -211,6 +308,63 @@ public class BuildShadowCellSetTests Assert.DoesNotContain(NeighborCell, set); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UnavailableOutdoorSeed_AddsOutsideButSkipsTransit_UntilTerrainHydrates( + bool useFlat) + { + var cache = new PhysicsDataCache + { + CollisionTraversalMode = useFlat + ? CollisionTraversalMode.Flat + : CollisionTraversalMode.Graph, + }; + cache.RegisterCellStructForTest( + NeighborCell, + MakeNullRootCell(Matrix4x4.Identity)); + + var sphere = One(new Vector3(12f, 12f, 0f), 0.5f); + IReadOnlyList seeded = CellTransit.BuildShadowCellSet( + cache, + 0xA9B40001u, + sphere, + 1, + isStatic: false); + uint landcell = seeded[0]; + cache.RegisterBuildingForTest(landcell, new BuildingPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Portals = + [ + new BldPortalInfo(NeighborCell, otherPortalId: 0, flags: 0), + ], + }); + + IReadOnlyList unavailable = CellTransit.BuildShadowCellSet( + cache, + 0xA9B40001u, + sphere, + 1, + isStatic: false); + + Assert.NotEmpty(unavailable); + Assert.All(unavailable, id => Assert.True((id & 0xFFFFu) < 0x0100u)); + Assert.DoesNotContain(NeighborCell, unavailable); + + RegisterFlatTerrain(cache); + IReadOnlyList hydrated = CellTransit.BuildShadowCellSet( + cache, + 0xA9B40001u, + sphere, + 1, + isStatic: false); + + Assert.Contains(landcell, hydrated); + Assert.Contains(NeighborCell, hydrated); + } + // ── Exterior straddle from an indoor seed ────────────────────────── [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs b/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs index ec99de63..843c0512 100644 --- a/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs @@ -11,14 +11,14 @@ namespace AcDream.Core.Tests.Physics; public class CellGraphPopulationTests { [Fact] - public void CacheCellStruct_AddsEnvCellToGraph_EvenWhenPhysicsBspIsNull() + public void CacheCellStruct_PublishesLoadedCell_WhenPhysicsAndContainmentRootsAreNull() { var cache = new PhysicsDataCache(); var cellStruct = new CellStruct { VertexArray = new VertexArray { Vertices = new Dictionary() }, Polygons = new Dictionary(), - // PhysicsBSP omitted (defaults to null) — triggers the null-BSP drop from _cellStruct + CellBSP = new CellBSPTree { Root = null }, }; var dat = new DatEnvCell { @@ -29,8 +29,14 @@ public class CellGraphPopulationTests cache.CacheCellStruct(0xA9B40174u, dat, cellStruct, Matrix4x4.Identity); - Assert.Null(cache.GetCellStruct(0xA9B40174u)); // dropped from physics cache - Assert.NotNull(cache.CellGraph.GetVisible(0xA9B40174u)); // but present in the graph + CellPhysics loaded = Assert.IsType( + cache.GetCellStruct(0xA9B40174u)); + Assert.True(CollisionTraversal.HasCellContainment(cache, loaded)); + Assert.True(CollisionTraversal.PointInsideCell( + cache, + loaded, + new Vector3(10_000f, -10_000f, 500f))); + Assert.NotNull(cache.CellGraph.GetVisible(0xA9B40174u)); Assert.IsType(cache.CellGraph.GetVisible(0xA9B40174u)); } } diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs index 722e94fa..265fc0d6 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs @@ -8,16 +8,11 @@ namespace AcDream.Core.Tests.Physics; public class CellTransitCheckBuildingTransitTests { [Fact] - public void BuildingPortalWithUnloadedCellBSP_NoCandidateAdded() + public void BuildingPortalWithLoadedNullRoot_CellIsAdmitted() { - // Verifies the null-CellBSP guard: when the destination interior cell - // is cached but its CellBSP isn't yet loaded (or is structurally absent), - // CheckBuildingTransit must NOT add the cell to candidates — even though - // PointInsideCellBsp(null, _) returns true. - // - // Happy-path (CellBSP present, sphere inside) requires a synthetic - // CellBSPTree which is non-trivial to construct from DatReaderWriter - // types. Deferred to visual verification. + // Retail separates an unavailable CEnvCell lookup from an authored + // CellStruct whose cell_bsp root is null. The latter is loaded, and + // the null-root sphere query is the universal-inside base case. // Building at world origin. One portal to interior cell 0xA9B40100. var building = new BuildingPhysics @@ -33,14 +28,13 @@ public class CellTransitCheckBuildingTransitTests }, }; - // Interior cell with null CellBSP — PointInsideCellBsp(null, _) returns true, - // but CheckBuildingTransit guards on CellBSP?.Root being non-null, so this - // cell is skipped. + // Interior cell with an authored null containment root. var interiorCell = new CellPhysics { WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Resolved = new Dictionary(), + CellBSP = new DatReaderWriter.Types.CellBSPTree { Root = null }, }; var cache = new PhysicsDataCache(); @@ -53,8 +47,33 @@ public class CellTransitCheckBuildingTransitTests sphereRadius: 0.5f, candidates); - // CellBSP is null → containment guard (otherCell?.CellBSP?.Root is null) - // skips this cell. No candidate added. + Assert.Contains(0xA9B40100u, candidates); + } + + [Fact] + public void BuildingPortalWithUnavailableCell_NoCandidateAdded() + { + var building = new BuildingPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Portals = + [ + new BldPortalInfo( + otherCellId: 0xA9B40100u, + otherPortalId: 0, + flags: 0), + ], + }; + var candidates = new HashSet(); + + CellTransit.CheckBuildingTransit( + new PhysicsDataCache(), + building, + worldSphereCenter: Vector3.Zero, + sphereRadius: 0.5f, + candidates); + Assert.Empty(candidates); } diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index cdbf5afa..dd849175 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -315,10 +315,11 @@ public class CellTransitFindCellSetTests } [Fact] - public void IndoorSeed_CellWithoutBsp_CannotVerify_StaysCurrent() + public void IndoorSeed_LoadedNullRoot_IsUniversallyInside_StaysCurrent() { - // Stale-beats-null while streaming hydrates: a registered cell with - // no CellBSP yet cannot be verified — trust the claim (no demotion). + // Retail distinguishes a failed cell lookup from a loaded CellStruct + // whose containment root is null. The latter is the BSP query's + // universally-inside base case, so the current cell wins immediately. Matrix4x4.Invert(Matrix4x4.Identity, out var inv); var cellNoBsp = new CellPhysics { diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs index e02ed83b..45b95f5c 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs @@ -160,4 +160,50 @@ public sealed class PhysicsDataCacheProductionTests structure.ContainmentBsp, runtimeCell.FlatContainmentBsp); } + + [Fact] + public void ProductionCellPublication_PreservesLoadedCellWithEmptyRoots() + { + const uint cellId = 0xA9B4_0174u; + PhysicsDataCache cache = PhysicsDataCache.CreateProduction(); + var emptyPhysics = new FlatPhysicsBsp( + -1, + ImmutableArray.Empty, + ImmutableArray.Empty, + FlatPolygonTable.Empty); + var emptyContainment = new FlatCellContainmentBsp( + -1, + ImmutableArray.Empty); + var structure = new FlatCellStructureCollisionAsset( + emptyPhysics, + emptyContainment, + FlatPolygonTable.Empty); + var topology = new FlatEnvCellTopology( + ImmutableArray.Empty, + ImmutableArray.Empty, + seenOutside: false); + + cache.CacheCellStruct( + cellId, + new EnvCell(), + Matrix4x4.Identity, + structure, + topology); + + CellPhysics loaded = Assert.IsType( + cache.GetCellStruct(cellId)); + Assert.False(CollisionTraversal.HasPhysics(cache, loaded)); + Assert.True(CollisionTraversal.HasCellContainment(cache, loaded)); + Assert.True(CollisionTraversal.PointInsideCell( + cache, + loaded, + new Vector3(10_000f, -10_000f, 500f))); + var graphCell = Assert.IsType( + cache.CellGraph.GetVisible(cellId)); + Assert.True(graphCell.PointInCell( + new Vector3(10_000f, -10_000f, 500f))); + Assert.Equal(1, cache.CellStructCount); + Assert.Equal(1, cache.FlatCellStructCount); + Assert.Equal(0, cache.GraphCellStructCount); + } } diff --git a/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs b/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs index 7c1ddda0..e099c2ae 100644 --- a/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs +++ b/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs @@ -27,6 +27,23 @@ public class EnvCellTests public void PointInCell_NullBsp_Aabb_OutsideIsFalse() => Assert.False(Make(new Vector3(0,0,0), new Vector3(10,10,10)).PointInCell(new Vector3(20,5,5))); + [Fact] + public void PointInCell_LoadedNullRoot_IsUniversallyInside() + { + var cell = new EnvCell( + 0xA9B4_0174u, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + seenOutside: false, + containmentBsp: new CellBSPTree { Root = null }); + + Assert.True(cell.PointInCell(new Vector3(10_000f, -10_000f, 500f))); + } + [Fact] public void PointInCell_TransformsWorldToLocalBeforeTesting() { From 3e0f3b6206808d806abdb37583b83dda2921adaf Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 14:48:26 +0200 Subject: [PATCH 21/73] fix(physics): validate retail cell containment roots --- .../retail-divergence-register.md | 6 +- docs/architecture/worldbuilder-inventory.md | 19 ++-- .../2026-07-31-cell-availability-semantics.md | 86 +++++++++++------ src/AcDream.Core/Physics/CellTransit.cs | 37 +++++--- .../Physics/CollisionTraversal.cs | 20 ++-- src/AcDream.Core/Physics/PhysicsDataCache.cs | 58 ++++++++---- src/AcDream.Core/World/Cells/CellGraph.cs | 6 +- src/AcDream.Core/World/Cells/EnvCell.cs | 20 ++-- ...PlayerMovementPlacementTransactionTests.cs | 7 ++ .../CameraCollisionUpdateViewerTests.cs | 9 +- .../LandblockPhysicsPublisherTests.cs | 7 ++ .../Physics/BuildShadowCellSetTests.cs | 94 ++++++++++++++----- .../Physics/CellGraphMembershipTests.cs | 4 + .../Physics/CellGraphPopulationTests.cs | 14 ++- .../CellTransitCheckBuildingTransitTests.cs | 13 +-- .../Physics/CellTransitFindCellSetTests.cs | 41 +++++--- .../CellTransitFindVisibleChildCellTests.cs | 4 +- .../Issue133DungeonTeleportPrefixTests.cs | 5 +- .../PhysicsDataCacheProductionTests.cs | 46 ++++++--- .../PhysicsEngineAdjustPositionTests.cs | 4 +- .../Ts4ProductionQuantumConformanceTests.cs | 12 +++ .../Rendering/CellGraphRootTests.cs | 35 +++++-- .../World/Cells/EnvCellTests.cs | 79 ++++++++++------ 23 files changed, 429 insertions(+), 197 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 169bb2d1..5d243c77 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -64,8 +64,10 @@ accepted-divergence entries (#96, #49, #50). ## 2. Adaptation (AD) — 44 active rows -Recent retirements: AD-3/AD-4 retired 2026-07-31 by the exact loaded-cell -availability and null-root containment port; AD-25 retired 2026-07-30 by the +Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate +visible-cell availability, full-catalog containment-root validation, and the +zero-portals point-in-cell guard (rootless payloads are quarantined; only a +missing positive child below a valid root is the inside base case); AD-25 retired 2026-07-30 by the shared `PhysicsObjUpdate.HandleAllCollisions` remote path; AD-11 retired 2026-07-23 by the exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 by the DAT-authored portal-space viewport. Recent additions and splits: diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index 0b1cd17f..a449b22b 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -178,13 +178,18 @@ package schema, bake, DAT reader, collision formula, or render portal graph changed. Evidence: `docs/research/2026-07-26-prepared-indoor-transit-regression.md`. -**Cell availability semantics (2026-07-31).** Raw and prepared CellStruct -publication now retains a `CellPhysics` record even when authored physics or -containment roots are empty. Payload absence is the unavailable state; a loaded -null/-1 containment root keeps retail's universal-inside BSP base case. -Registration-side outdoor floods still add outside cells but skip transit when -the active CLandCell is unavailable, then recover through the existing reflood -after terrain/cell hydration. No package schema or DAT reader changed. +**Cell availability semantics (2026-07-31, corrected after full-catalog +audit).** Raw and prepared CellStruct publication retains a `CellPhysics` +record when the physics root is empty but requires a valid containment root. +The installed 729,888-record raw and prepared catalogs contain zero rootless +containment payloads. A malformed null/-1 root is quarantined atomically; the +recursive inside base case applies only to a missing positive child below a +valid root. Registration-side outdoor floods still add outside cells but skip +transit when the active CLandCell is unavailable, and every later outdoor +candidate independently requires its own visible landcell before building +transit. The existing reflood retries after terrain/cell hydration. Both raw +and prepared point-in-cell paths preserve retail's zero-portals guard. No +package schema or DAT reader changed. Evidence: `docs/research/2026-07-31-cell-availability-semantics.md`. **Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter diff --git a/docs/research/2026-07-31-cell-availability-semantics.md b/docs/research/2026-07-31-cell-availability-semantics.md index a2bd019b..979dfbe9 100644 --- a/docs/research/2026-07-31-cell-availability-semantics.md +++ b/docs/research/2026-07-31-cell-availability-semantics.md @@ -1,26 +1,41 @@ -# Retail cell availability and null-root containment — 2026-07-31 +# Retail cell availability and containment-root validation — 2026-07-31 ## Scope This note closes divergence rows AD-3 and AD-4. It does not begin AD-6's atomic streaming-generation work. -The bug was one collapsed state. acdream treated all three of these as -“containment unavailable”: +The corrected port distinguishes these states: 1. no visible cell payload is loaded; -2. a loaded CellStruct has a null containment root; -3. a loaded CellStruct has an authored containment root. +2. a malformed raw/prepared payload has no containment root; +3. a loaded CellStruct has a valid authored containment root (its physics root + may independently be absent). -Retail distinguishes (1) from (2). A failed visible-cell lookup is -unavailable. A loaded CellStruct remains a real cell even when its BSPTREE -root is null, and the containment query's null-node base case is inside. +Only (3) is published. State (1) remains unavailable and retryable. State (2) +is quarantined atomically so a later valid hydration can retry; it must not +become a world-wide containing cell. + +## Installed-data audit + +The complete installed EoR catalog and matching prepared package were audited +before choosing this invariant: + +- enumerated EnvCells: **729,888**; +- raw: 0 missing EnvCells, 0 missing Environments, 0 missing CellStructs, + 0 null `CellBSP` objects, **0 null `CellBSP.Root`**, 729,888 valid roots; +- prepared `acdream.pak`: 0 missing aliases, 0 corrupt payloads, + **0 `ContainmentBsp.RootIndex < 0`**, 729,888 valid roots; +- 6,940 raw EnvCells have zero portals, so the retail portal-pointer guard is + a real catalog path rather than dead defensive code. + +There are therefore no root-null record IDs to preserve in either source. ## Retail oracle `CObjCell::find_cell_list @ 0x0052B4E0` in `docs/research/named-retail/acclient_2013_pseudo_c.txt:308742` establishes the -availability gate: +availability gates: - `CEnvCell::GetVisible` / `CLandCell::GetVisible` resolves the active seed at `0x0052B50C..0x0052B515`; @@ -31,32 +46,47 @@ availability gate: - each later candidate is independently skipped when its stored cell pointer is null at `0x0052B58E`. -`CCellStruct::point_in_cell @ 0x005338F0` delegates directly to -`BSPTREE::point_inside_cell_bsp @ 0x005398C0`. The already-ported graph and -flat BSP queries preserve the retail null-root base case: a negative/null root -returns true. Root presence is therefore not an availability predicate. +`CEnvCell::point_in_cell @ 0x0052C300` first returns false when +`this->portals == 0`, then transforms the point and calls +`CCellStruct::point_in_cell`. + +`CCellStruct::point_in_cell @ 0x005338F0` calls +`BSPTREE::point_inside_cell_bsp @ 0x005398C0`, which immediately invokes +`BSPNODE::point_inside_cell_bsp(this->root_node, ...)`. The BSP node method at +`0x0053C1F0` dereferences `this` before walking positive children. Only a +missing **positive child below a valid root** is the inside terminal case. A +missing root is not. ## Ported behavior -- `PhysicsDataCache` now publishes a `CellPhysics` record whenever an authored - raw or prepared CellStruct payload exists, even if its physics BSP and/or - containment BSP root is absent. -- `CollisionTraversal.HasCellContainment` tests representation payload - availability, not `Root` / `RootIndex`. `PointInsideCell` then lets the - graph or flat query return true for the null-root base case. +- `PhysicsDataCache` publishes graph, collision, and prepared records only + after a valid raw/prepared containment root is present. A missing physics + root is retained as a valid non-colliding cell. Invalid containment + publication changes no cache, so later hydration can retry. +- `CollisionTraversal.HasCellContainment` tests `Root` / `RootIndex`. +- Both raw and prepared `EnvCell.PointInCell` paths apply the zero-portals + guard before containment. `CellTransit` applies the same guard to its + `CellPhysics` representation. - `CellTransit.BuildShadowCellSet` still seeds all overlapped outdoor cells, but skips the transit walk when the active outdoor seed cannot be resolved - from `CellGraph`. A separately cached building can no longer promote an - object through an unavailable landcell. + from `CellGraph`. Every later outdoor candidate independently resolves via + `GetVisible` before building transit, so a stale building cannot promote an + object through an unavailable adjacent landcell. - The existing reflood lifecycle remains the recovery mechanism. Once terrain - or an indoor CellStruct publishes, the next reflood walks the same authored + or a valid indoor CellStruct publishes, the next reflood walks the authored portal/building relationships without reconstructing a different rule. ## Gates -Focused tests cover raw graph and prepared flat cache publication, absent -versus loaded-null-root containment, indoor and outdoor seeds, preservation of -outside-cell seeding, suppression of spurious building promotion, and -hydration/reflood recovery. Final Release gates passed: Core 4,162 / 1 skipped, -Runtime 440 / 0 skipped, App 4,002 / 3 skipped, plus the complete solution -build with zero errors. +Focused tests cover raw/prepared rootless quarantine and valid retry, valid +containment with missing physics, raw/prepared zero-portal parity, indoor and +outdoor seeds, preservation of outside-cell seeding, per-candidate adjacent +landcell availability, suppression of stale-building promotion, and +hydration/reflood recovery. The corrective checkpoint passes: + +- focused cell-availability suite: **54/54**; +- Core Release: **4,165 passed / 1 skipped**; +- Runtime Release: **440/440**; +- App Release: **4,002 passed / 3 skipped**; +- complete Release solution: **10,122 passed / 4 skipped**; +- `dotnet build AcDream.slnx -c Release`: **0 warnings / 0 errors**. diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 1c2855e8..71ded556 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -608,6 +608,13 @@ public static class CellTransit } else { + // CELLARRAY stores GetVisible's result beside every id. + // Retail skips a later candidate whose cell pointer is + // null (0052b588..0052b59f), even when a stale building + // record for that landcell remains cached. + if (cache.CellGraph.GetVisible(cellId) is null) + continue; + // CLandCell::find_transit_cells (0x00533800): // add_all_outside_cells (added_outside-guarded) then the // building bridge for the landcell's building, if any. @@ -666,9 +673,9 @@ public static class CellTransit /// /// /// - /// A missing record is unavailable and skipped. - /// A loaded record whose authored containment root is null retains retail's - /// universal-inside base case. + /// A missing or rootless record is unavailable + /// and skipped. The retail inside base case belongs to a missing positive + /// child below a valid root, not to the root itself. /// /// public static uint FindVisibleChildCell( @@ -705,8 +712,8 @@ public static class CellTransit /// /// CEnvCell::point_in_cell (cell-BSP vtable[0x84]) against a world point: /// transform to the cell's local frame, then . - /// A missing cell payload returns false; a loaded payload with a null root - /// returns true through the retail BSP base case. + /// A missing/rootless payload returns false. Retail also returns false + /// before containment when CEnvCell::portals is null. /// private static bool PointInCell( PhysicsDataCache cache, @@ -714,6 +721,7 @@ public static class CellTransit Vector3 worldPoint) { if (cell is null || + cell.Portals.Count == 0 || !CollisionTraversal.HasCellContainment(cache, cell)) { return false; @@ -920,6 +928,11 @@ public static class CellTransit if ((cellId & 0xFFFFu) < 0x0100u) { + // Match CELLARRAY's stored GetVisible pointer: an adjacent + // landcell id may be present because the sphere overlaps it, + // while that landblock is not loaded yet. + if (cache.CellGraph.GetVisible(cellId) is null) + continue; // Landcell dispatch — CLandCell::find_transit_cells (0x00533800) // → CSortCell::find_transit_cells (0x00534060, this->building) // → CBuildingObj::find_building_transit_cells (0x006b5230) @@ -994,17 +1007,13 @@ public static class CellTransit { // Interior candidate — point_in_cell via the cell BSP (vtable[0x84]). var cand = cache.GetCellStruct(candId); - if (cand is null || - !CollisionTraversal.HasCellContainment(cache, cand)) - { - continue; - } - - var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform); - if (CollisionTraversal.PointInsideCell(cache, cand, local)) + if (PointInCell(cache, cand, worldSphereCenter)) return candId; // interior-wins, stop (pseudo_c:308819) } - else if (outdoorResult == 0u && containingOutdoorId != 0u && outdoorPickAllowed) + else if (outdoorResult == 0u && + containingOutdoorId != 0u && + outdoorPickAllowed && + cache.CellGraph.GetVisible(candId) is not null) { // Outdoor candidate — CLandCell::point_in_cell is the XY-column the // sphere is over (acdream landcells have no BSP point_in_cell; the diff --git a/src/AcDream.Core/Physics/CollisionTraversal.cs b/src/AcDream.Core/Physics/CollisionTraversal.cs index a08a188b..d75f4bea 100644 --- a/src/AcDream.Core/Physics/CollisionTraversal.cs +++ b/src/AcDream.Core/Physics/CollisionTraversal.cs @@ -25,14 +25,9 @@ internal static class CollisionTraversal { if (UseFlat(cache)) { - // Availability is the authored CellStruct payload, not the - // containment root. Retail's loaded BSPTREE may have a null - // root; BSPNODE::point_inside_cell_bsp treats that base case as - // universally inside. A missing flat payload is still a broken - // production publication and must fail loudly. - _ = cell.FlatContainmentBsp ?? + FlatCellContainmentBsp flat = cell.FlatContainmentBsp ?? throw MissingFlat("cell containment"); - const bool flatAuthorityResult = true; + bool flatAuthorityResult = flat.RootIndex >= 0; CollisionShadowVerifier? flatShadow = cache.CollisionShadow; if (flatShadow is null || !flatShadow.TrySample(out long flatAuthoritySample)) @@ -43,7 +38,7 @@ internal static class CollisionTraversal flatShadow.BeginGraphPass(); try { - graphRefereeResult = true; + graphRefereeResult = cell.CellBSP?.Root is not null; } catch (Exception fault) { @@ -79,16 +74,15 @@ internal static class CollisionTraversal CollisionShadowVerifier? shadow = cache.CollisionShadow; if (shadow is null || !shadow.TrySample(out long sample)) - return true; + return cell.CellBSP?.Root is not null; bool flatResult = false; Exception? flatFault = null; shadow.BeginFlatPass(); try { - _ = cell.FlatContainmentBsp ?? - throw MissingFlat("cell containment"); - flatResult = true; + flatResult = (cell.FlatContainmentBsp ?? + throw MissingFlat("cell containment")).RootIndex >= 0; } catch (Exception fault) { @@ -99,7 +93,7 @@ internal static class CollisionTraversal shadow.EndFlatPass(); } - const bool graphResult = true; + bool graphResult = cell.CellBSP?.Root is not null; if (flatFault is null) { shadow.RecordBoolean( diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 43d2fbb4..6e8f019c 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -82,8 +82,8 @@ public sealed class PhysicsDataCache /// /// The unified cell graph (UCG): the active id->cell resolver and registry. - /// Populated unconditionally in so BSP-less - /// authored cells are registered too, and + /// Populated by for cells with valid + /// containment (including cells with no physics root), and /// consumed across the engine: the player render/lighting root /// (CellGraph.CurrCell, written at the player chokepoint /// PhysicsEngine.UpdatePlayerCurrCell and read by the renderer), the @@ -364,11 +364,10 @@ public sealed class PhysicsDataCache } /// - /// Extract and cache the authored CellStruct payload (indoor room - /// geometry), including cells whose physics or containment BSP has a null - /// root. Retail keeps those loaded cells distinct from an unavailable - /// visible-cell lookup; the null containment root is universally inside. - /// No-ops only when the id is already cached. + /// Extract and cache an authored CellStruct payload (indoor room geometry). + /// A missing physics root is valid (the cell can still own containment and + /// portals); a missing containment root is not a loadable CEnvCell and is + /// rejected before either the graph or collision record is published. /// public void CacheCellStruct( uint envCellId, @@ -398,11 +397,6 @@ public sealed class PhysicsDataCache !_flatEnvCell.ContainsKey(envCellId)) throw MissingPreparedCollision("EnvCell topology", envCellId); - if (preparedStructure is not null) - _flatCellStruct.TryAdd(envCellId, preparedStructure); - if (preparedTopology is not null) - _flatEnvCell.TryAdd(envCellId, preparedTopology); - if (_requirePreparedCollision) { CachePreparedCellStruct( @@ -416,7 +410,27 @@ public sealed class PhysicsDataCache return; } - // UCG Stage 1: register in the unified graph for every authored cell. + // CCellStruct::point_in_cell dereferences cell_bsp->root_node before + // entering BSPNODE::point_inside_cell_bsp. A null ROOT is therefore + // not the recursive missing-positive-child "inside" sentinel. The + // installed 2013 catalog contains zero such payloads; quarantine one + // rather than publishing a cell that claims the whole world. + if (cellStruct.CellBSP?.Root is null) + return; + + // A malformed optional prepared shadow must not attach to an otherwise + // valid raw cell. Production takes the prepared-only overload below. + if (preparedStructure?.ContainmentBsp.RootIndex < 0) + { + preparedStructure = null; + preparedTopology = null; + } + if (preparedStructure is not null) + _flatCellStruct.TryAdd(envCellId, preparedStructure); + if (preparedTopology is not null) + _flatEnvCell.TryAdd(envCellId, preparedTopology); + + // UCG Stage 1: register only a loadable authored cell. if (!CellGraph.Contains(envCellId)) { CellGraph.Add(UcgEnvCell.FromDat( @@ -618,6 +632,13 @@ public sealed class PhysicsDataCache FlatCellStructureCollisionAsset preparedStructure, FlatEnvCellTopology preparedTopology) { + // Same invariant as the raw loader. RootIndex -1 is the flattened + // encoding of a missing ROOT, not a recursive positive-child sentinel. + // Reject it atomically so graph, collision, and prepared caches agree + // that this cell is unavailable and a later valid hydration may retry. + if (preparedStructure.ContainmentBsp.RootIndex < 0) + return; + _flatCellStruct.TryAdd(envCellId, preparedStructure); _flatEnvCell.TryAdd(envCellId, preparedTopology); @@ -630,9 +651,8 @@ public sealed class PhysicsDataCache preparedTopology)); } - // The prepared structure itself is the loaded CellStruct payload. - // Empty physics and containment roots remain meaningful authored - // values; neither means that the cell is unavailable. + // Physics may be rootless even though the cell's containment and + // topology are valid; preserve that loaded, non-colliding cell. if (_cellStruct.ContainsKey(envCellId)) return; @@ -1023,9 +1043,9 @@ public sealed class CellPhysics /// (point-in-cell tests). Separate tree from /// (collision) and from the renderer's drawing-BSP. /// Source: cellStruct.CellBSP at cache time. - /// A nullable root is an authored, universally-inside containment tree. - /// Cell availability is represented by presence of this - /// record, not by root presence. + /// Root presence is required for a published cell. Missing positive + /// children inside a valid tree are the retail inside base case; a missing + /// root is rejected by . /// public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index d8eed135..48fc0951 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -7,9 +7,9 @@ namespace AcDream.Core.World.Cells; /// /// The unified cell graph: the active, authoritative id->cell resolver and registry. -/// Populated unconditionally from -/// (including -/// authored cells with null physics or containment roots) and consumed across +/// Populated from validated +/// payloads +/// (a physics root is optional; a containment root is required) and consumed across /// the engine: resolves any cell id, is /// the player render/lighting root, resolves the /// 3rd-person camera cell, and supplies the block-local diff --git a/src/AcDream.Core/World/Cells/EnvCell.cs b/src/AcDream.Core/World/Cells/EnvCell.cs index d18039c6..404fc483 100644 --- a/src/AcDream.Core/World/Cells/EnvCell.cs +++ b/src/AcDream.Core/World/Cells/EnvCell.cs @@ -11,9 +11,9 @@ namespace AcDream.Core.World.Cells; public sealed class EnvCell : ObjCell { /// - /// Cell-containment BSP (retail CellStruct.CellBSP). A present tree with a - /// null root is universally inside; an absent test/tooling payload uses the - /// legacy AABB fallback. + /// Cell-containment BSP (retail CellStruct.CellBSP). Production publication + /// requires a non-null root; prepared production uses + /// instead. /// public CellBSPTree? ContainmentBsp { get; } @@ -38,14 +38,18 @@ public sealed class EnvCell : ObjCell public override bool PointInCell(Vector3 worldPoint) { + // Retail CEnvCell::point_in_cell @ 0x0052C300 returns false before + // touching the CellStruct when this->portals is null. Installed data + // contains real zero-portal cells, so this guard is behavior-bearing. + if (Portals.Count == 0) + return false; + var local = Vector3.Transform(worldPoint, InverseWorldTransform); - if (FlatContainmentBsp is not null) + if (FlatContainmentBsp is { RootIndex: >= 0 }) return FlatBspQuery.PointInsideCellBsp(FlatContainmentBsp, local); - if (ContainmentBsp is not null) + if (ContainmentBsp?.Root is not null) return BSPQuery.PointInsideCellBsp(ContainmentBsp.Root, local); // BSPQuery.cs:1034 - return local.X >= LocalBoundsMin.X && local.X <= LocalBoundsMax.X - && local.Y >= LocalBoundsMin.Y && local.Y <= LocalBoundsMax.Y - && local.Z >= LocalBoundsMin.Z && local.Z <= LocalBoundsMax.Z; + return false; } /// diff --git a/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs b/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs index e61c0e50..5183e01b 100644 --- a/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs +++ b/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs @@ -57,6 +57,13 @@ public sealed class PlayerMovementPlacementTransactionTests Vertices = new Dictionary(), }, Polygons = new Dictionary(), + CellBSP = new CellBSPTree + { + Root = new CellBSPNode + { + Type = DatReaderWriter.Enums.BSPNodeType.Leaf, + }, + }, }; var envCell = new DatEnvCell { diff --git a/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs index a5dae8ce..128fc2c1 100644 --- a/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs @@ -71,10 +71,11 @@ public class CameraCollisionUpdateViewerTests var cache = new PhysicsDataCache(); var engine = new PhysicsEngine { DataCache = cache }; - // Feet cell: interior Z ≤ 94, in its stab list the room cell above. No portals - // (so the collision sweep cannot transit to the room — the start cell is decisive). + // Feet cell: interior Z ≤ 94, in its stab list the room cell above. + // The inert sentinel portal keeps the synthetic cell eligible for + // retail CEnvCell::point_in_cell without creating a usable transit. cache.RegisterCellStructForTest(FeetCellId, MakeCell(InteriorZAtMost(94f), new uint[] { RoomCellId })); - // Room cell: interior Z ≥ 94, no walls, no portals. + // Room cell: interior Z ≥ 94, no walls and no usable portals. cache.RegisterCellStructForTest(RoomCellId, MakeCell(InteriorZAtLeast(94f), Array.Empty())); var heights = new byte[81]; @@ -110,7 +111,7 @@ public class CameraCollisionUpdateViewerTests InverseWorldTransform = Matrix4x4.Identity, Resolved = new Dictionary(), CellBSP = new CellBSPTree { Root = cellBspRoot }, - Portals = Array.Empty(), + Portals = [new PortalInfo(0xFFFF, 0, 0)], PortalPolygons = new Dictionary(), VisibleCellIds = new HashSet(visibleCellIds), }; diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index 822bd86e..b05880e0 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -879,6 +879,13 @@ public sealed class LandblockPhysicsPublisherTests { [0] = new Polygon { VertexIds = [0, 1, 2] }, }, + CellBSP = new CellBSPTree + { + Root = new CellBSPNode + { + Type = DatReaderWriter.Enums.BSPNodeType.Leaf, + }, + }, }; var environment = new DatReaderWriter.DBObjs.Environment { diff --git a/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs index 274ad311..e0908912 100644 --- a/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using System.Numerics; using DatReaderWriter.Enums; @@ -63,6 +62,7 @@ public class BuildShadowCellSetTests private static CellPhysics MakeLeafCell(Matrix4x4 worldTransform) { Matrix4x4.Invert(worldTransform, out var inv); + var root = new CellBSPNode { Type = BSPNodeType.Leaf }; return new CellPhysics { WorldTransform = worldTransform, @@ -70,40 +70,25 @@ public class BuildShadowCellSetTests Resolved = new Dictionary(), CellBSP = new CellBSPTree { - Root = new CellBSPNode { Type = BSPNodeType.Leaf }, + Root = root, }, + FlatContainmentBsp = FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root), }; } - private static CellPhysics MakeNullRootCell(Matrix4x4 worldTransform) - { - Matrix4x4.Invert(worldTransform, out var inv); - return new CellPhysics - { - WorldTransform = worldTransform, - InverseWorldTransform = inv, - Resolved = new Dictionary(), - CellBSP = new CellBSPTree { Root = null }, - FlatContainmentBsp = new FlatCellContainmentBsp( - -1, - ImmutableArray.Empty), - }; - } - - private static CellPhysics MakeNullRootCellWithExteriorPortal( + private static CellPhysics MakeValidCellWithExteriorPortal( Matrix4x4 worldTransform) { Matrix4x4.Invert(worldTransform, out var inv); var portalPlane = new Plane(new Vector3(1f, 0f, 0f), -2.5f); + var root = new CellBSPNode { Type = BSPNodeType.Leaf }; return new CellPhysics { WorldTransform = worldTransform, InverseWorldTransform = inv, Resolved = new Dictionary(), - CellBSP = new CellBSPTree { Root = null }, - FlatContainmentBsp = new FlatCellContainmentBsp( - -1, - ImmutableArray.Empty), + CellBSP = new CellBSPTree { Root = root }, + FlatContainmentBsp = FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root), PortalPolygons = new Dictionary { [10] = new ResolvedPolygon @@ -191,7 +176,7 @@ public class BuildShadowCellSetTests [Theory] [InlineData(false)] [InlineData(true)] - public void IndoorSeed_RefloodsAfterNullRootPayloadHydrates( + public void IndoorSeed_RefloodsAfterValidPayloadHydrates( bool useFlat) { var cache = new PhysicsDataCache @@ -212,7 +197,7 @@ public class BuildShadowCellSetTests cache.RegisterCellStructForTest( IndoorSeed, - MakeNullRootCellWithExteriorPortal(Matrix4x4.Identity)); + MakeValidCellWithExteriorPortal(Matrix4x4.Identity)); IReadOnlyList hydrated = CellTransit.BuildShadowCellSet( cache, IndoorSeed, @@ -322,7 +307,7 @@ public class BuildShadowCellSetTests }; cache.RegisterCellStructForTest( NeighborCell, - MakeNullRootCell(Matrix4x4.Identity)); + MakeLeafCell(Matrix4x4.Identity)); var sphere = One(new Vector3(12f, 12f, 0f), 0.5f); IReadOnlyList seeded = CellTransit.BuildShadowCellSet( @@ -365,6 +350,65 @@ public class BuildShadowCellSetTests Assert.Contains(NeighborCell, hydrated); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LoadedSeed_AbsentAdjacentLandcell_SkipsStaleBuildingUntilAdjacentHydrates( + bool useFlat) + { + const uint seedCell = 0xA9B4_0031u; + const uint adjacentCell = 0xA9B3_0038u; + const uint interiorCell = 0xA9B3_0100u; + var cache = new PhysicsDataCache + { + CollisionTraversalMode = useFlat + ? CollisionTraversalMode.Flat + : CollisionTraversalMode.Graph, + }; + cache.CellGraph.RegisterTerrain( + 0xA9B4_0000u, + new TerrainSurface(new byte[81], new float[256]), + Vector3.Zero); + cache.RegisterCellStructForTest( + interiorCell, + MakeLeafCell(Matrix4x4.Identity)); + cache.RegisterBuildingForTest(adjacentCell, new BuildingPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Portals = + [ + new BldPortalInfo(interiorCell, otherPortalId: 0, flags: 0), + ], + }); + Sphere[] sphere = One(new Vector3(150f, 0.2f, 0f), 0.5f); + + IReadOnlyList unavailable = CellTransit.BuildShadowCellSet( + cache, + seedCell, + sphere, + 1, + isStatic: false); + + Assert.Contains(seedCell, unavailable); + Assert.Contains(adjacentCell, unavailable); + Assert.DoesNotContain(interiorCell, unavailable); + + cache.CellGraph.RegisterTerrain( + 0xA9B3_0000u, + new TerrainSurface(new byte[81], new float[256]), + new Vector3(0f, -192f, 0f)); + IReadOnlyList hydrated = CellTransit.BuildShadowCellSet( + cache, + seedCell, + sphere, + 1, + isStatic: false); + + Assert.Contains(adjacentCell, hydrated); + Assert.Contains(interiorCell, hydrated); + } + // ── Exterior straddle from an indoor seed ────────────────────────── [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs b/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs index 89680918..802e1eef 100644 --- a/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs @@ -27,6 +27,10 @@ public class CellGraphMembershipTests VertexArray = new VertexArray { Vertices = new Dictionary() }, Polygons = new Dictionary(), PhysicsBSP = null, + CellBSP = new CellBSPTree + { + Root = new CellBSPNode { Type = DatReaderWriter.Enums.BSPNodeType.Leaf }, + }, }; var dat = new DatEnvCell { diff --git a/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs b/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs index 843c0512..80b0a031 100644 --- a/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.World.Cells; +using DatReaderWriter.Enums; using DatReaderWriter.Types; using Xunit; using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; @@ -11,7 +12,7 @@ namespace AcDream.Core.Tests.Physics; public class CellGraphPopulationTests { [Fact] - public void CacheCellStruct_PublishesLoadedCell_WhenPhysicsAndContainmentRootsAreNull() + public void CacheCellStruct_RejectsRootlessContainment_ThenAllowsValidRetry() { var cache = new PhysicsDataCache(); var cellStruct = new CellStruct @@ -29,13 +30,16 @@ public class CellGraphPopulationTests cache.CacheCellStruct(0xA9B40174u, dat, cellStruct, Matrix4x4.Identity); + Assert.Null(cache.GetCellStruct(0xA9B40174u)); + Assert.Null(cache.CellGraph.GetVisible(0xA9B40174u)); + + cellStruct.CellBSP.Root = new CellBSPNode { Type = BSPNodeType.Leaf }; + cache.CacheCellStruct(0xA9B40174u, dat, cellStruct, Matrix4x4.Identity); + CellPhysics loaded = Assert.IsType( cache.GetCellStruct(0xA9B40174u)); + Assert.False(CollisionTraversal.HasPhysics(cache, loaded)); Assert.True(CollisionTraversal.HasCellContainment(cache, loaded)); - Assert.True(CollisionTraversal.PointInsideCell( - cache, - loaded, - new Vector3(10_000f, -10_000f, 500f))); Assert.NotNull(cache.CellGraph.GetVisible(0xA9B40174u)); Assert.IsType(cache.CellGraph.GetVisible(0xA9B40174u)); } diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs index 265fc0d6..d6f4c478 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs @@ -8,11 +8,11 @@ namespace AcDream.Core.Tests.Physics; public class CellTransitCheckBuildingTransitTests { [Fact] - public void BuildingPortalWithLoadedNullRoot_CellIsAdmitted() + public void BuildingPortalWithRootlessContainment_CellIsRejected() { - // Retail separates an unavailable CEnvCell lookup from an authored - // CellStruct whose cell_bsp root is null. The latter is loaded, and - // the null-root sphere query is the universal-inside base case. + // Retail dereferences CCellStruct.cell_bsp->root_node before calling + // the recursive query. A missing positive child means inside; a + // missing root is not a valid loaded CEnvCell. // Building at world origin. One portal to interior cell 0xA9B40100. var building = new BuildingPhysics @@ -28,7 +28,8 @@ public class CellTransitCheckBuildingTransitTests }, }; - // Interior cell with an authored null containment root. + // Rootless fixture bypasses the production quarantine to verify that + // the traversal boundary still rejects it safely. var interiorCell = new CellPhysics { WorldTransform = Matrix4x4.Identity, @@ -47,7 +48,7 @@ public class CellTransitCheckBuildingTransitTests sphereRadius: 0.5f, candidates); - Assert.Contains(0xA9B40100u, candidates); + Assert.Empty(candidates); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index dd849175..80dd9544 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -172,7 +172,7 @@ public class CellTransitFindCellSetTests // ────────────────────────────────────────────────────────────────── [Fact] - public void OutdoorSeed_CrossesLandblockBoundary_South() + public void OutdoorSeed_CrossesLandblockBoundary_South_AfterDestinationHydrates() { // The #106 acceptance golden: walking south out of A9B4, the outdoor // cell must advance to the southern neighbour block's cell. Origin of @@ -183,14 +183,25 @@ public class CellTransitFindCellSetTests var cache = new PhysicsDataCache(); cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); - uint containing = CellTransit.FindCellSet( + uint unavailable = CellTransit.FindCellSet( cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f, currentCellId: 0xA9B40031u, out var cellSet); - Assert.Equal(0xA9B30038u, containing); + Assert.Equal(0xA9B40031u, unavailable); Assert.Contains(0xA9B30038u, cellSet); Assert.Contains(0xA9B40031u, cellSet); // +Y neighbour still in the set + + cache.CellGraph.RegisterTerrain( + 0xA9B30000u, + new TerrainSurface(new byte[81], new float[256]), + new Vector3(0f, -192f, 0f)); + uint containing = CellTransit.FindCellSet( + cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f, + currentCellId: 0xA9B40031u, + out _); + + Assert.Equal(0xA9B30038u, containing); } [Fact] @@ -227,6 +238,10 @@ public class CellTransitFindCellSetTests 0xA9B30000u, new TerrainSurface(new byte[81], new float[256]), new Vector3(0f, -192f, 0f)); + cache.CellGraph.RegisterTerrain( + 0xA9B40000u, + new TerrainSurface(new byte[81], new float[256]), + Vector3.Zero); uint containing = CellTransit.FindCellSet( cache, new Vector3(150f, 1f, 0f), sphereRadius: 0.5f, @@ -315,27 +330,29 @@ public class CellTransitFindCellSetTests } [Fact] - public void IndoorSeed_LoadedNullRoot_IsUniversallyInside_StaysCurrent() + public void FindVisibleChildCell_RootlessContainment_IsUnavailable() { - // Retail distinguishes a failed cell lookup from a loaded CellStruct - // whose containment root is null. The latter is the BSP query's - // universally-inside base case, so the current cell wins immediately. + // Bypass production cache validation to pin the traversal boundary: + // a rootless fixture cannot claim a point even though the recursive + // helper's missing-positive-child base case returns inside. Matrix4x4.Invert(Matrix4x4.Identity, out var inv); var cellNoBsp = new CellPhysics { WorldTransform = Matrix4x4.Identity, InverseWorldTransform = inv, Resolved = new Dictionary(), + Portals = [new PortalInfo(0x0101, 0, 0)], }; var cache = new PhysicsDataCache(); cache.RegisterCellStructForTest(0xA9B40150u, cellNoBsp); - uint containing = CellTransit.FindCellSet( - cache, new Vector3(-10f, 12f, 0f), sphereRadius: 0.5f, - currentCellId: 0xA9B40150u, - out _); + uint containing = CellTransit.FindVisibleChildCell( + cache, + 0xA9B40150u, + new Vector3(-10f, 12f, 0f), + useStabList: true); - Assert.Equal(0xA9B40150u, containing); + Assert.Equal(0u, containing); } // ────────────────────────────────────────────────────────────────── diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs index a5613fb6..88afa121 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs @@ -106,7 +106,9 @@ public class CellTransitFindVisibleChildCellTests InverseWorldTransform = Matrix4x4.Identity, Resolved = new Dictionary(), CellBSP = new CellBSPTree { Root = cellBspRoot }, - Portals = Array.Empty(), + // Keep this synthetic cell eligible for retail point_in_cell; the + // test varies containment and stab-list behavior, not portal absence. + Portals = [new PortalInfo(0xFFFF, 0, 0)], PortalPolygons = new Dictionary(), VisibleCellIds = new HashSet(visibleCellIds), }; diff --git a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs index e429f100..8cb3606a 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs @@ -134,7 +134,10 @@ public class Issue133DungeonTeleportPrefixTests // Leaf root → point_in_cell true for any point → AdjustPosition // validates the claim (found=true, cell unchanged). CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } }, - Portals = Array.Empty(), + // Retail CEnvCell::point_in_cell rejects cells with no portal + // array before consulting the containment BSP. The synthetic + // cell is intended to exercise an eligible loaded dungeon cell. + Portals = [new PortalInfo(0xFFFF, 0, 0)], PortalPolygons = new Dictionary(), VisibleCellIds = new HashSet(), }; diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs index 45b95f5c..69f9fa95 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs @@ -107,8 +107,13 @@ public sealed class PhysicsDataCacheProductionTests var structure = new FlatCellStructureCollisionAsset( physicsBsp, new FlatCellContainmentBsp( - -1, - ImmutableArray.Empty), + 0, + ImmutableArray.Create(new FlatCellBspNode( + BSPNodeType.Leaf, + default, + -1, + -1, + 0))), FlatPolygonTable.Empty); var topology = new FlatEnvCellTopology( ImmutableArray.Empty, @@ -162,7 +167,7 @@ public sealed class PhysicsDataCacheProductionTests } [Fact] - public void ProductionCellPublication_PreservesLoadedCellWithEmptyRoots() + public void ProductionCellPublication_RejectsRootlessContainment_ThenAllowsValidRetry() { const uint cellId = 0xA9B4_0174u; PhysicsDataCache cache = PhysicsDataCache.CreateProduction(); @@ -190,18 +195,33 @@ public sealed class PhysicsDataCacheProductionTests structure, topology); - CellPhysics loaded = Assert.IsType( - cache.GetCellStruct(cellId)); + Assert.Null(cache.GetCellStruct(cellId)); + Assert.Null(cache.CellGraph.GetVisible(cellId)); + Assert.Equal(0, cache.FlatCellStructCount); + Assert.Equal(0, cache.FlatEnvCellCount); + + var validContainment = new FlatCellContainmentBsp( + 0, + ImmutableArray.Create(new FlatCellBspNode( + BSPNodeType.Leaf, + default, + -1, + -1, + 0))); + cache.CacheCellStruct( + cellId, + new EnvCell(), + Matrix4x4.Identity, + new FlatCellStructureCollisionAsset( + emptyPhysics, + validContainment, + FlatPolygonTable.Empty), + topology); + + CellPhysics loaded = Assert.IsType(cache.GetCellStruct(cellId)); Assert.False(CollisionTraversal.HasPhysics(cache, loaded)); Assert.True(CollisionTraversal.HasCellContainment(cache, loaded)); - Assert.True(CollisionTraversal.PointInsideCell( - cache, - loaded, - new Vector3(10_000f, -10_000f, 500f))); - var graphCell = Assert.IsType( - cache.CellGraph.GetVisible(cellId)); - Assert.True(graphCell.PointInCell( - new Vector3(10_000f, -10_000f, 500f))); + Assert.NotNull(cache.CellGraph.GetVisible(cellId)); Assert.Equal(1, cache.CellStructCount); Assert.Equal(1, cache.FlatCellStructCount); Assert.Equal(0, cache.GraphCellStructCount); diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs index 631ac435..2966fb51 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs @@ -104,7 +104,9 @@ public class PhysicsEngineAdjustPositionTests InverseWorldTransform = Matrix4x4.Identity, Resolved = new Dictionary(), CellBSP = new CellBSPTree { Root = cellBspRoot }, - Portals = Array.Empty(), + // Keep this synthetic cell eligible for retail point_in_cell; the + // test varies containment and adjustment behavior, not portal absence. + Portals = [new PortalInfo(0xFFFF, 0, 0)], PortalPolygons = new Dictionary(), VisibleCellIds = new HashSet(visibleCellIds), }; diff --git a/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs index 783d24a2..fe318b36 100644 --- a/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs @@ -418,6 +418,18 @@ public sealed class Ts4ProductionQuantumConformanceTests Array.Empty(), 0f, 0f); + // The authored roof and the 90-tick path cross the west edge of the + // anchor block. Retail's CELLARRAY retains that candidate id but only + // dispatches/picks it when GetVisible resolves the adjacent CLandCell. + // Hydrate the west neighbor so this fixture continues to measure the + // collision response rather than unavailable-streaming behavior. + engine.AddLandblock( + 0xA8B40000u, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + -192f, + 0f); engine.ShadowObjects.Register( GfxId, GfxId, diff --git a/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs b/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs index 2b757174..c12129ed 100644 --- a/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs @@ -18,7 +18,10 @@ using System.Collections.Generic; using System.Numerics; using AcDream.Core.World.Cells; +using DatReaderWriter.Enums; using Xunit; +using CellBSPNode = DatReaderWriter.Types.CellBSPNode; +using CellBSPTree = DatReaderWriter.Types.CellBSPTree; namespace AcDream.Core.Tests.Rendering; @@ -29,8 +32,8 @@ public class CellGraphRootTests // ------------------------------------------------------------------ /// - /// Synthetic EnvCell with an identity transform and axis-aligned bounds so - /// PointInCell returns true for points inside [min, max]. + /// Synthetic EnvCell with an authored six-plane containment BSP for + /// [min,max] and one portal so retail CEnvCell::point_in_cell is eligible. /// seenOutside = false → sealed dungeon; true → building interior/exterior. /// private static EnvCell MakeEnvCell(uint id, Vector3 min, Vector3 max, bool seenOutside = false) @@ -39,10 +42,10 @@ public class CellGraphRootTests Matrix4x4.Identity, Matrix4x4.Identity, min, max, - portals: new List(), + portals: new List { new(0xFFFFu, 0, 0, 0) }, stabList: new List(), seenOutside: seenOutside, - containmentBsp: null); + containmentBsp: new CellBSPTree { Root = BoundsBsp(min, max) }); /// /// EnvCell with an explicit stab list (used by FindVisibleChildCell tests). @@ -54,10 +57,30 @@ public class CellGraphRootTests Matrix4x4.Identity, Matrix4x4.Identity, min, max, - portals: new List(), + portals: new List { new(0xFFFFu, 0, 0, 0) }, stabList: stabList, seenOutside: seenOutside, - containmentBsp: null); + containmentBsp: new CellBSPTree { Root = BoundsBsp(min, max) }); + + private static CellBSPNode BoundsBsp(Vector3 min, Vector3 max) + { + var leaf = new CellBSPNode { Type = BSPNodeType.Leaf }; + CellBSPNode Add(Plane plane, CellBSPNode positive) => new() + { + Type = BSPNodeType.BPIn, + SplittingPlane = plane, + PosNode = positive, + }; + + CellBSPNode root = leaf; + root = Add(new Plane(-Vector3.UnitZ, max.Z), root); + root = Add(new Plane(Vector3.UnitZ, -min.Z), root); + root = Add(new Plane(-Vector3.UnitY, max.Y), root); + root = Add(new Plane(Vector3.UnitY, -min.Y), root); + root = Add(new Plane(-Vector3.UnitX, max.X), root); + root = Add(new Plane(Vector3.UnitX, -min.X), root); + return root; + } // ------------------------------------------------------------------ // Predicate helpers — mirror the formulas in GameWindow.OnRender (Stage 3) diff --git a/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs b/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs index e099c2ae..8e82ee59 100644 --- a/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs +++ b/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs @@ -10,46 +10,67 @@ namespace AcDream.Core.Tests.World.Cells; public class EnvCellTests { - private static EnvCell Make(Vector3 min, Vector3 max, Matrix4x4? transform = null) + private static readonly UcgCellPortal[] OnePortal = + [new UcgCellPortal(0xA9B4_0175u, 0, 0, 0)]; + + private static EnvCell Make( + CellBSPNode? root, + bool prepared, + bool hasPortals, + Matrix4x4? transform = null) { var t = transform ?? Matrix4x4.Identity; Matrix4x4.Invert(t, out var inv); - return new EnvCell(0xA9B40174u, t, inv, min, max, - System.Array.Empty(), System.Array.Empty(), - seenOutside: false, containmentBsp: null); - } - - [Fact] - public void PointInCell_NullBsp_Aabb_InsideIsTrue() - => Assert.True(Make(new Vector3(0,0,0), new Vector3(10,10,10)).PointInCell(new Vector3(5,5,5))); - - [Fact] - public void PointInCell_NullBsp_Aabb_OutsideIsFalse() - => Assert.False(Make(new Vector3(0,0,0), new Vector3(10,10,10)).PointInCell(new Vector3(20,5,5))); - - [Fact] - public void PointInCell_LoadedNullRoot_IsUniversallyInside() - { - var cell = new EnvCell( - 0xA9B4_0174u, - Matrix4x4.Identity, - Matrix4x4.Identity, - Vector3.Zero, + return new EnvCell( + 0xA9B40174u, + t, + inv, + -Vector3.One, Vector3.One, - Array.Empty(), + hasPortals ? OnePortal : Array.Empty(), Array.Empty(), seenOutside: false, - containmentBsp: new CellBSPTree { Root = null }); + containmentBsp: new CellBSPTree { Root = root }, + flatContainmentBsp: prepared + ? FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root) + : null); + } - Assert.True(cell.PointInCell(new Vector3(10_000f, -10_000f, 500f))); + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PointInCell_ZeroPortals_RejectsBeforeContainment(bool prepared) + { + var root = new CellBSPNode { Type = BSPNodeType.Leaf }; + + Assert.False(Make(root, prepared, hasPortals: false).PointInCell(Vector3.Zero)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PointInCell_RootlessContainment_IsRejected(bool prepared) + { + Assert.False(Make(null, prepared, hasPortals: true).PointInCell(Vector3.Zero)); } [Fact] public void PointInCell_TransformsWorldToLocalBeforeTesting() { - var c = Make(new Vector3(0,0,0), new Vector3(10,10,10), Matrix4x4.CreateTranslation(100,0,0)); - Assert.True(c.PointInCell(new Vector3(105,5,5))); - Assert.False(c.PointInCell(new Vector3(5,5,5))); + var root = new CellBSPNode + { + Type = BSPNodeType.BPIn, + SplittingPlane = new Plane(Vector3.UnitX, 0f), + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + var cell = Make( + root, + prepared: false, + hasPortals: true, + transform: Matrix4x4.CreateTranslation(100f, 0f, 0f)); + + Assert.True(cell.PointInCell(new Vector3(105f, 0f, 0f))); + Assert.False(cell.PointInCell(new Vector3(95f, 0f, 0f))); } [Fact] @@ -74,7 +95,7 @@ public class EnvCellTests Matrix4x4.Identity, -Vector3.One, Vector3.One, - Array.Empty(), + OnePortal, Array.Empty(), seenOutside: false, containmentBsp: new CellBSPTree { Root = graphRoot }, From be94bc9b0691be7cac56acbf1d2d5c591efe87ba Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 15:19:25 +0200 Subject: [PATCH 22/73] fix(physics): activate collision generations atomically --- docs/architecture/acdream-architecture.md | 17 +- .../retail-divergence-register.md | 2 +- .../2026-07-31-atomic-collision-generation.md | 72 ++++ memory/project_collision_port.md | 8 + .../Streaming/LandblockPhysicsPublisher.cs | 103 ++++-- .../LandblockPresentationPipeline.cs | 12 + .../Streaming/StreamingController.cs | 34 ++ src/AcDream.Core/AcDream.Core.csproj | 3 + src/AcDream.Core/Physics/PhysicsDataCache.cs | 144 +++++++- src/AcDream.Core/Physics/PhysicsEngine.cs | 95 ++++- .../Physics/ShadowObjectRegistry.cs | 341 +++++++++++++++++- src/AcDream.Core/World/Cells/CellGraph.cs | 72 ++++ .../Hosting/HeadlessSessionWorldProjection.cs | 33 +- src/AcDream.Runtime/AcDream.Runtime.csproj | 2 + .../Physics/RuntimePhysicsState.cs | 277 +++++++++++++- .../Runtime/RuntimePhysicsOwnershipTests.cs | 6 +- .../LandblockPhysicsPublisherTests.cs | 42 +++ .../Physics/RuntimePhysicsStateTests.cs | 219 ++++++++++- 18 files changed, 1402 insertions(+), 80 deletions(-) create mode 100644 docs/research/2026-07-31-atomic-collision-generation.md diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4e892324..d7b4cffb 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -485,8 +485,21 @@ What exists and is active: collision assets loaded from the validated prepared package. Production retains no parsed DAT collision graph; graph construction is restricted to bake/equivalence tools and explicit test oracles. -- `ShadowObjectRegistry` gives movement a broadphase over nearby objects and - buildings. +- Landblock collision activation is generation-owned by + `RuntimePhysicsState`. Graphical and no-window hosts populate a private + `PreparedLandblockCollisionGeneration` over bounded cursors; its cache, + `CellGraph`, engine landblock, buildings, static shadows, and dynamic-owner + refloods are never visible through the borrowed live engine. Runtime validates + the exact admission plus every affected dynamic shadow-owner version, seals + the complete replacement, and commits it synchronously on the one update + thread before emitting `CollisionGenerationCommitted`. A moving, spawned, or + deleted owner makes the generation not-ready and returns only that dirty owner + to the cursor-budgeted refresh phase. Thus readers see the complete old + generation or complete new generation, never a mixed cell/cache/shadow world. +- `ShadowObjectRegistry` gives movement a per-cell broadphase over nearby + objects and buildings. Streaming reflood is structurally part of the Runtime + collision-generation commit; there is no independent post-publication + reflood suffix. - `TerrainSurface` uses triangle-aware terrain contact; older "bilinear terrain Z" descriptions are historical B.3 language, not current architecture. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 5d243c77..c531da62 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -89,7 +89,7 @@ AD-53..AD-55 (Campaign P response-layer findings). | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | -| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 | +| ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus retained dynamic-owner `recalc_cross_cells` is one Runtime-owned collision generation. App and Headless build only an off-side `PreparedLandblockCollisionGeneration`; the complete previous generation remains queryable until the one update-thread commit. Every affected dynamic owner carries an exact mutation version. Movement, spawn, or deletion during staging rejects activation and returns only dirty owners to a bounded refresh cursor; a stale admission/recenter/cancellation can dispose only its private generation. The commit installs the precomputed cell rows before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | | ~~AD-11~~ | **RETIRED 2026-07-23** — the matching binary disproved the old nonzero interpretation: `ItemUses::IsUseable` executes `not bitfield; and eax,1`, so absent/reset zero is usable and only `USEABLE_NO` disables use. Toolbar, item policy, and world interaction now share that exact Core predicate. | `src/AcDream.Core/Items/ClientObject.cs` (`ItemUseability.IsUseable`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs` | — | — | `ItemUses::IsUseable @ 0x004FCCC0`; matching v11.4186 instructions recorded in `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | diff --git a/docs/research/2026-07-31-atomic-collision-generation.md b/docs/research/2026-07-31-atomic-collision-generation.md new file mode 100644 index 00000000..abdeb784 --- /dev/null +++ b/docs/research/2026-07-31-atomic-collision-generation.md @@ -0,0 +1,72 @@ +# Atomic collision-generation activation (Slice 3B) + +## Retail anchor + +Retail hydrates a cell synchronously. `CObjCell::init_objects` +(`0x0052B420`) visits objects associated with that cell and invokes +`CPhysicsObj::recalc_cross_cells` (`0x00515A30`). The final position path also +replaces shadows as one `SetPositionInternal` operation (`0x00515330`). Retail +therefore never exposes a world where the new cell exists but the objects that +overlap it still have their old cross-cell set. + +Acdream streams a landblock over several update frames. Literal per-cell +mutation during those frames was not equivalent: the active `PhysicsDataCache`, +`CellGraph`, `PhysicsEngine`, buildings, static shadows, and retained-object +refloods changed at different cursors. Collision queries could observe a mixed +generation, and correctness depended on a later optional landblock callback. + +## Ported adaptation + +The asynchronous unit is now one Runtime-owned collision generation: + +1. `BeginCollisionAdmission` issues the exact Runtime/landblock generation. +2. `PrepareCollisionGeneration` clones the bounded resident spatial records + into a private cache, graph, engine, and shadow registry. Global immutable + GfxObj/Setup catalogs are not copied; the accepted build's exact closure is + populated by the existing cursors. +3. App and Headless publish terrain, EnvCells, topology, buildings, prepared + collision assets, static owners, and retained dynamic-owner cell sets only + into that private generation. +4. Each dynamic owner refresh captures its exact `ShadowObjectRegistry` + mutation version. Movement, state/payload change, spawn, suspension, or + deletion changes that version. +5. `CommitCollisionGeneration` validates the admission, exact affected-owner + set, and all captured versions. If any owner is dirty, it returns the sorted + dirty IDs without touching the active world; App advances those IDs through + its existing work meter and retries. +6. Once fresh, Runtime precomputes the replacement arrays and synchronously + replaces the landblock's cache/graph/engine/building/static and dynamic + shadow state on the same update thread. Only after the complete replacement + does it emit `CollisionGenerationCommitted` and a ready acknowledgement. + +The stable borrowed `PhysicsEngine` and `PhysicsDataCache` object identities do +not change. Presentation and no-window hosts use the same Runtime transaction. +Network workers still enqueue immutable messages and cannot mutate collision or +shadow state. + +## Failure and lifetime rules + +- A newer admission invalidates an older prepared generation. +- Demotion, withdrawal, reset, and disposal invalidate the admission before + changing the active generation. +- Disposing a stale/cancelled prepared generation clears only its private + engine/cache/shadows. +- The prior complete generation remains queryable throughout preparation. +- The commit notification is the future lost-cell-registry seam. Slice 3B does + not implement `GotoLostCell` or change `SetPosition` recovery behavior. + +## Deterministic evidence + +The focused Runtime/App tests pin: + +- previous terrain/cells/buildings/statics remain visible until commit; +- exactly one notification after a successful complete activation; +- stale admission replacement has no active-world side effect; +- movement during staging rejects, refreshes only the dirty owner, and then + installs its latest cell set; +- spawn and deletion during staging both reject stale activation; +- graphical and no-window publishers use the same Runtime transaction; +- removal and terminal teardown converge the active ownership ledger. + +This retires divergence row AD-6. The remaining lost-cell state-machine work is +deliberately outside this slice. diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md index 1866b886..1da3577c 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -10,6 +10,14 @@ as "delete everything and start over." A partial retail transition port exists: - `TransitionTypes` carries the active `SpherePath`, `CollisionInfo`, transition, step, contact, and partial slide logic. - `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data. +- Landblock streaming never edits the live collision world incrementally. + `PreparedLandblockCollisionGeneration` owns an off-side cache, CellGraph, + engine snapshot, buildings, statics, and versioned dynamic-owner refloods; + `RuntimePhysicsState.CommitCollisionGeneration` validates freshness and + activates the complete generation on the single update thread. A dirty live + owner returns to the App cursor for refresh. The old generation remains + queryable until that commit. See + `docs/research/2026-07-31-atomic-collision-generation.md`. - `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects. - `TerrainSurface` uses triangle-aware terrain contact. diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 169d912c..ffccba70 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -12,7 +12,7 @@ namespace AcDream.App.Streaming; /// publication. The render publisher commits buildings and EnvCells between /// these stages without recomputing the captured origin. /// -public sealed class LandblockPhysicsPublication +public sealed class LandblockPhysicsPublication : IDisposable { internal LandblockPhysicsPublication( object owner, @@ -21,7 +21,8 @@ public sealed class LandblockPhysicsPublication uint currentCellId, BuildingInfo[] buildings, uint[] priorStaticOwnerIds, - RuntimeCollisionAdmission collisionAdmission) + RuntimeCollisionAdmission collisionAdmission, + PreparedLandblockCollisionGeneration preparedGeneration) { Owner = owner; Build = build; @@ -30,6 +31,7 @@ public sealed class LandblockPhysicsPublication Buildings = buildings; PriorStaticOwnerIds = priorStaticOwnerIds; CollisionAdmission = collisionAdmission; + PreparedGeneration = preparedGeneration; } internal object Owner { get; } @@ -38,6 +40,9 @@ public sealed class LandblockPhysicsPublication internal BuildingInfo[] Buildings { get; } internal uint[] PriorStaticOwnerIds { get; } internal RuntimeCollisionAdmission CollisionAdmission { get; } + internal PreparedLandblockCollisionGeneration PreparedGeneration { get; } + internal PhysicsDataCache StagingCache => PreparedGeneration.DataCache; + internal PhysicsEngine StagingEngine => PreparedGeneration.Engine; internal SortedSet GfxObjectIdSet { get; } = new(); internal uint[] GfxObjectIds { get; set; } = Array.Empty(); internal int PreparationCursor { get; set; } @@ -64,6 +69,12 @@ public sealed class LandblockPhysicsPublication internal bool BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } + public void Dispose() + { + if (!CompletionCommitted) + PreparedGeneration.Dispose(); + } + public uint LandblockId => Build.Landblock.LandblockId; public Vector3 Origin { get; } } @@ -204,6 +215,8 @@ public sealed class LandblockPhysicsPublisher build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty; BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray() ?? Array.Empty(); + RuntimeCollisionAdmission collisionAdmission = + _physics.BeginCollisionAdmission(build.Landblock.LandblockId); var publication = new LandblockPhysicsPublication( _receiptOwner, build, @@ -212,11 +225,14 @@ public sealed class LandblockPhysicsPublisher buildings, _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( build.Landblock.LandblockId), - _physics.BeginCollisionAdmission( - build.Landblock.LandblockId)); + collisionAdmission, + _physics.PrepareCollisionGeneration(collisionAdmission)); publication.SetupObjectIds = build.Collisions is { } collisions ? [.. collisions.SetupIds] : datBundle.Setups.Keys.Order().ToArray(); + publication.PreparedGeneration.SetAssetClosure( + publication.GfxObjectIds, + publication.SetupObjectIds); return publication; } @@ -237,6 +253,9 @@ public sealed class LandblockPhysicsPublisher if (publication.Build.Collisions is { } collisions) { publication.GfxObjectIds = [.. collisions.GfxObjIds]; + publication.PreparedGeneration.SetAssetClosure( + publication.GfxObjectIds, + publication.SetupObjectIds); publication.PreparationCursor = entities.Count; publication.PreparationCommitted = true; return true; @@ -256,6 +275,9 @@ public sealed class LandblockPhysicsPublisher } publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray(); + publication.PreparedGeneration.SetAssetClosure( + publication.GfxObjectIds, + publication.SetupObjectIds); publication.PreparationCommitted = true; return true; } @@ -299,9 +321,9 @@ public sealed class LandblockPhysicsPublisher // CacheCellStruct/CacheBuilding use first-wins semantics within one // publication, so the replacement pass starts with one exact // landblock-scoped withdrawal. - _physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId); - _physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId); - _physicsDataCache.CellGraph.RemoveEnvCellsForLandblock( + publication.StagingCache.RemoveCellsForLandblock(landblock.LandblockId); + publication.StagingCache.RemoveBuildingsForLandblock(landblock.LandblockId); + publication.StagingCache.CellGraph.RemoveEnvCellsForLandblock( landblock.LandblockId); publication.PriorCacheRemoved = true; } @@ -330,6 +352,7 @@ public sealed class LandblockPhysicsPublisher PublishBuilding( landblock, datBundle, + publication.StagingCache, publication.TerrainSurface, origin, publication.Buildings[publication.BuildingCursor]); @@ -337,8 +360,9 @@ public sealed class LandblockPhysicsPublisher } else if (!publication.BaseCommitted) { - _physics.AdmitCollisionAssets( + _physics.StageCollisionAssets( publication.CollisionAdmission, + publication.PreparedGeneration, new RuntimeLandblockCollisionAssets( landblock.LandblockId, publication.TerrainSurface, @@ -409,7 +433,7 @@ public sealed class LandblockPhysicsPublisher gfxObjectId, out FlatGfxObjCollisionAsset? prepared) == true) { - _physicsDataCache.CacheGfxObj(gfxObjectId, prepared); + publication.StagingCache.CacheGfxObj(gfxObjectId, prepared); } else if (datBundle.GfxObjs.TryGetValue( gfxObjectId, @@ -417,7 +441,7 @@ public sealed class LandblockPhysicsPublisher { // Graph-oracle fixture seam. Production near builds always // carry the strict prepared closure. - _physicsDataCache.CacheGfxObj(gfxObjectId, source); + publication.StagingCache.CacheGfxObj(gfxObjectId, source); } publication.GfxCursor++; _gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted; @@ -430,19 +454,19 @@ public sealed class LandblockPhysicsPublisher setupId, out FlatSetupCollision? prepared) == true) { - _physicsDataCache.CacheSetup(setupId, prepared); + publication.StagingCache.CacheSetup(setupId, prepared); } else if (datBundle.Setups.TryGetValue(setupId, out var source)) { // Graph-oracle fixture seam only. - _physicsDataCache.CacheSetup(setupId, source); + publication.StagingCache.CacheSetup(setupId, source); } publication.SetupCursor++; } else if (publication.PriorStaticCursor < publication.PriorStaticOwnerIds.Length) { - _physicsEngine.ShadowObjects.DeregisterStaticOwnerForLandblock( + publication.StagingEngine.ShadowObjects.DeregisterStaticOwnerForLandblock( publication.PriorStaticOwnerIds[ publication.PriorStaticCursor], landblock.LandblockId); @@ -458,15 +482,17 @@ public sealed class LandblockPhysicsPublisher else if (publication.RefloodOwnerIds is null) { publication.RefloodOwnerIds = - _physicsEngine.ShadowObjects - .CaptureRefloodOwnersForLandblock(landblock.LandblockId); + _physics.CaptureCollisionDynamicOwners( + publication.CollisionAdmission, + publication.PreparedGeneration); } else if (publication.RefloodCursor < publication.RefloodOwnerIds.Length) { - _physicsEngine.ShadowObjects.RefloodOwnerForLandblock( - publication.RefloodOwnerIds[publication.RefloodCursor], - landblock.LandblockId); + _physics.RefreshCollisionDynamicOwner( + publication.CollisionAdmission, + publication.PreparedGeneration, + publication.RefloodOwnerIds[publication.RefloodCursor]); publication.RefloodCursor++; } else if (!publication.RefloodCommitted) @@ -478,14 +504,24 @@ public sealed class LandblockPhysicsPublisher $"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " + $"(outdoorNone={publication.NoCollisionCount})"); } - LogMissingSceneryBounds(landblock); - _refloodCount++; + LogMissingSceneryBounds(landblock, publication.StagingCache); publication.RefloodCommitted = true; } else { - _physics.CompleteCollisionAdmission( - publication.CollisionAdmission); + RuntimeCollisionGenerationCommit commit = + _physics.CommitCollisionGeneration( + publication.CollisionAdmission, + publication.PreparedGeneration); + if (!commit.Committed) + { + publication.RefloodOwnerIds = commit.DirtyDynamicOwnerIds; + publication.RefloodCursor = 0; + publication.RefloodCommitted = false; + _completePublishTicks += Stopwatch.GetTimestamp() - started; + return false; + } + _refloodCount++; _staticBspOwnerCount += publication.BspOwnerCount; _staticCylinderOwnerCount += publication.CylinderOwnerCount; publication.CompletionCommitted = true; @@ -545,7 +581,7 @@ public sealed class LandblockPhysicsPublisher Matrix4x4.CreateFromQuaternion(rotation) * Matrix4x4.CreateTranslation(cellOriginWorld); - _physicsDataCache.CacheCellStruct( + publication.StagingCache.CacheCellStruct( envCellId, envCell, physicsCellTransform, @@ -623,7 +659,7 @@ public sealed class LandblockPhysicsPublisher Matrix4x4 physicsCellTransform = Matrix4x4.CreateFromQuaternion(rotation) * Matrix4x4.CreateTranslation(cellOriginWorld); - _physicsDataCache.CacheCellStruct( + publication.StagingCache.CacheCellStruct( envCellId, envCell, cellStruct, @@ -687,6 +723,7 @@ public sealed class LandblockPhysicsPublisher private void PublishBuilding( LoadedLandblock landblock, PhysicsDatBundle datBundle, + PhysicsDataCache cache, TerrainSurface terrainSurface, Vector3 origin, BuildingInfo building) @@ -720,7 +757,7 @@ public sealed class LandblockPhysicsPublisher ? setup.Parts[0] : 0u; } - _physicsDataCache.CacheBuilding( + cache.CacheBuilding( landcellId, portals, buildingTransform, @@ -752,11 +789,11 @@ public sealed class LandblockPhysicsPublisher ShadowShapeBuilder.FromLandblockBspParts( entity.MeshRefs, entity.IsBuildingShell, - _physicsDataCache.GetGfxObj); + publication.StagingCache.GetGfxObj); entityBspCount = bspShapes.Count; if (entityBspCount > 0) { - _physicsEngine.ShadowObjects.RegisterMultiPart( + publication.StagingEngine.ShadowObjects.RegisterMultiPart( entity.Id, entity.Position, entity.Rotation, @@ -772,9 +809,9 @@ public sealed class LandblockPhysicsPublisher } FlatSetupCollision? setup = - _physicsDataCache.GetFlatSetup(entity.SourceGfxObjOrSetupId); + publication.StagingCache.GetFlatSetup(entity.SourceGfxObjOrSetupId); if (setup is null - && _physicsDataCache.GetSetup( + && publication.StagingCache.GetSetup( entity.SourceGfxObjOrSetupId) is { } graphSetup) { // Graph-oracle fixture seam only. Production Setup publication is @@ -858,7 +895,7 @@ public sealed class LandblockPhysicsPublisher if (setupShapes.Count > 0) { - _physicsEngine.ShadowObjects.RegisterMultiPart( + publication.StagingEngine.ShadowObjects.RegisterMultiPart( entity.Id, entity.Position, entity.Rotation, @@ -917,7 +954,9 @@ public sealed class LandblockPhysicsPublisher } } - private void LogMissingSceneryBounds(LoadedLandblock landblock) + private static void LogMissingSceneryBounds( + LoadedLandblock landblock, + PhysicsDataCache cache) { if (!PhysicsDiagnostics.ProbeBuildingEnabled) return; @@ -933,7 +972,7 @@ public sealed class LandblockPhysicsPublisher foreach (MeshRef meshRef in entity.MeshRefs) { GfxObjVisualBounds? bounds = - _physicsDataCache.GetVisualBounds(meshRef.GfxObjId); + cache.GetVisualBounds(meshRef.GfxObjId); if (bounds is not null && bounds.Radius > 0f) { hasBounds = true; diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index f5ae4f9d..6f18c36c 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -215,6 +215,18 @@ public sealed class LandblockPresentationPipeline public IReadOnlyList GetPendingPublicationResults() => _publications.Keys.ToArray(); + /// + /// Cancels retained publication receipts during a generation reset. A + /// collision receipt owns only its private staging world until activation, + /// so cancellation cannot withdraw or partially replace the active world. + /// + internal void CancelPendingPublications() + { + foreach (PublicationTransaction transaction in _publications.Values) + transaction.PhysicsPublication?.Dispose(); + _publications.Clear(); + } + public void ResumePublication(LandblockStreamResult result) { ArgumentNullException.ThrowIfNull(result); diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 08f20cc8..6f4c7912 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -32,6 +32,7 @@ public sealed class StreamingController public bool GenerationAdvanced; public bool PendingLoadsCleared; public bool CompletionQueueCleared; + public bool PendingPublicationsCleared; public bool RegionCleared; public bool SpatialGenerationDetached; public bool PreparationCommitted; @@ -45,6 +46,7 @@ public sealed class StreamingController public bool GenerationAdvanced; public bool PendingLoadsCleared; public bool CompletionQueueCleared; + public bool PendingPublicationsCleared; public bool RegionCleared; public List? ResidentIds; public IEnumerator? ResidentEnumerator; @@ -1358,6 +1360,22 @@ public sealed class StreamingController } if (!transaction.RegionCleared) { + if (!transaction.PendingPublicationsCleared) + { + if (!TryRunStreamingWork( + meter, + new StreamingWorkCost(EntityOperations: 1), + "recenter-cancel-publications", + () => + { + _presentation.CancelPendingPublications(); + transaction.PendingPublicationsCleared = true; + return true; + })) + { + return false; + } + } if (!TryRunStreamingWork( meter, new StreamingWorkCost(EntityOperations: 1), @@ -1499,6 +1517,22 @@ public sealed class StreamingController if (!transaction.RegionCleared) { + if (!transaction.PendingPublicationsCleared) + { + if (!TryRunStreamingWork( + meter, + new StreamingWorkCost(EntityOperations: 1), + "reload-cancel-publications", + () => + { + _presentation.CancelPendingPublications(); + transaction.PendingPublicationsCleared = true; + return true; + })) + { + return false; + } + } if (!TryRunStreamingWork( meter, new StreamingWorkCost(EntityOperations: 1), diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 128df795..cf3567d5 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -20,6 +20,9 @@ <_Parameter1>AcDream.Core.Tests + + <_Parameter1>AcDream.Runtime + diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 6e8f019c..ba9b3e36 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -6,6 +6,7 @@ using DatReaderWriter.Types; using Plane = System.Numerics.Plane; using UcgEnvCell = AcDream.Core.World.Cells.EnvCell; using UcgCellGraph = AcDream.Core.World.Cells.CellGraph; +using PreparedCellGraphLandblock = AcDream.Core.World.Cells.PreparedCellGraphLandblock; namespace AcDream.Core.Physics; @@ -21,6 +22,7 @@ namespace AcDream.Core.Physics; public sealed class PhysicsDataCache { private readonly bool _requirePreparedCollision; + private PhysicsDataCache? _readFallback; private readonly ConcurrentDictionary _gfxObj = new(); private readonly ConcurrentDictionary _visualBounds = new(); private readonly ConcurrentDictionary _setup = new(); @@ -92,7 +94,112 @@ public sealed class PhysicsDataCache /// (TryGetTerrainOrigin, read by CellTransit's pick + transit /// paths). No longer inert. /// - public UcgCellGraph CellGraph { get; } = new(); + public UcgCellGraph CellGraph { get; private set; } = new(); + + /// + /// Copies the currently committed immutable collision records into an + /// off-side cache. Streaming may replace one landblock in this copy over + /// many frames without exposing a partially withdrawn cell graph to live + /// physics queries. + /// + internal PhysicsDataCache CreateCollisionStagingCopy() + { + var copy = new PhysicsDataCache(_requirePreparedCollision) + { + CollisionTraversalMode = CollisionTraversalMode, + CellGraph = CellGraph.CreateCollisionStagingCopy(), + _readFallback = this, + }; + // Global immutable GfxObj/Setup records are not copied wholesale. + // The accepted build's exact closure is staged cursor-by-cursor below; + // copying the process-retained asset catalog here would turn every + // landblock publication into an unbounded frame spike. + CopyDictionary(_cellStruct, copy._cellStruct); + CopyDictionary(_flatCellStruct, copy._flatCellStruct); + CopyDictionary(_flatEnvCell, copy._flatEnvCell); + CopyDictionary(_buildings, copy._buildings); + return copy; + } + + internal PreparedPhysicsDataCacheLandblock PrepareLandblockReplacement( + uint landblockId, + ReadOnlySpan gfxObjectIds, + ReadOnlySpan setupIds) + { + uint prefix = landblockId & 0xFFFF0000u; + return new PreparedPhysicsDataCacheLandblock( + prefix, + CaptureRequested(_gfxObj, gfxObjectIds), + CaptureRequested(_visualBounds, gfxObjectIds), + CaptureRequested(_flatGfxObj, gfxObjectIds), + CaptureRequested(_setup, setupIds), + CaptureRequested(_flatSetup, setupIds), + CapturePrefix(_cellStruct, prefix), + CapturePrefix(_flatCellStruct, prefix), + CapturePrefix(_flatEnvCell, prefix), + CapturePrefix(_buildings, prefix), + CellGraph.PrepareLandblockReplacement(prefix)); + } + + internal void CommitLandblockReplacement( + PreparedPhysicsDataCacheLandblock replacement) + { + RemoveCellsForLandblock(replacement.LandblockPrefix); + RemoveBuildingsForLandblock(replacement.LandblockPrefix); + CommitEntries(_gfxObj, replacement.GfxObjects, replace: false); + CommitEntries(_visualBounds, replacement.VisualBounds, replace: false); + CommitEntries(_flatGfxObj, replacement.FlatGfxObjects, replace: false); + CommitEntries(_setup, replacement.Setups, replace: false); + CommitEntries(_flatSetup, replacement.FlatSetups, replace: false); + CommitEntries(_cellStruct, replacement.Cells, replace: true); + CommitEntries(_flatCellStruct, replacement.FlatCells, replace: true); + CommitEntries(_flatEnvCell, replacement.FlatEnvCells, replace: true); + CommitEntries(_buildings, replacement.Buildings, replace: true); + CellGraph.CommitLandblockReplacement(replacement.CellGraph); + } + + private static void CopyDictionary( + ConcurrentDictionary source, + ConcurrentDictionary destination) + { + foreach ((uint id, T value) in source) + destination.TryAdd(id, value); + } + + private static KeyValuePair[] CaptureRequested( + ConcurrentDictionary source, + ReadOnlySpan ids) + { + var result = new List>(ids.Length); + for (int index = 0; index < ids.Length; index++) + { + uint id = ids[index]; + if (source.TryGetValue(id, out T? value)) + result.Add(new KeyValuePair(id, value)); + } + return result.ToArray(); + } + + private static KeyValuePair[] CapturePrefix( + ConcurrentDictionary source, + uint prefix) => source + .Where(pair => (pair.Key & 0xFFFF0000u) == prefix) + .OrderBy(static pair => pair.Key) + .ToArray(); + + private static void CommitEntries( + ConcurrentDictionary destination, + KeyValuePair[] entries, + bool replace) + { + foreach ((uint id, T value) in entries) + { + if (replace) + destination[id] = value; + else + destination.TryAdd(id, value); + } + } /// /// Extract and cache the physics BSP + polygon data from a GfxObj, @@ -237,7 +344,9 @@ public sealed class PhysicsDataCache /// Get the cached visual AABB for a GfxObj, or null if not cached. /// public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) => - _visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null; + _visualBounds.TryGetValue(gfxObjId, out var vb) + ? vb + : _readFallback?.GetVisualBounds(gfxObjId); /// /// Compute a tight axis-aligned bounding box over all vertices in the mesh. @@ -756,14 +865,24 @@ public sealed class PhysicsDataCache $"Production {kind} 0x{sourceId:X8} has no prepared collision asset. " + "Gameplay must not extract or fall back to a parsed DAT graph."); - public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null; + public GfxObjPhysics? GetGfxObj(uint id) => + _gfxObj.TryGetValue(id, out var p) + ? p + : _readFallback?.GetGfxObj(id); - public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null; + public SetupPhysics? GetSetup(uint id) => + _setup.TryGetValue(id, out var p) + ? p + : _readFallback?.GetSetup(id); public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null; public FlatGfxObjCollisionAsset? GetFlatGfxObj(uint id) => - _flatGfxObj.TryGetValue(id, out var value) ? value : null; + _flatGfxObj.TryGetValue(id, out var value) + ? value + : _readFallback?.GetFlatGfxObj(id); public FlatSetupCollision? GetFlatSetup(uint id) => - _flatSetup.TryGetValue(id, out var value) ? value : null; + _flatSetup.TryGetValue(id, out var value) + ? value + : _readFallback?.GetFlatSetup(id); public FlatCellStructureCollisionAsset? GetFlatCellStruct(uint id) => _flatCellStruct.TryGetValue(id, out var value) ? value : null; public FlatEnvCellTopology? GetFlatEnvCell(uint id) => @@ -926,6 +1045,19 @@ public sealed class PhysicsDataCache public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; } +internal sealed record PreparedPhysicsDataCacheLandblock( + uint LandblockPrefix, + KeyValuePair[] GfxObjects, + KeyValuePair[] VisualBounds, + KeyValuePair[] FlatGfxObjects, + KeyValuePair[] Setups, + KeyValuePair[] FlatSetups, + KeyValuePair[] Cells, + KeyValuePair[] FlatCells, + KeyValuePair[] FlatEnvCells, + KeyValuePair[] Buildings, + PreparedCellGraphLandblock CellGraph); + /// /// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless /// of whether it has physics data. Used as a collision fallback shape for diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 72347120..017b44c1 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -153,13 +153,106 @@ public sealed class PhysicsEngine /// public ClientObjectTable? Objects { get; set; } - private sealed record LandblockPhysics( + internal sealed record LandblockPhysics( TerrainSurface Terrain, IReadOnlyList Cells, IReadOnlyList Portals, float WorldOffsetX, float WorldOffsetY); + /// + /// Creates an off-side collision world from the last complete generation. + /// Streaming modifies this copy only; the active engine and its borrowed + /// cache/registry identities remain stable until Runtime commits. + /// + internal PhysicsEngine CreateCollisionStagingCopy( + PhysicsDataCache stagingCache) + { + ArgumentNullException.ThrowIfNull(stagingCache); + var staging = new PhysicsEngine + { + DataCache = stagingCache, + Objects = Objects, + }; + foreach ((uint id, LandblockPhysics landblock) in _landblocks) + staging._landblocks[id] = landblock; + staging.ShadowObjects.CopyCollisionStateFrom( + ShadowObjects, + stagingCache); + return staging; + } + + internal PreparedPhysicsEngineLandblock PrepareLandblockReplacement( + PhysicsEngine staging, + uint landblockId, + ReadOnlySpan gfxObjectIds, + ReadOnlySpan setupIds, + IReadOnlyDictionary expectedDynamicVersions) + { + ArgumentNullException.ThrowIfNull(staging); + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + if (!staging._landblocks.TryGetValue( + canonical, + out LandblockPhysics? landblock)) + { + throw new InvalidOperationException( + $"Staging collision generation has no landblock 0x{canonical:X8}."); + } + PhysicsDataCache stagingCache = staging.DataCache + ?? throw new InvalidOperationException( + "Staging collision engine has no data cache."); + return new PreparedPhysicsEngineLandblock( + canonical, + landblock, + stagingCache.PrepareLandblockReplacement( + canonical, + gfxObjectIds, + setupIds), + ShadowObjects.PrepareLandblockReplacement( + staging.ShadowObjects, + canonical, + expectedDynamicVersions)); + } + + internal bool ValidateLandblockReplacement( + PreparedPhysicsEngineLandblock replacement) => + ShadowObjects.ValidateLandblockReplacement(replacement.Shadows); + + internal void CommitLandblockReplacement( + PreparedPhysicsEngineLandblock replacement) + { + if (!ValidateLandblockReplacement(replacement)) + { + throw new InvalidOperationException( + "Collision generation changed after it was sealed."); + } + (DataCache ?? throw new InvalidOperationException( + "Active collision engine has no data cache.")) + .CommitLandblockReplacement(replacement.DataCache); + _landblocks[replacement.LandblockId] = replacement.Landblock; + ShadowObjects.CommitLandblockReplacement(replacement.Shadows); + } + + internal sealed class PreparedPhysicsEngineLandblock + { + internal PreparedPhysicsEngineLandblock( + uint landblockId, + LandblockPhysics landblock, + PreparedPhysicsDataCacheLandblock dataCache, + ShadowObjectRegistry.PreparedLandblockShadowReplacement shadows) + { + LandblockId = landblockId; + Landblock = landblock; + DataCache = dataCache; + Shadows = shadows; + } + + internal uint LandblockId { get; } + internal LandblockPhysics Landblock { get; } + internal PreparedPhysicsDataCacheLandblock DataCache { get; } + internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; } + } + /// /// Register a landblock with its terrain surface, indoor cells, portal /// planes, and world-space origin offset. diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index b511a81a..6e7afc6d 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -51,8 +51,9 @@ public sealed class ShadowObjectRegistry /// is the streaming-side trigger. /// private readonly Dictionary _entityReg = new(); + private readonly Dictionary _ownerVersions = new(); - private sealed record RegistrationRecord( + internal sealed record RegistrationRecord( uint SeedCellId, Vector3 EntityWorldPos, Quaternion EntityWorldRot, @@ -67,6 +68,16 @@ public sealed class ShadowObjectRegistry float CylHeight, float Scale); + internal ulong GetOwnerVersion(uint entityId) => + _ownerVersions.TryGetValue(entityId, out ulong version) + ? version + : 0UL; + + private void BumpOwnerVersion(uint entityId) + { + _ownerVersions[entityId] = checked(GetOwnerVersion(entityId) + 1UL); + } + /// /// The flood's data source (cells, buildings, terrain origins). Wired by /// when its own DataCache is set. @@ -135,6 +146,7 @@ public sealed class ShadowObjectRegistry _entityReg[entityId] = new RegistrationRecord( seed, worldPos, rotation, state, flags, isStatic, IsMultiPart: false, gfxObjId, radius, collisionType, cylHeight, scale); + BumpOwnerVersion(entityId); } /// @@ -214,6 +226,7 @@ public sealed class ShadowObjectRegistry seed, entityWorldPos, entityWorldRot, state, flags, isStatic, IsMultiPart: true, GfxObjId: 0u, Radius: 0f, CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f); + BumpOwnerVersion(entityId); } /// @@ -271,7 +284,10 @@ public sealed class ShadowObjectRegistry }; if (suspended || !_entityToCells.TryGetValue(entityId, out List? cells)) + { + BumpOwnerVersion(entityId); return; + } foreach (uint cellId in cells) { @@ -300,6 +316,7 @@ public sealed class ShadowObjectRegistry foreach (uint cellId in cells) AddEntryToCell(entry, cellId); } + BumpOwnerVersion(entityId); } /// @@ -441,6 +458,7 @@ public sealed class ShadowObjectRegistry } _suspendedEntities.Add(entityId); + BumpOwnerVersion(entityId); return true; } @@ -624,11 +642,16 @@ public sealed class ShadowObjectRegistry if (_entityReg.TryGetValue(entityId, out var reg)) _entityReg[entityId] = reg with { State = newState }; + BumpOwnerVersion(entityId); } /// Remove an entity from all cells it was registered in. public void Deregister(uint entityId) { + bool existed = _entityReg.ContainsKey(entityId) + || _entityToCells.ContainsKey(entityId) + || _entityShapes.ContainsKey(entityId) + || _suspendedEntities.Contains(entityId); if (_entityToCells.TryGetValue(entityId, out var cellIds)) { foreach (var cellId in cellIds) @@ -642,6 +665,8 @@ public sealed class ShadowObjectRegistry _entityReg.Remove(entityId); _suspendedEntities.Remove(entityId); _withdrawnPrefixesByOwner.Remove(entityId); + if (existed) + BumpOwnerVersion(entityId); } /// @@ -708,11 +733,13 @@ public sealed class ShadowObjectRegistry { uint lbPrefix = landblockId & 0xFFFF0000u; var toRemove = new List(); + var touchedOwners = new HashSet(); foreach (var (entityId, cells) in _entityToCells) { if (!cells.Exists(cell => (cell & 0xFFFF0000u) == lbPrefix)) continue; + touchedOwners.Add(entityId); if (!_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn)) { withdrawn = new HashSet(); @@ -753,6 +780,8 @@ public sealed class ShadowObjectRegistry _withdrawnPrefixesByOwner.Remove(eid); } } + foreach (uint entityId in touchedOwners) + BumpOwnerVersion(entityId); } /// @@ -795,6 +824,315 @@ public sealed class ShadowObjectRegistry /// Suspended logical registrations awaiting spatial re-entry. public int SuspendedRegistrationCount => _suspendedEntities.Count; + /// + /// Copies the committed registry into an off-side collision generation. + /// All mutable lists and sets are cloned; immutable registration and shape + /// payloads may be shared. + /// + internal void CopyCollisionStateFrom( + ShadowObjectRegistry source, + PhysicsDataCache stagingCache) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(stagingCache); + Clear(); + DataCache = stagingCache; + foreach ((uint cellId, List entries) in source._cells) + _cells[cellId] = new List(entries); + foreach ((uint ownerId, List cells) in source._entityToCells) + _entityToCells[ownerId] = new List(cells); + foreach (uint ownerId in source._suspendedEntities) + _suspendedEntities.Add(ownerId); + foreach ((uint ownerId, HashSet prefixes) in + source._withdrawnPrefixesByOwner) + { + _withdrawnPrefixesByOwner[ownerId] = new HashSet(prefixes); + } + foreach ((uint ownerId, IReadOnlyList shapes) in + source._entityShapes) + { + _entityShapes[ownerId] = shapes; + } + foreach ((uint ownerId, RegistrationRecord registration) in + source._entityReg) + { + _entityReg[ownerId] = registration; + } + foreach ((uint ownerId, ulong version) in source._ownerVersions) + _ownerVersions[ownerId] = version; + } + + internal uint[] CaptureDynamicRefloodOwnersForLandblock( + uint landblockId) + { + uint[] owners = CaptureRefloodOwnersForLandblock(landblockId); + return owners.Where(ownerId => + _entityReg.TryGetValue(ownerId, out RegistrationRecord? record) + && !record.IsStatic) + .ToArray(); + } + + /// + /// Refreshes one staging owner from the exact active payload, then floods + /// it against the staging generation's complete cell graph. The returned + /// source version is the commit-time freshness token. + /// + internal bool RefreshDynamicOwnerFrom( + ShadowObjectRegistry source, + uint entityId, + uint landblockId, + out ulong sourceVersion) + { + ArgumentNullException.ThrowIfNull(source); + Deregister(entityId); + sourceVersion = source.GetOwnerVersion(entityId); + if (!source._entityReg.TryGetValue( + entityId, + out RegistrationRecord? registration) + || registration.IsStatic + || source._suspendedEntities.Contains(entityId) + || !source.OwnerTouchesLandblock(entityId, landblockId)) + { + return false; + } + + if (registration.IsMultiPart + && source._entityShapes.TryGetValue( + entityId, + out IReadOnlyList? shapes)) + { + RegisterMultiPart( + entityId, + registration.EntityWorldPos, + registration.EntityWorldRot, + shapes, + registration.State, + registration.Flags, + 0f, + 0f, + landblockId, + registration.SeedCellId, + isStatic: false); + } + else + { + Register( + entityId, + registration.GfxObjId, + registration.EntityWorldPos, + registration.EntityWorldRot, + registration.Radius, + 0f, + 0f, + landblockId, + registration.CollisionType, + registration.CylHeight, + registration.Scale, + registration.State, + registration.Flags, + registration.SeedCellId, + isStatic: false); + } + return true; + } + + internal uint[] FindDirtyDynamicOwners( + uint landblockId, + IReadOnlyDictionary expectedVersions) + { + var dirty = new HashSet( + CaptureDynamicRefloodOwnersForLandblock(landblockId)); + dirty.UnionWith(expectedVersions.Keys); + dirty.RemoveWhere(ownerId => + expectedVersions.TryGetValue(ownerId, out ulong expected) + && OwnerTouchesLandblock(ownerId, landblockId) + && GetOwnerVersion(ownerId) == expected); + uint[] result = dirty.ToArray(); + Array.Sort(result); + return result; + } + + internal PreparedLandblockShadowReplacement PrepareLandblockReplacement( + ShadowObjectRegistry staging, + uint landblockId, + IReadOnlyDictionary expectedDynamicVersions) + { + ArgumentNullException.ThrowIfNull(staging); + uint[] dirty = FindDirtyDynamicOwners( + landblockId, + expectedDynamicVersions); + if (dirty.Length != 0) + { + throw new InvalidOperationException( + "Dynamic shadow owners changed before collision generation sealing."); + } + + var owners = new HashSet(CaptureStaticOwnersForLandblock(landblockId)); + owners.UnionWith(staging.CaptureStaticOwnersForLandblock(landblockId)); + owners.UnionWith(expectedDynamicVersions.Keys); + uint[] ownerIds = owners.ToArray(); + Array.Sort(ownerIds); + var states = new List(ownerIds.Length); + foreach (uint ownerId in ownerIds) + { + if (staging.TryCaptureOwnerState(ownerId, out PreparedShadowOwnerState? state) + && state is not null) + states.Add(state); + } + return new PreparedLandblockShadowReplacement( + landblockId & 0xFFFF0000u, + ownerIds, + states.ToArray(), + expectedDynamicVersions.ToDictionary( + static pair => pair.Key, + static pair => pair.Value)); + } + + internal bool ValidateLandblockReplacement( + PreparedLandblockShadowReplacement replacement) + { + foreach ((uint ownerId, ulong version) in replacement.DynamicVersions) + { + if (GetOwnerVersion(ownerId) != version + || !OwnerTouchesLandblock(ownerId, replacement.LandblockPrefix)) + { + return false; + } + } + return CaptureDynamicRefloodOwnersForLandblock( + replacement.LandblockPrefix) + .SequenceEqual(replacement.DynamicVersions.Keys.Order()); + } + + internal void CommitLandblockReplacement( + PreparedLandblockShadowReplacement replacement) + { + if (!ValidateLandblockReplacement(replacement)) + { + throw new InvalidOperationException( + "Dynamic shadow owners changed before collision generation commit."); + } + + foreach (uint ownerId in replacement.OwnerIds) + Deregister(ownerId); + foreach (PreparedShadowOwnerState state in replacement.OwnerStates) + InstallOwnerState(state); + } + + private bool OwnerTouchesLandblock(uint entityId, uint landblockId) + { + uint prefix = landblockId & 0xFFFF0000u; + if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? record)) + return false; + if ((record.SeedCellId & 0xFFFF0000u) == prefix) + return true; + if (_entityToCells.TryGetValue(entityId, out List? cells) + && cells.Exists(cell => (cell & 0xFFFF0000u) == prefix)) + { + return true; + } + return _withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? withdrawn) + && withdrawn.Contains(prefix); + } + + private bool TryCaptureOwnerState( + uint entityId, + out PreparedShadowOwnerState? state) + { + if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? registration)) + { + state = null; + return false; + } + _entityToCells.TryGetValue(entityId, out List? cells); + _entityShapes.TryGetValue( + entityId, + out IReadOnlyList? shapes); + _withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? withdrawn); + var rows = new List(); + if (cells is not null) + { + foreach (uint cellId in cells) + { + if (_cells.TryGetValue(cellId, out List? entries)) + { + rows.Add(new PreparedShadowCellRows( + cellId, + entries.Where(entry => entry.EntityId == entityId) + .ToArray())); + } + } + } + state = new PreparedShadowOwnerState( + entityId, + registration, + shapes, + cells?.ToArray() ?? Array.Empty(), + rows.ToArray(), + _suspendedEntities.Contains(entityId), + withdrawn?.ToArray() ?? Array.Empty()); + return true; + } + + private void InstallOwnerState(PreparedShadowOwnerState state) + { + _entityReg[state.EntityId] = state.Registration; + if (state.Shapes is not null) + _entityShapes[state.EntityId] = state.Shapes; + if (state.Suspended) + _suspendedEntities.Add(state.EntityId); + if (state.WithdrawnPrefixes.Length != 0) + { + _withdrawnPrefixesByOwner[state.EntityId] = + new HashSet(state.WithdrawnPrefixes); + } + if (state.CellIds.Length != 0) + _entityToCells[state.EntityId] = new List(state.CellIds); + foreach (PreparedShadowCellRows row in state.Rows) + { + foreach (ShadowEntry entry in row.Entries) + AddEntryToCell(entry, row.CellId); + } + BumpOwnerVersion(state.EntityId); + } + + internal sealed class PreparedLandblockShadowReplacement + { + internal PreparedLandblockShadowReplacement( + uint landblockPrefix, + uint[] ownerIds, + PreparedShadowOwnerState[] ownerStates, + Dictionary dynamicVersions) + { + LandblockPrefix = landblockPrefix; + OwnerIds = ownerIds; + OwnerStates = ownerStates; + DynamicVersions = dynamicVersions; + } + + internal uint LandblockPrefix { get; } + internal uint[] OwnerIds { get; } + internal PreparedShadowOwnerState[] OwnerStates { get; } + internal Dictionary DynamicVersions { get; } + } + + internal sealed record PreparedShadowOwnerState( + uint EntityId, + RegistrationRecord Registration, + IReadOnlyList? Shapes, + uint[] CellIds, + PreparedShadowCellRows[] Rows, + bool Suspended, + uint[] WithdrawnPrefixes); + + internal sealed record PreparedShadowCellRows( + uint CellId, + ShadowEntry[] Entries); + /// /// Retires the complete logical registry at terminal physics-engine /// disposal, including suspended live registrations that own no cell row. @@ -807,6 +1145,7 @@ public sealed class ShadowObjectRegistry _withdrawnPrefixesByOwner.Clear(); _entityShapes.Clear(); _entityReg.Clear(); + _ownerVersions.Clear(); _fallback = null; } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 48fc0951..9d9e82ea 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -125,4 +125,76 @@ public sealed class CellGraph return stab; return null; } + + /// + /// Creates an immutable-reference snapshot for collision-generation + /// preparation. EnvCell and TerrainSurface records are immutable after + /// publication, so copying the registries is sufficient; the active graph + /// remains untouched while the staging graph is rebuilt. + /// + internal CellGraph CreateCollisionStagingCopy() + { + var copy = new CellGraph { CurrCell = CurrCell }; + foreach ((uint id, EnvCell cell) in _envCells) + copy._envCells.TryAdd(id, cell); + foreach ((uint id, (TerrainSurface Terrain, Vector3 Origin) terrain) in + _terrain) + { + copy._terrain.TryAdd(id, terrain); + } + return copy; + } + + internal PreparedCellGraphLandblock PrepareLandblockReplacement( + uint landblockId) + { + uint prefix = landblockId & 0xFFFF0000u; + KeyValuePair[] envCells = _envCells + .Where(static pair => (pair.Key & 0xFFFFu) >= 0x0100u) + .Where(pair => (pair.Key & 0xFFFF0000u) == prefix) + .OrderBy(static pair => pair.Key) + .ToArray(); + bool hasTerrain = _terrain.TryGetValue(prefix, out var terrain); + return new PreparedCellGraphLandblock( + prefix, + envCells, + hasTerrain, + terrain.Terrain, + terrain.Origin, + CurrCell?.Id ?? 0u); + } + + internal void CommitLandblockReplacement( + PreparedCellGraphLandblock replacement) + { + uint currentCellId = CurrCell?.Id ?? 0u; + RemoveLandblock(replacement.LandblockPrefix); + if (replacement.HasTerrain) + { + _terrain[replacement.LandblockPrefix] = ( + replacement.Terrain!, + replacement.Origin); + } + foreach ((uint id, EnvCell cell) in replacement.EnvCells) + _envCells[id] = cell; + + uint desiredCurrentCellId = + (currentCellId & 0xFFFF0000u) == replacement.LandblockPrefix + ? currentCellId + : currentCellId == 0u + && (replacement.CurrentCellId & 0xFFFF0000u) + == replacement.LandblockPrefix + ? replacement.CurrentCellId + : 0u; + if (desiredCurrentCellId != 0u) + CurrCell = GetVisible(desiredCurrentCellId); + } } + +internal sealed record PreparedCellGraphLandblock( + uint LandblockPrefix, + KeyValuePair[] EnvCells, + bool HasTerrain, + TerrainSurface? Terrain, + Vector3 Origin, + uint CurrentCellId); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index 8f364dec..4baa3bdc 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -174,7 +174,14 @@ internal sealed class HeadlessCollisionNeighborhood RuntimePhysicsState physics = _runtime.EntityObjects.Physics; - PhysicsDataCache cache = physics.DataCache; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(landblockId); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + prepared.SetAssetClosure( + [.. collisions.GfxObjIds], + [.. collisions.SetupIds]); + PhysicsDataCache cache = prepared.DataCache; TerrainSurface terrain = LandblockPhysicsContentBuilder.BuildTerrainSurface( landblock, @@ -197,12 +204,11 @@ internal sealed class HeadlessCollisionNeighborhood cache, collisions); - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(landblockId); try { - physics.AdmitCollisionAssets( + physics.StageCollisionAssets( admission, + prepared, new RuntimeLandblockCollisionAssets( landblockId, terrain, @@ -213,12 +219,27 @@ internal sealed class HeadlessCollisionNeighborhood currentCellId)); _ = LandblockPhysicsContentBuilder .PublishStaticCollision( - physics.Engine, + prepared.Engine, cache, landblock, collisions, origin); - _ = physics.CompleteCollisionAdmission(admission); + foreach (uint ownerId in physics.CaptureCollisionDynamicOwners( + admission, + prepared)) + { + physics.RefreshCollisionDynamicOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + if (!commit.Committed) + { + throw new InvalidOperationException( + "Headless collision generation changed during synchronous publication."); + } _resident.Add(CanonicalLandblock(landblockId)); } catch diff --git a/src/AcDream.Runtime/AcDream.Runtime.csproj b/src/AcDream.Runtime/AcDream.Runtime.csproj index 4c033569..b1bc35a5 100644 --- a/src/AcDream.Runtime/AcDream.Runtime.csproj +++ b/src/AcDream.Runtime/AcDream.Runtime.csproj @@ -12,6 +12,8 @@ + + diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 3b04be34..ba56b41b 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -54,7 +54,7 @@ public sealed class RuntimeCollisionAdmission } internal RuntimePhysicsState Owner { get; } - internal bool AssetsAdmitted { get; set; } + internal bool AssetsPrepared { get; set; } internal bool Completed { get; set; } public uint LandblockId { get; } public ulong Generation { get; } @@ -63,7 +63,109 @@ public sealed class RuntimeCollisionAdmission public readonly record struct RuntimeCollisionAcknowledgement( uint LandblockId, ulong Generation, - bool WasResident); + bool WasResident, + bool Ready); + +public readonly record struct RuntimeCollisionGenerationCommit( + RuntimeCollisionAcknowledgement Acknowledgement, + uint[] DirtyDynamicOwnerIds) +{ + public bool Committed => Acknowledgement.Ready; +} + +public readonly record struct RuntimeCollisionGenerationCommitted( + uint LandblockId, + ulong Generation, + bool Ready); + +/// +/// One off-side collision generation. It owns a private cache, cell graph, +/// engine, and shadow registry cloned from the previous complete generation. +/// Hosts may populate it incrementally, but only Runtime can activate it. +/// +internal sealed class PreparedLandblockCollisionGeneration : IDisposable +{ + private readonly RuntimePhysicsState _owner; + private readonly RuntimeCollisionAdmission _admission; + private readonly Dictionary _dynamicOwnerVersions = new(); + private bool _disposed; + + internal PreparedLandblockCollisionGeneration( + RuntimePhysicsState owner, + RuntimeCollisionAdmission admission, + PhysicsDataCache dataCache, + PhysicsEngine engine) + { + _owner = owner; + _admission = admission; + DataCache = dataCache; + Engine = engine; + } + + internal PhysicsDataCache DataCache { get; } + internal PhysicsEngine Engine { get; } + internal uint[] GfxObjectIds { get; private set; } = Array.Empty(); + internal uint[] SetupIds { get; private set; } = Array.Empty(); + internal IReadOnlyDictionary DynamicOwnerVersions => + _dynamicOwnerVersions; + internal bool IsDisposed => _disposed; + + internal bool Matches( + RuntimePhysicsState owner, + RuntimeCollisionAdmission admission) => + ReferenceEquals(_owner, owner) + && ReferenceEquals(_admission, admission); + + internal void SetAssetClosure(uint[] gfxObjectIds, uint[] setupIds) + { + EnsureUsable(); + GfxObjectIds = gfxObjectIds ?? throw new ArgumentNullException(nameof(gfxObjectIds)); + SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds)); + } + + internal void RefreshDynamicOwner(uint ownerId) + { + EnsureUsable(); + bool retained = Engine.ShadowObjects.RefreshDynamicOwnerFrom( + _owner.Engine.ShadowObjects, + ownerId, + _admission.LandblockId, + out ulong version); + if (retained) + _dynamicOwnerVersions[ownerId] = version; + else + _dynamicOwnerVersions.Remove(ownerId); + } + + internal uint[] FindDirtyDynamicOwners() + { + EnsureUsable(); + return _owner.Engine.ShadowObjects.FindDirtyDynamicOwners( + _admission.LandblockId, + _dynamicOwnerVersions); + } + + internal void MarkCommitted() + { + EnsureUsable(); + _disposed = true; + } + + public void Dispose() + { + if (_disposed) + return; + Engine.Clear(); + _dynamicOwnerVersions.Clear(); + _disposed = true; + } + + private void EnsureUsable() + { + if (_disposed) + throw new ObjectDisposedException(nameof(PreparedLandblockCollisionGeneration)); + } +} /// /// Presentation-free mutable physics world for one Runtime/session owner. @@ -83,9 +185,12 @@ public sealed class RuntimePhysicsState : IDisposable private readonly Dictionary _collisionGenerations = new(); private readonly Dictionary _collisionAdmissions = new(); + private int _collisionMutationThreadId; private bool _disposed; public event Action? CellCommitted; + public event Action? + CollisionGenerationCommitted; internal RuntimePhysicsState( RuntimeEntityDirectory entities, @@ -865,6 +970,7 @@ public sealed class RuntimePhysicsState : IDisposable uint landblockId) { EnsureNotDisposed(); + EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); ulong generation = _collisionGenerations.TryGetValue( canonical, @@ -880,11 +986,29 @@ public sealed class RuntimePhysicsState : IDisposable return admission; } - public void AdmitCollisionAssets( + internal PreparedLandblockCollisionGeneration PrepareCollisionGeneration( + RuntimeCollisionAdmission admission) + { + ValidateAdmission(admission); + EnsureCollisionMutationThread(); + PhysicsDataCache stagingCache = DataCache.CreateCollisionStagingCopy(); + PhysicsEngine stagingEngine = + Engine.CreateCollisionStagingCopy(stagingCache); + return new PreparedLandblockCollisionGeneration( + this, + admission, + stagingCache, + stagingEngine); + } + + internal void StageCollisionAssets( RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared, RuntimeLandblockCollisionAssets assets) { ValidateAdmission(admission); + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); ArgumentNullException.ThrowIfNull(assets); if (CanonicalLandblock(assets.LandblockId) != admission.LandblockId) @@ -898,13 +1022,13 @@ public sealed class RuntimePhysicsState : IDisposable throw new InvalidOperationException( "A completed collision admission cannot publish more assets."); } - if (admission.AssetsAdmitted) + if (admission.AssetsPrepared) { throw new InvalidOperationException( - "Collision assets were already admitted by this receipt."); + "Collision assets were already prepared by this receipt."); } - Engine.AddLandblock( + prepared.Engine.AddLandblock( admission.LandblockId, assets.Terrain, assets.CellSurfaces, @@ -914,38 +1038,106 @@ public sealed class RuntimePhysicsState : IDisposable if ((assets.CurrentCellId & 0xFFFF0000u) == (admission.LandblockId & 0xFFFF0000u)) { - Engine.UpdatePlayerCurrCell(assets.CurrentCellId); + prepared.Engine.UpdatePlayerCurrCell(assets.CurrentCellId); } - admission.AssetsAdmitted = true; + admission.AssetsPrepared = true; } - public RuntimeCollisionAcknowledgement CompleteCollisionAdmission( - RuntimeCollisionAdmission admission) + internal uint[] CaptureCollisionDynamicOwners( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) { ValidateAdmission(admission); - if (!admission.AssetsAdmitted) + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); + return Engine.ShadowObjects.CaptureDynamicRefloodOwnersForLandblock( + admission.LandblockId); + } + + internal void RefreshCollisionDynamicOwner( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared, + uint ownerId) + { + ValidateAdmission(admission); + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); + prepared.RefreshDynamicOwner(ownerId); + } + + internal RuntimeCollisionGenerationCommit CommitCollisionGeneration( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + ValidateAdmission(admission); + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); + if (!admission.AssetsPrepared) { throw new InvalidOperationException( - "Collision admission cannot complete before its assets publish."); + "Collision generation cannot commit before its assets are prepared."); } if (admission.Completed) { throw new InvalidOperationException( - "Collision admission has already completed."); + "Collision generation has already completed."); } + uint[] dirtyOwners = prepared.FindDirtyDynamicOwners(); + if (dirtyOwners.Length != 0) + { + return new RuntimeCollisionGenerationCommit( + new RuntimeCollisionAcknowledgement( + admission.LandblockId, + admission.Generation, + Engine.IsLandblockTerrainResident(admission.LandblockId), + Ready: false), + dirtyOwners); + } + + PhysicsEngine.PreparedPhysicsEngineLandblock replacement = + Engine.PrepareLandblockReplacement( + prepared.Engine, + admission.LandblockId, + prepared.GfxObjectIds, + prepared.SetupIds, + prepared.DynamicOwnerVersions); + if (!Engine.ValidateLandblockReplacement(replacement)) + { + dirtyOwners = prepared.FindDirtyDynamicOwners(); + return new RuntimeCollisionGenerationCommit( + new RuntimeCollisionAcknowledgement( + admission.LandblockId, + admission.Generation, + Engine.IsLandblockTerrainResident(admission.LandblockId), + Ready: false), + dirtyOwners); + } + + Engine.CommitLandblockReplacement(replacement); admission.Completed = true; _collisionAdmissions.Remove(admission.LandblockId); - return new RuntimeCollisionAcknowledgement( + prepared.MarkCommitted(); + var acknowledgement = new RuntimeCollisionAcknowledgement( admission.LandblockId, admission.Generation, - Engine.IsLandblockTerrainResident(admission.LandblockId)); + Engine.IsLandblockTerrainResident(admission.LandblockId), + Ready: Engine.IsLandblockTerrainResident(admission.LandblockId)); + PublishCollisionGenerationCommitted( + new RuntimeCollisionGenerationCommitted( + acknowledgement.LandblockId, + acknowledgement.Generation, + acknowledgement.Ready)); + return new RuntimeCollisionGenerationCommit( + acknowledgement, + Array.Empty()); } public RuntimeCollisionAcknowledgement DemoteCollisionToTerrain( uint landblockId) { EnsureNotDisposed(); + EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); bool resident = Engine.IsLandblockTerrainResident(canonical); InvalidateCollisionAdmission(canonical); @@ -953,13 +1145,15 @@ public sealed class RuntimePhysicsState : IDisposable return new RuntimeCollisionAcknowledgement( canonical, _collisionGenerations[canonical], - resident); + resident, + Ready: Engine.IsLandblockTerrainResident(canonical)); } public RuntimeCollisionAcknowledgement WithdrawCollision( uint landblockId) { EnsureNotDisposed(); + EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); bool resident = Engine.IsLandblockTerrainResident(canonical); InvalidateCollisionAdmission(canonical); @@ -967,7 +1161,8 @@ public sealed class RuntimePhysicsState : IDisposable return new RuntimeCollisionAcknowledgement( canonical, _collisionGenerations[canonical], - resident); + resident, + Ready: false); } public void Dispose() @@ -981,6 +1176,7 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions.Clear(); _collisionGenerations.Clear(); CellCommitted = null; + CollisionGenerationCommitted = null; _disposed = true; } @@ -1053,6 +1249,20 @@ public sealed class RuntimePhysicsState : IDisposable private void EnsureNotDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + private void EnsureCollisionMutationThread() + { + int current = Environment.CurrentManagedThreadId; + int owner = Interlocked.CompareExchange( + ref _collisionMutationThreadId, + current, + 0); + if (owner != 0 && owner != current) + { + throw new InvalidOperationException( + "Collision generations must be staged and committed on one update thread."); + } + } + private void EnsureCurrent(RuntimeEntityRecord record) { if (!Entities.IsCurrent(record)) @@ -1081,6 +1291,39 @@ public sealed class RuntimePhysicsState : IDisposable } } + private void ValidatePreparedGeneration( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + ArgumentNullException.ThrowIfNull(prepared); + ObjectDisposedException.ThrowIf(prepared.IsDisposed, prepared); + if (!prepared.Matches(this, admission)) + { + throw new InvalidOperationException( + "Prepared collision generation is stale or belongs to another admission."); + } + } + + private void PublishCollisionGenerationCommitted( + RuntimeCollisionGenerationCommitted committed) + { + Delegate[] observers = CollisionGenerationCommitted? + .GetInvocationList() ?? Array.Empty(); + foreach (Delegate observer in observers) + { + try + { + ((Action)observer)(committed); + } + catch (Exception error) + { + System.Diagnostics.Trace.TraceError( + "Collision-generation commit observer failed after activation: {0}", + error); + } + } + } + private void InvalidateCollisionAdmission(uint landblockId) { ulong generation = _collisionGenerations.TryGetValue( diff --git a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs index 8d49d6b2..25e3ee4e 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs @@ -119,7 +119,11 @@ public sealed class RuntimePhysicsOwnershipTests collisionPublisher, StringComparison.Ordinal); Assert.Contains( - "AdmitCollisionAssets(", + "StageCollisionAssets(", + collisionPublisher, + StringComparison.Ordinal); + Assert.Contains( + "CommitCollisionGeneration(", collisionPublisher, StringComparison.Ordinal); Assert.DoesNotContain( diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index b05880e0..c29f2c04 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -88,6 +88,11 @@ public sealed class LandblockPhysicsPublisherTests Assert.Equal(1, diagnostics.CellSurfaceCount); Assert.Equal(1, diagnostics.PortalPlaneCount); Assert.Equal(1, diagnostics.BuildingCount); + Assert.Null(fixture.Cache.CellGraph.GetVisible(envCellId)); + Assert.Empty(fixture.Cache.BuildingIds); + + fixture.Publisher.CompletePublication(receipt); + Assert.NotNull(fixture.Cache.CellGraph.GetVisible(envCellId)); BuildingPhysics building = Assert.Single( fixture.Cache.BuildingIds.Select(id => fixture.Cache.GetBuilding(id)!)); @@ -502,6 +507,11 @@ public sealed class LandblockPhysicsPublisherTests LandblockPhysicsPublication secondReceipt = Begin( fixture.Publisher, Build(FirstLandblock, [moved])); + ShadowEntry duringPreparation = Assert.Single( + fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal( + first.Position + new Vector3(0f, 0f, 0.6f), + duringPreparation.Position); fixture.Publisher.CompletePublication(secondReceipt); Assert.Equal(1, fixture.Engine.LandblockCount); @@ -513,6 +523,27 @@ public sealed class LandblockPhysicsPublisherTests Assert.Equal(2, fixture.Publisher.Diagnostics.CompleteCount); } + [Fact] + public void CancelledPublicationDisposesOnlyItsPrivateCollisionGeneration() + { + var fixture = Fixture(); + Publish(fixture.Publisher, Build(FirstLandblock)); + Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); + + LandblockPhysicsPublication pending = Begin( + fixture.Publisher, + Build(AdjacentLandblock)); + Assert.False(pending.PreparedGeneration.IsDisposed); + + pending.Dispose(); + + Assert.True(pending.PreparedGeneration.IsDisposed); + Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); + Assert.False(fixture.Engine.IsLandblockTerrainResident(AdjacentLandblock)); + Assert.Throws(() => + fixture.Publisher.CompletePublication(pending)); + } + [Fact] public void NearReapply_RemovesOmittedStaticAcrossSeamAndPreservesNeighborOwner() { @@ -760,6 +791,17 @@ public sealed class LandblockPhysicsPublisherTests Assert.DoesNotContain("ShadowObjects.RefloodLandblock", source, StringComparison.Ordinal); Assert.DoesNotContain("_physicsEngine.DemoteLandblockToTerrain", source, StringComparison.Ordinal); Assert.DoesNotContain("_physicsEngine.RemoveLandblock", source, StringComparison.Ordinal); + + string publisherSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Streaming", + "LandblockPhysicsPublisher.cs")); + Assert.DoesNotContain("_physicsDataCache.RemoveCellsForLandblock", publisherSource, StringComparison.Ordinal); + Assert.DoesNotContain("_physicsDataCache.RemoveBuildingsForLandblock", publisherSource, StringComparison.Ordinal); + Assert.DoesNotContain("_physicsEngine.ShadowObjects.Reflood", publisherSource, StringComparison.Ordinal); + Assert.Contains("CommitCollisionGeneration(", publisherSource, StringComparison.Ordinal); } private static void Publish( diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index 795e25c4..75ce462f 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -311,27 +311,28 @@ public sealed class RuntimePhysicsStateTests first.Physics.BeginCollisionAdmission(0xA9B4FFFFu); Assert.Throws(() => - first.Physics.AdmitCollisionAssets( - admission, - CollisionAssets(0xA9B4FFFFu))); + first.Physics.PrepareCollisionGeneration(admission)); Assert.Throws(() => - second.Physics.AdmitCollisionAssets( - newer, - CollisionAssets(0xA9B4FFFFu))); + second.Physics.PrepareCollisionGeneration(newer)); - first.Physics.AdmitCollisionAssets( + using PreparedLandblockCollisionGeneration prepared = + first.Physics.PrepareCollisionGeneration(newer); + first.Physics.StageCollisionAssets( newer, + prepared, CollisionAssets(0xA9B4FFFFu)); - RuntimeCollisionAcknowledgement completed = - first.Physics.CompleteCollisionAdmission(newer); + RuntimeCollisionGenerationCommit commit = + first.Physics.CommitCollisionGeneration(newer, prepared); + RuntimeCollisionAcknowledgement completed = commit.Acknowledgement; + Assert.True(commit.Committed); Assert.True(completed.WasResident); Assert.Equal(1, first.Physics.Engine.LandblockCount); Assert.Equal( 0, first.Physics.CaptureOwnership().CollisionAdmissionCount); Assert.Throws(() => - first.Physics.CompleteCollisionAdmission(newer)); + first.Physics.CommitCollisionGeneration(newer, prepared)); RuntimeCollisionAcknowledgement withdrawn = first.Physics.WithdrawCollision(0xA9B4FFFFu); @@ -340,6 +341,193 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(0, first.Physics.Engine.LandblockCount); } + [Fact] + public void CollisionGenerationKeepsPreviousWorldVisibleUntilOneCommitNotification() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + RuntimeCollisionAdmission firstAdmission = + physics.BeginCollisionAdmission(0xA9B4FFFFu); + using (PreparedLandblockCollisionGeneration first = + physics.PrepareCollisionGeneration(firstAdmission)) + { + physics.StageCollisionAssets( + firstAdmission, + first, + CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f)); + Assert.True(physics.CommitCollisionGeneration( + firstAdmission, + first).Committed); + } + + int notifications = 0; + physics.CollisionGenerationCommitted += _ => notifications++; + RuntimeCollisionAdmission replacementAdmission = + physics.BeginCollisionAdmission(0xA9B4FFFFu); + using PreparedLandblockCollisionGeneration replacement = + physics.PrepareCollisionGeneration(replacementAdmission); + physics.StageCollisionAssets( + replacementAdmission, + replacement, + CollisionAssets(0xA9B4FFFFu, terrainHeight: 25f)); + + Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f)); + Assert.Equal(0, notifications); + + RuntimeCollisionGenerationCommit committed = + physics.CommitCollisionGeneration( + replacementAdmission, + replacement); + + Assert.True(committed.Committed); + Assert.Equal(25f, physics.Engine.SampleTerrainZ(1f, 1f)); + Assert.Equal(1, notifications); + } + + [Fact] + public void CollisionGenerationRejectsStaleReplacementWithoutMutatingActiveWorld() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + RuntimeCollisionAdmission stale = + physics.BeginCollisionAdmission(0xA9B4FFFFu); + using PreparedLandblockCollisionGeneration preparedStale = + physics.PrepareCollisionGeneration(stale); + physics.StageCollisionAssets( + stale, + preparedStale, + CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f)); + + RuntimeCollisionAdmission current = + physics.BeginCollisionAdmission(0xA9B4FFFFu); + Assert.Throws(() => + physics.CommitCollisionGeneration(stale, preparedStale)); + Assert.False(physics.Engine.IsLandblockTerrainResident(0xA9B4FFFFu)); + + using PreparedLandblockCollisionGeneration preparedCurrent = + physics.PrepareCollisionGeneration(current); + physics.StageCollisionAssets( + current, + preparedCurrent, + CollisionAssets(0xA9B4FFFFu, terrainHeight: 20f)); + Assert.True(physics.CommitCollisionGeneration( + current, + preparedCurrent).Committed); + Assert.Equal(20f, physics.Engine.SampleTerrainZ(1f, 1f)); + } + + [Fact] + public void MovingDynamicOwnerDuringStagingMustRefreshBeforeCommit() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(0x0101FFFFu); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(0x0101FFFFu, terrainHeight: 5f)); + Assert.True(physics.CommitCollisionGeneration( + initialAdmission, + initial).Committed); + } + physics.Engine.ShadowObjects.Register( + 42u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + 0x0101FFFFu, + seedCellId: 0x01010001u, + isStatic: false); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(0x0101FFFFu); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(0x0101FFFFu, terrainHeight: 15f)); + uint owner = Assert.Single(physics.CaptureCollisionDynamicOwners( + admission, + prepared)); + physics.RefreshCollisionDynamicOwner(admission, prepared, owner); + physics.Engine.ShadowObjects.UpdatePosition( + owner, + new Vector3(30f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + 0x0101FFFFu, + seedCellId: 0x01010009u); + + RuntimeCollisionGenerationCommit rejected = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(rejected.Committed); + Assert.Equal(owner, Assert.Single(rejected.DirtyDynamicOwnerIds)); + Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f)); + + physics.RefreshCollisionDynamicOwner(admission, prepared, owner); + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); + Assert.Contains( + physics.Engine.ShadowObjects.GetObjectsInCell(0x01010009u), + entry => entry.EntityId == owner); + } + + [Fact] + public void SpawnAndDeleteDuringStagingAreBothGenerationGated() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(0x0101FFFFu); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(0x0101FFFFu, terrainHeight: 12f)); + Assert.Empty(physics.CaptureCollisionDynamicOwners(admission, prepared)); + + physics.Engine.ShadowObjects.Register( + 77u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + 0x0101FFFFu, + seedCellId: 0x01010001u, + isStatic: false); + RuntimeCollisionGenerationCommit spawned = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(spawned.Committed); + Assert.Equal(77u, Assert.Single(spawned.DirtyDynamicOwnerIds)); + + physics.RefreshCollisionDynamicOwner(admission, prepared, 77u); + physics.Engine.ShadowObjects.Deregister(77u); + RuntimeCollisionGenerationCommit deleted = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(deleted.Committed); + Assert.Equal(77u, Assert.Single(deleted.DirtyDynamicOwnerIds)); + + physics.RefreshCollisionDynamicOwner(admission, prepared, 77u); + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); + } + [Fact] public void TerminalDisposalClearsLandblocksShadowsAndWorksets() { @@ -352,10 +540,13 @@ public sealed class RuntimePhysicsStateTests RuntimeCollisionAdmission admission = lifetime.Physics.BeginCollisionAdmission(0x0101FFFFu); - lifetime.Physics.AdmitCollisionAssets( + using PreparedLandblockCollisionGeneration prepared = + lifetime.Physics.PrepareCollisionGeneration(admission); + lifetime.Physics.StageCollisionAssets( admission, + prepared, CollisionAssets(0x0101FFFFu)); - _ = lifetime.Physics.CompleteCollisionAdmission(admission); + _ = lifetime.Physics.CommitCollisionGeneration(admission, prepared); lifetime.Physics.Engine.ShadowObjects.Register( entityId: record.LocalEntityId!.Value, gfxObjId: 0x01000001u, @@ -740,10 +931,12 @@ public sealed class RuntimePhysicsStateTests } private static RuntimeLandblockCollisionAssets CollisionAssets( - uint landblockId) + uint landblockId, + float terrainHeight = 0f) { var heights = new byte[81]; var table = new float[256]; + table[0] = terrainHeight; return new RuntimeLandblockCollisionAssets( landblockId, new TerrainSurface(heights, table), From d94145e6b88e38fc82f3b4b820e037f50b188bee Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 15:53:05 +0200 Subject: [PATCH 23/73] fix(physics): seal collision generations before activation --- docs/architecture/acdream-architecture.md | 21 +- .../retail-divergence-register.md | 2 +- .../2026-07-31-atomic-collision-generation.md | 43 +- memory/project_collision_port.md | 14 +- .../Streaming/LandblockPhysicsPublisher.cs | 99 ++++- .../LandblockPresentationPipeline.cs | 12 +- src/AcDream.Core/Physics/PhysicsDataCache.cs | 334 +++++++++++--- src/AcDream.Core/Physics/PhysicsEngine.cs | 86 +++- .../Physics/ShadowObjectRegistry.cs | 419 +++++++++++++----- src/AcDream.Core/World/Cells/CellGraph.cs | 139 ++++-- .../Hosting/HeadlessSessionWorldProjection.cs | 186 +++++--- .../Physics/RuntimePhysicsState.cs | 283 ++++++++++-- .../LandblockPhysicsPublisherTests.cs | 37 ++ .../HeadlessSessionHostTests.cs | 86 ++++ .../Physics/RuntimePhysicsStateTests.cs | 205 +++++++-- 15 files changed, 1556 insertions(+), 410 deletions(-) diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index d7b4cffb..d1d8dac8 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -488,14 +488,19 @@ What exists and is active: - Landblock collision activation is generation-owned by `RuntimePhysicsState`. Graphical and no-window hosts populate a private `PreparedLandblockCollisionGeneration` over bounded cursors; its cache, - `CellGraph`, engine landblock, buildings, static shadows, and dynamic-owner - refloods are never visible through the borrowed live engine. Runtime validates - the exact admission plus every affected dynamic shadow-owner version, seals - the complete replacement, and commits it synchronously on the one update - thread before emitting `CollisionGenerationCommitted`. A moving, spawned, or - deleted owner makes the generation not-ready and returns only that dirty owner - to the cursor-budgeted refresh phase. Thus readers see the complete old - generation or complete new generation, never a mixed cell/cache/shadow world. + `CellGraph`, engine landblock, buildings, static shadows, and retained-owner + refloods are never visible through the borrowed live engine. Retained owners + comprise every non-suspended dynamic or adjacent-root static touching the + target prefix (including a withdrawn repair marker); target-root statics come + from the authored replacement. Runtime mutation-gates their exact capture, + refreshes each through the host work meter, and builds every cache/graph/ + shadow replacement list through one-work-unit seal cursors. The final update- + thread activation performs one mutation-version check and installs the sealed + records without heap allocation before emitting + `CollisionGenerationCommitted`. Cancellation disposes only the named staging + generation and never withdraws the previous active world. Thus readers see + the complete old generation or complete new generation, never a mixed + cell/cache/shadow world. - `ShadowObjectRegistry` gives movement a per-cell broadphase over nearby objects and buildings. Streaming reflood is structurally part of the Runtime collision-generation commit; there is no independent post-publication diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c531da62..1b19d620 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -89,7 +89,7 @@ AD-53..AD-55 (Campaign P response-layer findings). | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | -| ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus retained dynamic-owner `recalc_cross_cells` is one Runtime-owned collision generation. App and Headless build only an off-side `PreparedLandblockCollisionGeneration`; the complete previous generation remains queryable until the one update-thread commit. Every affected dynamic owner carries an exact mutation version. Movement, spawn, or deletion during staging rejects activation and returns only dirty owners to a bounded refresh cursor; a stale admission/recenter/cancellation can dispose only its private generation. The commit installs the precomputed cell rows before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | +| ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build off-side through mutation-stable, one-work-unit capture/seal cursors; rowless owner state/payload updates, movement, spawn, deletion, and seam-static changes reject stale sealing. The complete previous generation remains queryable until one allocation-free update-thread activation. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. The commit installs precomputed cell rows and clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | | ~~AD-11~~ | **RETIRED 2026-07-23** — the matching binary disproved the old nonzero interpretation: `ItemUses::IsUseable` executes `not bitfield; and eax,1`, so absent/reset zero is usable and only `USEABLE_NO` disables use. Toolbar, item policy, and world interaction now share that exact Core predicate. | `src/AcDream.Core/Items/ClientObject.cs` (`ItemUseability.IsUseable`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs` | — | — | `ItemUses::IsUseable @ 0x004FCCC0`; matching v11.4186 instructions recorded in `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | diff --git a/docs/research/2026-07-31-atomic-collision-generation.md b/docs/research/2026-07-31-atomic-collision-generation.md index abdeb784..0e92fba1 100644 --- a/docs/research/2026-07-31-atomic-collision-generation.md +++ b/docs/research/2026-07-31-atomic-collision-generation.md @@ -25,19 +25,25 @@ The asynchronous unit is now one Runtime-owned collision generation: GfxObj/Setup catalogs are not copied; the accepted build's exact closure is populated by the existing cursors. 3. App and Headless publish terrain, EnvCells, topology, buildings, prepared - collision assets, static owners, and retained dynamic-owner cell sets only - into that private generation. -4. Each dynamic owner refresh captures its exact `ShadowObjectRegistry` - mutation version. Movement, state/payload change, spawn, suspension, or - deletion changes that version. -5. `CommitCollisionGeneration` validates the admission, exact affected-owner - set, and all captured versions. If any owner is dirty, it returns the sorted - dirty IDs without touching the active world; App advances those IDs through - its existing work meter and retries. -6. Once fresh, Runtime precomputes the replacement arrays and synchronously - replaces the landblock's cache/graph/engine/building/static and dynamic - shadow state on the same update thread. Only after the complete replacement - does it emit `CollisionGenerationCommitted` and a ready acknowledgement. + collision assets, and target-root static owners only into that private + generation. +4. A mutation-stable cursor captures every non-suspended owner that touches — + or has a withdrawn repair marker for — the target prefix. That includes + live dynamic owners and statics rooted in an adjacent landblock. Only a + target-root static is omitted, because the authored replacement supersedes + it. Each retained owner refresh captures its exact + `ShadowObjectRegistry` mutation version; a rowless withdrawn owner therefore + remains freshness-gated when state or payload changes. +5. Explicit one-work-unit cursors build the complete replacement before the + activation frame: requested global collision records, cells/topology, + buildings, cell graph removals, affected static owners, retained-owner + states, and removal lists. An active-owner mutation restarts capture and + sealing without touching the active world. +6. `CommitCollisionGeneration` performs only the final mutation-version check + and installs the already sealed replacement synchronously on the update + thread. The dense 256-owner gate measures zero managed bytes in this final + activation. Only after the complete replacement does Runtime emit + `CollisionGenerationCommitted` and a ready acknowledgement. The stable borrowed `PhysicsEngine` and `PhysicsDataCache` object identities do not change. Presentation and no-window hosts use the same Runtime transaction. @@ -47,6 +53,9 @@ shadow state. ## Failure and lifetime rules - A newer admission invalidates an older prepared generation. +- Cancellation names one admission and its private staging generation. It can + never withdraw or demote the active landblock, and cancelling a stale receipt + cannot invalidate a newer admission. - Demotion, withdrawal, reset, and disposal invalidate the admission before changing the active generation. - Disposing a stale/cancelled prepared generation clears only its private @@ -64,7 +73,15 @@ The focused Runtime/App tests pin: - stale admission replacement has no active-world side effect; - movement during staging rejects, refreshes only the dirty owner, and then installs its latest cell set; +- an authoritative state change on a retained rowless owner rejects a stale + seal and installs the refreshed state on retry; +- a neighboring static whose shadow crossed the seam is restored atomically + on reload and its withdrawn-prefix marker clears only at activation; - spawn and deletion during staging both reject stale activation; +- Headless faults immediately after admission and after staging preserve the + prior complete world and leave no collision admission behind; +- dense sealing consumes at most one work unit per call, while its final + 256-owner activation allocates zero managed bytes; - graphical and no-window publishers use the same Runtime transaction; - removal and terminal teardown converge the active ownership ledger. diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md index 1da3577c..f75de062 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -12,11 +12,15 @@ as "delete everything and start over." A partial retail transition port exists: - `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data. - Landblock streaming never edits the live collision world incrementally. `PreparedLandblockCollisionGeneration` owns an off-side cache, CellGraph, - engine snapshot, buildings, statics, and versioned dynamic-owner refloods; - `RuntimePhysicsState.CommitCollisionGeneration` validates freshness and - activates the complete generation on the single update thread. A dirty live - owner returns to the App cursor for refresh. The old generation remains - queryable until that commit. See + engine snapshot, buildings, statics, and versioned retained-owner refloods. + Retained means every non-suspended dynamic or adjacent-root static touching + (or withdrawn from) the target prefix; only authored target-root statics are + superseded. All scans and replacement construction run through bounded + capture/seal cursors. `RuntimePhysicsState.CommitCollisionGeneration` does + one final mutation-version check and activates the sealed generation without + managed allocation on the single update thread. Cancellation tears down only + the named staging generation. The old generation remains queryable until + commit. See `docs/research/2026-07-31-atomic-collision-generation.md`. - `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects. - `TerrainSurface` uses triangle-aware terrain contact. diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index ffccba70..8f34842d 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -21,6 +21,7 @@ public sealed class LandblockPhysicsPublication : IDisposable uint currentCellId, BuildingInfo[] buildings, uint[] priorStaticOwnerIds, + RuntimePhysicsState physics, RuntimeCollisionAdmission collisionAdmission, PreparedLandblockCollisionGeneration preparedGeneration) { @@ -30,6 +31,7 @@ public sealed class LandblockPhysicsPublication : IDisposable CurrentCellId = currentCellId; Buildings = buildings; PriorStaticOwnerIds = priorStaticOwnerIds; + Physics = physics; CollisionAdmission = collisionAdmission; PreparedGeneration = preparedGeneration; } @@ -39,6 +41,7 @@ public sealed class LandblockPhysicsPublication : IDisposable internal uint CurrentCellId { get; } internal BuildingInfo[] Buildings { get; } internal uint[] PriorStaticOwnerIds { get; } + internal RuntimePhysicsState Physics { get; } internal RuntimeCollisionAdmission CollisionAdmission { get; } internal PreparedLandblockCollisionGeneration PreparedGeneration { get; } internal PhysicsDataCache StagingCache => PreparedGeneration.DataCache; @@ -63,16 +66,19 @@ public sealed class LandblockPhysicsPublication : IDisposable internal int CylinderOwnerCount { get; set; } internal int NoCollisionCount { get; set; } internal int SceneryTried { get; set; } - internal uint[]? RefloodOwnerIds { get; set; } + internal IReadOnlyList? RefloodOwnerIds { get; set; } internal int RefloodCursor { get; set; } internal bool RefloodCommitted { get; set; } + internal bool SealCommitted { get; set; } internal bool BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } public void Dispose() { if (!CompletionCommitted) - PreparedGeneration.Dispose(); + Physics.CancelCollisionGeneration( + CollisionAdmission, + PreparedGeneration); } public uint LandblockId => Build.Landblock.LandblockId; @@ -217,23 +223,34 @@ public sealed class LandblockPhysicsPublisher ?? Array.Empty(); RuntimeCollisionAdmission collisionAdmission = _physics.BeginCollisionAdmission(build.Landblock.LandblockId); - var publication = new LandblockPhysicsPublication( - _receiptOwner, - build, - origin, - _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u, - buildings, - _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( - build.Landblock.LandblockId), - collisionAdmission, - _physics.PrepareCollisionGeneration(collisionAdmission)); - publication.SetupObjectIds = build.Collisions is { } collisions - ? [.. collisions.SetupIds] - : datBundle.Setups.Keys.Order().ToArray(); - publication.PreparedGeneration.SetAssetClosure( - publication.GfxObjectIds, - publication.SetupObjectIds); - return publication; + PreparedLandblockCollisionGeneration? prepared = null; + try + { + prepared = _physics.PrepareCollisionGeneration(collisionAdmission); + var publication = new LandblockPhysicsPublication( + _receiptOwner, + build, + origin, + _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u, + buildings, + _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( + build.Landblock.LandblockId), + _physics, + collisionAdmission, + prepared); + publication.SetupObjectIds = build.Collisions is { } collisions + ? [.. collisions.SetupIds] + : datBundle.Setups.Keys.Order().ToArray(); + publication.PreparedGeneration.SetAssetClosure( + publication.GfxObjectIds, + publication.SetupObjectIds); + return publication; + } + catch + { + _physics.CancelCollisionGeneration(collisionAdmission, prepared); + throw; + } } /// @@ -481,15 +498,20 @@ public sealed class LandblockPhysicsPublisher } else if (publication.RefloodOwnerIds is null) { - publication.RefloodOwnerIds = - _physics.CaptureCollisionDynamicOwners( + RuntimeCollisionOwnerCaptureStep capture = + _physics.AdvanceCollisionRetainedOwnerCapture( publication.CollisionAdmission, publication.PreparedGeneration); + if (capture.Completed) + { + publication.RefloodOwnerIds = + publication.PreparedGeneration.RetainedOwnerIds; + } } else if (publication.RefloodCursor - < publication.RefloodOwnerIds.Length) + < publication.RefloodOwnerIds.Count) { - _physics.RefreshCollisionDynamicOwner( + _physics.RefreshCollisionRetainedOwner( publication.CollisionAdmission, publication.PreparedGeneration, publication.RefloodOwnerIds[publication.RefloodCursor]); @@ -507,6 +529,25 @@ public sealed class LandblockPhysicsPublisher LogMissingSceneryBounds(landblock, publication.StagingCache); publication.RefloodCommitted = true; } + else if (!publication.SealCommitted) + { + RuntimeCollisionSealStep seal = + _physics.AdvanceCollisionGenerationSeal( + publication.CollisionAdmission, + publication.PreparedGeneration); + if (seal.WorkUnits > 1) + { + throw new InvalidOperationException( + "Collision seal exceeded its one-unit publication budget."); + } + publication.SealCommitted = seal.Completed; + if (seal.Restarted) + { + publication.RefloodOwnerIds = null; + publication.RefloodCursor = 0; + publication.RefloodCommitted = false; + } + } else { RuntimeCollisionGenerationCommit commit = @@ -515,9 +556,13 @@ public sealed class LandblockPhysicsPublisher publication.PreparedGeneration); if (!commit.Committed) { - publication.RefloodOwnerIds = commit.DirtyDynamicOwnerIds; + _physics.RestartCollisionRetainedOwnerCapture( + publication.CollisionAdmission, + publication.PreparedGeneration); + publication.RefloodOwnerIds = null; publication.RefloodCursor = 0; publication.RefloodCommitted = false; + publication.SealCommitted = false; _completePublishTicks += Stopwatch.GetTimestamp() - started; return false; } @@ -1011,5 +1056,11 @@ public sealed class LandblockPhysicsPublisher "The physics publication receipt belongs to another publisher.", nameof(publication)); } + if (!publication.CompletionCommitted) + { + ObjectDisposedException.ThrowIf( + publication.PreparedGeneration.IsDisposed, + publication); + } } } diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 6f18c36c..5f7c9fff 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -731,13 +731,15 @@ public sealed class LandblockPresentationPipeline < transaction.Build.Landblock.Entities.Count ? 1 : transaction.PhysicsPublication - .RefloodOwnerIds is not null - && transaction.PhysicsPublication + .RefloodOwnerIds is null + || transaction.PhysicsPublication .RefloodCursor < transaction.PhysicsPublication - .RefloodOwnerIds.Length - ? 1 - : 0; + .RefloodOwnerIds.Count + || !transaction.PhysicsPublication + .SealCommitted + ? 1 + : 0; if (!TryRun( new StreamingWorkCost( EntityOperations: entityOperations), diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index ba9b3e36..8be11764 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -121,31 +121,24 @@ public sealed class PhysicsDataCache return copy; } - internal PreparedPhysicsDataCacheLandblock PrepareLandblockReplacement( + internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( + PhysicsDataCache staging, uint landblockId, - ReadOnlySpan gfxObjectIds, - ReadOnlySpan setupIds) - { - uint prefix = landblockId & 0xFFFF0000u; - return new PreparedPhysicsDataCacheLandblock( - prefix, - CaptureRequested(_gfxObj, gfxObjectIds), - CaptureRequested(_visualBounds, gfxObjectIds), - CaptureRequested(_flatGfxObj, gfxObjectIds), - CaptureRequested(_setup, setupIds), - CaptureRequested(_flatSetup, setupIds), - CapturePrefix(_cellStruct, prefix), - CapturePrefix(_flatCellStruct, prefix), - CapturePrefix(_flatEnvCell, prefix), - CapturePrefix(_buildings, prefix), - CellGraph.PrepareLandblockReplacement(prefix)); - } + uint[] gfxObjectIds, + uint[] setupIds) => new( + this, + staging, + landblockId, + gfxObjectIds, + setupIds); internal void CommitLandblockReplacement( PreparedPhysicsDataCacheLandblock replacement) { - RemoveCellsForLandblock(replacement.LandblockPrefix); - RemoveBuildingsForLandblock(replacement.LandblockPrefix); + RemoveEntries(_cellStruct, replacement.CellIdsToRemove); + RemoveEntries(_flatCellStruct, replacement.FlatCellIdsToRemove); + RemoveEntries(_flatEnvCell, replacement.FlatEnvCellIdsToRemove); + RemoveEntries(_buildings, replacement.BuildingIdsToRemove); CommitEntries(_gfxObj, replacement.GfxObjects, replace: false); CommitEntries(_visualBounds, replacement.VisualBounds, replace: false); CommitEntries(_flatGfxObj, replacement.FlatGfxObjects, replace: false); @@ -166,34 +159,14 @@ public sealed class PhysicsDataCache destination.TryAdd(id, value); } - private static KeyValuePair[] CaptureRequested( - ConcurrentDictionary source, - ReadOnlySpan ids) - { - var result = new List>(ids.Length); - for (int index = 0; index < ids.Length; index++) - { - uint id = ids[index]; - if (source.TryGetValue(id, out T? value)) - result.Add(new KeyValuePair(id, value)); - } - return result.ToArray(); - } - - private static KeyValuePair[] CapturePrefix( - ConcurrentDictionary source, - uint prefix) => source - .Where(pair => (pair.Key & 0xFFFF0000u) == prefix) - .OrderBy(static pair => pair.Key) - .ToArray(); - private static void CommitEntries( ConcurrentDictionary destination, - KeyValuePair[] entries, + IReadOnlyList> entries, bool replace) { - foreach ((uint id, T value) in entries) + for (int index = 0; index < entries.Count; index++) { + (uint id, T value) = entries[index]; if (replace) destination[id] = value; else @@ -201,6 +174,14 @@ public sealed class PhysicsDataCache } } + private static void RemoveEntries( + ConcurrentDictionary destination, + IReadOnlyList ids) + { + for (int index = 0; index < ids.Count; index++) + destination.TryRemove(ids[index], out _); + } + /// /// Extract and cache the physics BSP + polygon data from a GfxObj, /// PLUS always cache a visual AABB from the vertex data regardless of @@ -1043,19 +1024,268 @@ public sealed class PhysicsDataCache /// Test helper, mirrors . public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; + + internal sealed class LandblockReplacementBuilder : IDisposable + { + private readonly PhysicsDataCache _active; + private readonly PhysicsDataCache _staging; + private readonly uint _prefix; + private readonly uint[] _gfxIds; + private readonly uint[] _setupIds; + private readonly List> _gfx = new(); + private readonly List> _bounds = new(); + private readonly List> _flatGfx = new(); + private readonly List> _setups = new(); + private readonly List> _flatSetups = new(); + private readonly List> _cells = new(); + private readonly List> _flatCells = new(); + private readonly List> _flatEnvCells = new(); + private readonly List> _buildings = new(); + private readonly HashSet _cellIds = new(); + private readonly HashSet _flatCellIds = new(); + private readonly HashSet _flatEnvCellIds = new(); + private readonly HashSet _buildingIds = new(); + private readonly List _removeCells = new(); + private readonly List _removeFlatCells = new(); + private readonly List _removeFlatEnvCells = new(); + private readonly List _removeBuildings = new(); + private readonly UcgCellGraph.LandblockReplacementBuilder _cellGraph; + private IEnumerator>? _cellEnumerator; + private IEnumerator>? _flatCellEnumerator; + private IEnumerator>? _flatEnvEnumerator; + private IEnumerator>? _buildingEnumerator; + private int _phase; + private int _cursor; + + internal LandblockReplacementBuilder( + PhysicsDataCache active, + PhysicsDataCache staging, + uint landblockId, + uint[] gfxIds, + uint[] setupIds) + { + _active = active; + _staging = staging; + _prefix = landblockId & 0xFFFF0000u; + _gfxIds = gfxIds; + _setupIds = setupIds; + _cellGraph = active.CellGraph.CreateLandblockReplacementBuilder( + staging.CellGraph, + _prefix); + } + + internal int WorkUnits { get; private set; } + internal PreparedPhysicsDataCacheLandblock? Prepared { get; private set; } + + internal bool Advance() + { + switch (_phase) + { + case 0: + if (_cursor < _gfxIds.Length) + { + uint id = _gfxIds[_cursor++]; + Capture(_staging._gfxObj, id, _gfx); + Capture(_staging._visualBounds, id, _bounds); + Capture(_staging._flatGfxObj, id, _flatGfx); + WorkUnits++; + return false; + } + _cursor = 0; + _phase++; + return false; + case 1: + if (_cursor < _setupIds.Length) + { + uint id = _setupIds[_cursor++]; + Capture(_staging._setup, id, _setups); + Capture(_staging._flatSetup, id, _flatSetups); + WorkUnits++; + return false; + } + _phase++; + return false; + case 2: + _cellEnumerator ??= _staging._cellStruct.GetEnumerator(); + if (CapturePrefixOne(_cellEnumerator, _prefix, _cells, _cellIds)) + { + WorkUnits++; + return false; + } + _cellEnumerator.Dispose(); + _cellEnumerator = null; + _phase++; + return false; + case 3: + _cellEnumerator ??= _active._cellStruct.GetEnumerator(); + if (CaptureRemovalOne(_cellEnumerator, _prefix, _cellIds, _removeCells)) + { + WorkUnits++; + return false; + } + _cellEnumerator.Dispose(); + _cellEnumerator = null; + _phase++; + return false; + case 4: + _flatCellEnumerator ??= _staging._flatCellStruct.GetEnumerator(); + if (CapturePrefixOne(_flatCellEnumerator, _prefix, _flatCells, _flatCellIds)) + { + WorkUnits++; + return false; + } + _flatCellEnumerator.Dispose(); + _flatCellEnumerator = null; + _phase++; + return false; + case 5: + _flatCellEnumerator ??= _active._flatCellStruct.GetEnumerator(); + if (CaptureRemovalOne(_flatCellEnumerator, _prefix, _flatCellIds, _removeFlatCells)) + { + WorkUnits++; + return false; + } + _flatCellEnumerator.Dispose(); + _flatCellEnumerator = null; + _phase++; + return false; + case 6: + _flatEnvEnumerator ??= _staging._flatEnvCell.GetEnumerator(); + if (CapturePrefixOne(_flatEnvEnumerator, _prefix, _flatEnvCells, _flatEnvCellIds)) + { + WorkUnits++; + return false; + } + _flatEnvEnumerator.Dispose(); + _flatEnvEnumerator = null; + _phase++; + return false; + case 7: + _flatEnvEnumerator ??= _active._flatEnvCell.GetEnumerator(); + if (CaptureRemovalOne(_flatEnvEnumerator, _prefix, _flatEnvCellIds, _removeFlatEnvCells)) + { + WorkUnits++; + return false; + } + _flatEnvEnumerator.Dispose(); + _flatEnvEnumerator = null; + _phase++; + return false; + case 8: + _buildingEnumerator ??= _staging._buildings.GetEnumerator(); + if (CapturePrefixOne(_buildingEnumerator, _prefix, _buildings, _buildingIds)) + { + WorkUnits++; + return false; + } + _buildingEnumerator.Dispose(); + _buildingEnumerator = null; + _phase++; + return false; + case 9: + _buildingEnumerator ??= _active._buildings.GetEnumerator(); + if (CaptureRemovalOne(_buildingEnumerator, _prefix, _buildingIds, _removeBuildings)) + { + WorkUnits++; + return false; + } + _buildingEnumerator.Dispose(); + _buildingEnumerator = null; + _phase++; + return false; + case 10: + WorkUnits++; + if (!_cellGraph.Advance()) + return false; + Prepared = new PreparedPhysicsDataCacheLandblock( + _prefix, + _gfx, + _bounds, + _flatGfx, + _setups, + _flatSetups, + _removeCells, + _cells, + _removeFlatCells, + _flatCells, + _removeFlatEnvCells, + _flatEnvCells, + _removeBuildings, + _buildings, + _cellGraph.Prepared!); + _phase++; + return true; + default: + return true; + } + } + + private static void Capture( + ConcurrentDictionary source, + uint id, + List> destination) + { + if (source.TryGetValue(id, out T? value)) + destination.Add(new KeyValuePair(id, value)); + } + + private static bool CapturePrefixOne( + IEnumerator> enumerator, + uint prefix, + List> destination, + HashSet ids) + { + if (!enumerator.MoveNext()) + return false; + KeyValuePair pair = enumerator.Current; + if ((pair.Key & 0xFFFF0000u) == prefix) + { + destination.Add(pair); + ids.Add(pair.Key); + } + return true; + } + + private static bool CaptureRemovalOne( + IEnumerator> enumerator, + uint prefix, + HashSet retained, + List destination) + { + if (!enumerator.MoveNext()) + return false; + uint id = enumerator.Current.Key; + if ((id & 0xFFFF0000u) == prefix && !retained.Contains(id)) + destination.Add(id); + return true; + } + + public void Dispose() + { + _cellEnumerator?.Dispose(); + _flatCellEnumerator?.Dispose(); + _flatEnvEnumerator?.Dispose(); + _buildingEnumerator?.Dispose(); + _cellGraph.Dispose(); + } + } } internal sealed record PreparedPhysicsDataCacheLandblock( uint LandblockPrefix, - KeyValuePair[] GfxObjects, - KeyValuePair[] VisualBounds, - KeyValuePair[] FlatGfxObjects, - KeyValuePair[] Setups, - KeyValuePair[] FlatSetups, - KeyValuePair[] Cells, - KeyValuePair[] FlatCells, - KeyValuePair[] FlatEnvCells, - KeyValuePair[] Buildings, + IReadOnlyList> GfxObjects, + IReadOnlyList> VisualBounds, + IReadOnlyList> FlatGfxObjects, + IReadOnlyList> Setups, + IReadOnlyList> FlatSetups, + IReadOnlyList CellIdsToRemove, + IReadOnlyList> Cells, + IReadOnlyList FlatCellIdsToRemove, + IReadOnlyList> FlatCells, + IReadOnlyList FlatEnvCellIdsToRemove, + IReadOnlyList> FlatEnvCells, + IReadOnlyList BuildingIdsToRemove, + IReadOnlyList> Buildings, PreparedCellGraphLandblock CellGraph); /// diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 017b44c1..cd7e6b07 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -182,12 +182,12 @@ public sealed class PhysicsEngine return staging; } - internal PreparedPhysicsEngineLandblock PrepareLandblockReplacement( + internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( PhysicsEngine staging, uint landblockId, - ReadOnlySpan gfxObjectIds, - ReadOnlySpan setupIds, - IReadOnlyDictionary expectedDynamicVersions) + uint[] gfxObjectIds, + uint[] setupIds, + IReadOnlyDictionary expectedRetainedVersions) { ArgumentNullException.ThrowIfNull(staging); uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; @@ -201,31 +201,25 @@ public sealed class PhysicsEngine PhysicsDataCache stagingCache = staging.DataCache ?? throw new InvalidOperationException( "Staging collision engine has no data cache."); - return new PreparedPhysicsEngineLandblock( + return new LandblockReplacementBuilder( canonical, landblock, - stagingCache.PrepareLandblockReplacement( + (DataCache ?? throw new InvalidOperationException( + "Active collision engine has no data cache.")) + .CreateLandblockReplacementBuilder( + stagingCache, canonical, gfxObjectIds, setupIds), - ShadowObjects.PrepareLandblockReplacement( + ShadowObjects.CreateLandblockReplacementBuilder( staging.ShadowObjects, canonical, - expectedDynamicVersions)); + expectedRetainedVersions)); } - internal bool ValidateLandblockReplacement( - PreparedPhysicsEngineLandblock replacement) => - ShadowObjects.ValidateLandblockReplacement(replacement.Shadows); - internal void CommitLandblockReplacement( PreparedPhysicsEngineLandblock replacement) { - if (!ValidateLandblockReplacement(replacement)) - { - throw new InvalidOperationException( - "Collision generation changed after it was sealed."); - } (DataCache ?? throw new InvalidOperationException( "Active collision engine has no data cache.")) .CommitLandblockReplacement(replacement.DataCache); @@ -253,6 +247,64 @@ public sealed class PhysicsEngine internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; } } + internal sealed class LandblockReplacementBuilder : IDisposable + { + private readonly uint _landblockId; + private readonly LandblockPhysics _landblock; + private readonly PhysicsDataCache.LandblockReplacementBuilder _data; + private readonly ShadowObjectRegistry.LandblockReplacementBuilder _shadows; + private int _phase; + + internal LandblockReplacementBuilder( + uint landblockId, + LandblockPhysics landblock, + PhysicsDataCache.LandblockReplacementBuilder data, + ShadowObjectRegistry.LandblockReplacementBuilder shadows) + { + _landblockId = landblockId; + _landblock = landblock; + _data = data; + _shadows = shadows; + } + + internal int WorkUnits => _data.WorkUnits + _shadows.WorkUnits; + internal bool IsStable => _shadows.IsStable; + internal PreparedPhysicsEngineLandblock? Prepared { get; private set; } + + internal bool Advance() + { + if (_phase == 0) + { + if (!_data.Advance()) + return false; + _phase++; + return false; + } + if (_phase == 1) + { + if (!_shadows.Advance()) + return false; + if (IsStable && _data.Prepared is not null + && _shadows.Prepared is not null) + { + Prepared = new PreparedPhysicsEngineLandblock( + _landblockId, + _landblock, + _data.Prepared, + _shadows.Prepared); + } + _phase++; + } + return true; + } + + public void Dispose() + { + _data.Dispose(); + _shadows.Dispose(); + } + } + /// /// Register a landblock with its terrain surface, indoor cells, portal /// planes, and world-space origin offset. diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 6e7afc6d..2c2d6fdc 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -52,6 +52,7 @@ public sealed class ShadowObjectRegistry /// private readonly Dictionary _entityReg = new(); private readonly Dictionary _ownerVersions = new(); + private ulong _mutationVersion; internal sealed record RegistrationRecord( uint SeedCellId, @@ -76,8 +77,88 @@ public sealed class ShadowObjectRegistry private void BumpOwnerVersion(uint entityId) { _ownerVersions[entityId] = checked(GetOwnerVersion(entityId) + 1UL); + _mutationVersion = checked(_mutationVersion + 1UL); } + internal ulong MutationVersion => _mutationVersion; + + internal RetainedRefloodOwnerScan CreateRetainedRefloodOwnerScan( + uint landblockId) => new(this, landblockId & 0xFFFF0000u); + + internal sealed class RetainedRefloodOwnerScan : IDisposable + { + private readonly ShadowObjectRegistry _owner; + private readonly uint _prefix; + private readonly ulong _sourceMutationVersion; + private Dictionary.Enumerator _enumerator; + private bool _completed; + + internal RetainedRefloodOwnerScan( + ShadowObjectRegistry owner, + uint prefix) + { + _owner = owner; + _prefix = prefix; + _sourceMutationVersion = owner.MutationVersion; + _enumerator = owner._entityReg.GetEnumerator(); + } + + internal RetainedRefloodOwnerScanStep Advance() + { + if (_completed) + { + return new RetainedRefloodOwnerScanStep( + Completed: true, + Stable: _owner.MutationVersion == _sourceMutationVersion, + HasOwner: false, + OwnerId: 0u, + SourceMutationVersion: _sourceMutationVersion); + } + if (_owner.MutationVersion != _sourceMutationVersion) + { + _completed = true; + return new RetainedRefloodOwnerScanStep( + Completed: true, + Stable: false, + HasOwner: false, + OwnerId: 0u, + SourceMutationVersion: _sourceMutationVersion); + } + if (!_enumerator.MoveNext()) + { + _completed = true; + return new RetainedRefloodOwnerScanStep( + Completed: true, + Stable: true, + HasOwner: false, + OwnerId: 0u, + SourceMutationVersion: _sourceMutationVersion); + } + + (uint ownerId, RegistrationRecord registration) = + _enumerator.Current; + bool retained = !_owner._suspendedEntities.Contains(ownerId) + && (!registration.IsStatic + || (registration.SeedCellId & 0xFFFF0000u) != _prefix) + && _owner.OwnerTouchesLandblock(ownerId, _prefix); + return new RetainedRefloodOwnerScanStep( + Completed: false, + Stable: true, + HasOwner: retained, + OwnerId: retained ? ownerId : 0u, + SourceMutationVersion: _sourceMutationVersion); + } + + public void Dispose() => _enumerator.Dispose(); + } + + internal readonly record struct RetainedRefloodOwnerScanStep( + bool Completed, + bool Stable, + bool HasOwner, + uint OwnerId, + ulong SourceMutationVersion); + /// /// The flood's data source (cells, buildings, terrain origins). Wired by /// when its own DataCache is set. @@ -624,8 +705,14 @@ public sealed class ShadowObjectRegistry { // Suspended dynamic objects have no cell rows, but their retained // registration must still receive authoritative state changes. - if (_entityReg.TryGetValue(entityId, out var retainedRegistration)) - _entityReg[entityId] = retainedRegistration with { State = newState }; + bool retained = _entityReg.TryGetValue( + entityId, + out RegistrationRecord? retainedRegistration); + if (retained) + { + _entityReg[entityId] = retainedRegistration! with { State = newState }; + BumpOwnerVersion(entityId); + } if (!_entityToCells.TryGetValue(entityId, out var cellIds)) return; // not registered — no-op @@ -640,9 +727,6 @@ public sealed class ShadowObjectRegistry } } - if (_entityReg.TryGetValue(entityId, out var reg)) - _entityReg[entityId] = reg with { State = newState }; - BumpOwnerVersion(entityId); } /// Remove an entity from all cells it was registered in. @@ -657,7 +741,7 @@ public sealed class ShadowObjectRegistry foreach (var cellId in cellIds) { if (_cells.TryGetValue(cellId, out var list)) - list.RemoveAll(e => e.EntityId == entityId); + RemoveOwnerRows(list, entityId); } _entityToCells.Remove(entityId); } @@ -669,6 +753,17 @@ public sealed class ShadowObjectRegistry BumpOwnerVersion(entityId); } + private static void RemoveOwnerRows( + List entries, + uint entityId) + { + for (int index = entries.Count - 1; index >= 0; index--) + { + if (entries[index].EntityId == entityId) + entries.RemoveAt(index); + } + } + /// /// Logically tear down every static object owned by a landblock, including /// shadow rows flooded into adjacent landblocks. Dynamic/server-live owners @@ -824,6 +919,11 @@ public sealed class ShadowObjectRegistry /// Suspended logical registrations awaiting spatial re-entry. public int SuspendedRegistrationCount => _suspendedEntities.Count; + public bool HasOwnerRowsInLandblock(uint ownerId, uint landblockId) => + _entityToCells.TryGetValue(ownerId, out List? cells) + && cells.Exists(cell => + (cell & 0xFFFF0000u) == (landblockId & 0xFFFF0000u)); + /// /// Copies the committed registry into an off-side collision generation. /// All mutable lists and sets are cloned; immutable registration and shape @@ -860,16 +960,7 @@ public sealed class ShadowObjectRegistry } foreach ((uint ownerId, ulong version) in source._ownerVersions) _ownerVersions[ownerId] = version; - } - - internal uint[] CaptureDynamicRefloodOwnersForLandblock( - uint landblockId) - { - uint[] owners = CaptureRefloodOwnersForLandblock(landblockId); - return owners.Where(ownerId => - _entityReg.TryGetValue(ownerId, out RegistrationRecord? record) - && !record.IsStatic) - .ToArray(); + _mutationVersion = source._mutationVersion; } /// @@ -877,7 +968,7 @@ public sealed class ShadowObjectRegistry /// it against the staging generation's complete cell graph. The returned /// source version is the commit-time freshness token. /// - internal bool RefreshDynamicOwnerFrom( + internal bool RefreshRetainedOwnerFrom( ShadowObjectRegistry source, uint entityId, uint landblockId, @@ -889,8 +980,10 @@ public sealed class ShadowObjectRegistry if (!source._entityReg.TryGetValue( entityId, out RegistrationRecord? registration) - || registration.IsStatic || source._suspendedEntities.Contains(entityId) + || (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) + == (landblockId & 0xFFFF0000u)) || !source.OwnerTouchesLandblock(entityId, landblockId)) { return false; @@ -912,7 +1005,7 @@ public sealed class ShadowObjectRegistry 0f, landblockId, registration.SeedCellId, - isStatic: false); + isStatic: registration.IsStatic); } else { @@ -931,92 +1024,42 @@ public sealed class ShadowObjectRegistry registration.State, registration.Flags, registration.SeedCellId, - isStatic: false); + isStatic: registration.IsStatic); + } + + if (source._withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? sourceWithdrawn)) + { + var retainedWithdrawn = new HashSet(sourceWithdrawn); + uint prefix = landblockId & 0xFFFF0000u; + if (_entityToCells.TryGetValue(entityId, out List? cells) + && cells.Exists(cell => (cell & 0xFFFF0000u) == prefix)) + { + retainedWithdrawn.Remove(prefix); + } + if (retainedWithdrawn.Count != 0) + _withdrawnPrefixesByOwner[entityId] = retainedWithdrawn; } return true; } - internal uint[] FindDirtyDynamicOwners( - uint landblockId, - IReadOnlyDictionary expectedVersions) - { - var dirty = new HashSet( - CaptureDynamicRefloodOwnersForLandblock(landblockId)); - dirty.UnionWith(expectedVersions.Keys); - dirty.RemoveWhere(ownerId => - expectedVersions.TryGetValue(ownerId, out ulong expected) - && OwnerTouchesLandblock(ownerId, landblockId) - && GetOwnerVersion(ownerId) == expected); - uint[] result = dirty.ToArray(); - Array.Sort(result); - return result; - } - - internal PreparedLandblockShadowReplacement PrepareLandblockReplacement( + internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( ShadowObjectRegistry staging, uint landblockId, - IReadOnlyDictionary expectedDynamicVersions) - { - ArgumentNullException.ThrowIfNull(staging); - uint[] dirty = FindDirtyDynamicOwners( + IReadOnlyDictionary expectedRetainedVersions) => new( + this, + staging, landblockId, - expectedDynamicVersions); - if (dirty.Length != 0) - { - throw new InvalidOperationException( - "Dynamic shadow owners changed before collision generation sealing."); - } - - var owners = new HashSet(CaptureStaticOwnersForLandblock(landblockId)); - owners.UnionWith(staging.CaptureStaticOwnersForLandblock(landblockId)); - owners.UnionWith(expectedDynamicVersions.Keys); - uint[] ownerIds = owners.ToArray(); - Array.Sort(ownerIds); - var states = new List(ownerIds.Length); - foreach (uint ownerId in ownerIds) - { - if (staging.TryCaptureOwnerState(ownerId, out PreparedShadowOwnerState? state) - && state is not null) - states.Add(state); - } - return new PreparedLandblockShadowReplacement( - landblockId & 0xFFFF0000u, - ownerIds, - states.ToArray(), - expectedDynamicVersions.ToDictionary( - static pair => pair.Key, - static pair => pair.Value)); - } - - internal bool ValidateLandblockReplacement( - PreparedLandblockShadowReplacement replacement) - { - foreach ((uint ownerId, ulong version) in replacement.DynamicVersions) - { - if (GetOwnerVersion(ownerId) != version - || !OwnerTouchesLandblock(ownerId, replacement.LandblockPrefix)) - { - return false; - } - } - return CaptureDynamicRefloodOwnersForLandblock( - replacement.LandblockPrefix) - .SequenceEqual(replacement.DynamicVersions.Keys.Order()); - } + expectedRetainedVersions); internal void CommitLandblockReplacement( PreparedLandblockShadowReplacement replacement) { - if (!ValidateLandblockReplacement(replacement)) - { - throw new InvalidOperationException( - "Dynamic shadow owners changed before collision generation commit."); - } - - foreach (uint ownerId in replacement.OwnerIds) - Deregister(ownerId); - foreach (PreparedShadowOwnerState state in replacement.OwnerStates) - InstallOwnerState(state); + for (int index = 0; index < replacement.OwnerIds.Count; index++) + Deregister(replacement.OwnerIds[index]); + for (int index = 0; index < replacement.OwnerStates.Count; index++) + InstallOwnerState(replacement.OwnerStates[index]); } private bool OwnerTouchesLandblock(uint entityId, uint landblockId) @@ -1071,10 +1114,10 @@ public sealed class ShadowObjectRegistry entityId, registration, shapes, - cells?.ToArray() ?? Array.Empty(), - rows.ToArray(), + cells is null ? null : new List(cells), + rows, _suspendedEntities.Contains(entityId), - withdrawn?.ToArray() ?? Array.Empty()); + withdrawn is null ? null : new HashSet(withdrawn)); return true; } @@ -1085,49 +1128,194 @@ public sealed class ShadowObjectRegistry _entityShapes[state.EntityId] = state.Shapes; if (state.Suspended) _suspendedEntities.Add(state.EntityId); - if (state.WithdrawnPrefixes.Length != 0) + if (state.WithdrawnPrefixes is not null) { - _withdrawnPrefixesByOwner[state.EntityId] = - new HashSet(state.WithdrawnPrefixes); + _withdrawnPrefixesByOwner[state.EntityId] = state.WithdrawnPrefixes; } - if (state.CellIds.Length != 0) - _entityToCells[state.EntityId] = new List(state.CellIds); - foreach (PreparedShadowCellRows row in state.Rows) + if (state.CellIds is not null) + _entityToCells[state.EntityId] = state.CellIds; + for (int rowIndex = 0; rowIndex < state.Rows.Count; rowIndex++) { - foreach (ShadowEntry entry in row.Entries) - AddEntryToCell(entry, row.CellId); + PreparedShadowCellRows row = state.Rows[rowIndex]; + for (int entryIndex = 0; entryIndex < row.Entries.Length; entryIndex++) + AddEntryToCell(row.Entries[entryIndex], row.CellId); } BumpOwnerVersion(state.EntityId); } + internal sealed class LandblockReplacementBuilder : IDisposable + { + private readonly ShadowObjectRegistry _active; + private readonly ShadowObjectRegistry _staging; + private readonly uint _prefix; + private readonly ulong _sourceMutationVersion; + private readonly HashSet _owners = new(); + private readonly List _ownerIds = new(); + private readonly List _states = new(); + private IEnumerator>? _expectedEnumerator; + private Dictionary.Enumerator _registrationEnumerator; + private HashSet.Enumerator _ownerEnumerator; + private int _phase; + + internal LandblockReplacementBuilder( + ShadowObjectRegistry active, + ShadowObjectRegistry staging, + uint landblockId, + IReadOnlyDictionary expected) + { + _active = active; + _staging = staging; + _prefix = landblockId & 0xFFFF0000u; + _sourceMutationVersion = active.MutationVersion; + _expectedEnumerator = expected.GetEnumerator(); + } + + internal int WorkUnits { get; private set; } + internal bool IsStable => + _active.MutationVersion == _sourceMutationVersion; + internal PreparedLandblockShadowReplacement? Prepared { get; private set; } + + internal bool Advance() + { + if (!IsStable) + return true; + switch (_phase) + { + case 0: + if (_expectedEnumerator!.MoveNext()) + { + (uint ownerId, ulong version) = _expectedEnumerator.Current; + if (_active.GetOwnerVersion(ownerId) != version + || !_active.IsRetainedRefloodOwner(ownerId, _prefix)) + { + return true; + } + AddOwner(ownerId); + WorkUnits++; + return false; + } + _expectedEnumerator.Dispose(); + _expectedEnumerator = null; + _registrationEnumerator = _active._entityReg.GetEnumerator(); + _phase++; + return false; + case 1: + if (_registrationEnumerator.MoveNext()) + { + (uint ownerId, RegistrationRecord registration) = + _registrationEnumerator.Current; + if (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) == _prefix) + { + AddOwner(ownerId); + } + WorkUnits++; + return false; + } + _registrationEnumerator.Dispose(); + _registrationEnumerator = _staging._entityReg.GetEnumerator(); + _phase++; + return false; + case 2: + if (_registrationEnumerator.MoveNext()) + { + (uint ownerId, RegistrationRecord registration) = + _registrationEnumerator.Current; + if (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) == _prefix) + { + AddOwner(ownerId); + } + WorkUnits++; + return false; + } + _registrationEnumerator.Dispose(); + _ownerEnumerator = _owners.GetEnumerator(); + _phase++; + return false; + case 3: + if (_ownerEnumerator.MoveNext()) + { + uint ownerId = _ownerEnumerator.Current; + if (_staging.TryCaptureOwnerState( + ownerId, + out PreparedShadowOwnerState? state) + && state is not null) + { + _states.Add(state); + } + WorkUnits++; + return false; + } + _ownerEnumerator.Dispose(); + if (IsStable) + { + Prepared = new PreparedLandblockShadowReplacement( + _prefix, + _ownerIds, + _states); + } + _phase++; + return true; + default: + return true; + } + } + + private void AddOwner(uint ownerId) + { + if (_owners.Add(ownerId)) + _ownerIds.Add(ownerId); + } + + public void Dispose() + { + _expectedEnumerator?.Dispose(); + if (_phase is 1 or 2) + _registrationEnumerator.Dispose(); + if (_phase == 3) + _ownerEnumerator.Dispose(); + } + } + + private bool IsRetainedRefloodOwner(uint ownerId, uint landblockId) + { + if (!_entityReg.TryGetValue(ownerId, out RegistrationRecord? registration) + || _suspendedEntities.Contains(ownerId) + || (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) + == (landblockId & 0xFFFF0000u))) + { + return false; + } + return OwnerTouchesLandblock(ownerId, landblockId); + } + internal sealed class PreparedLandblockShadowReplacement { internal PreparedLandblockShadowReplacement( uint landblockPrefix, - uint[] ownerIds, - PreparedShadowOwnerState[] ownerStates, - Dictionary dynamicVersions) + IReadOnlyList ownerIds, + IReadOnlyList ownerStates) { LandblockPrefix = landblockPrefix; OwnerIds = ownerIds; OwnerStates = ownerStates; - DynamicVersions = dynamicVersions; } internal uint LandblockPrefix { get; } - internal uint[] OwnerIds { get; } - internal PreparedShadowOwnerState[] OwnerStates { get; } - internal Dictionary DynamicVersions { get; } + internal IReadOnlyList OwnerIds { get; } + internal IReadOnlyList OwnerStates { get; } } internal sealed record PreparedShadowOwnerState( uint EntityId, RegistrationRecord Registration, IReadOnlyList? Shapes, - uint[] CellIds, - PreparedShadowCellRows[] Rows, + List? CellIds, + IReadOnlyList Rows, bool Suspended, - uint[] WithdrawnPrefixes); + HashSet? WithdrawnPrefixes); internal sealed record PreparedShadowCellRows( uint CellId, @@ -1146,6 +1334,7 @@ public sealed class ShadowObjectRegistry _entityShapes.Clear(); _entityReg.Clear(); _ownerVersions.Clear(); + _mutationVersion = 0UL; _fallback = null; } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 9d9e82ea..021c6d46 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -19,7 +19,7 @@ namespace AcDream.Core.World.Cells; public sealed class CellGraph { private readonly ConcurrentDictionary _envCells = new(); - private readonly ConcurrentDictionary _terrain = new(); + private readonly ConcurrentDictionary _terrain = new(); /// The player's current cell — the render/lighting root. Written ONLY at the /// player chokepoint @@ -34,7 +34,8 @@ public sealed class CellGraph /// Any id in the cell's landblock; masked to (id & 0xFFFF0000). public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin) - => _terrain[landblockPrefix & 0xFFFF0000u] = (terrain, worldOrigin); + => _terrain[landblockPrefix & 0xFFFF0000u] = + new CellGraphTerrain(terrain, worldOrigin); /// /// World origin (SW corner) of the landblock containing , @@ -137,46 +138,36 @@ public sealed class CellGraph var copy = new CellGraph { CurrCell = CurrCell }; foreach ((uint id, EnvCell cell) in _envCells) copy._envCells.TryAdd(id, cell); - foreach ((uint id, (TerrainSurface Terrain, Vector3 Origin) terrain) in - _terrain) + foreach ((uint id, CellGraphTerrain terrain) in _terrain) { copy._terrain.TryAdd(id, terrain); } return copy; } - internal PreparedCellGraphLandblock PrepareLandblockReplacement( - uint landblockId) - { - uint prefix = landblockId & 0xFFFF0000u; - KeyValuePair[] envCells = _envCells - .Where(static pair => (pair.Key & 0xFFFFu) >= 0x0100u) - .Where(pair => (pair.Key & 0xFFFF0000u) == prefix) - .OrderBy(static pair => pair.Key) - .ToArray(); - bool hasTerrain = _terrain.TryGetValue(prefix, out var terrain); - return new PreparedCellGraphLandblock( - prefix, - envCells, - hasTerrain, - terrain.Terrain, - terrain.Origin, - CurrCell?.Id ?? 0u); - } + internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( + CellGraph staging, + uint landblockId) => new(this, staging, landblockId); internal void CommitLandblockReplacement( PreparedCellGraphLandblock replacement) { uint currentCellId = CurrCell?.Id ?? 0u; - RemoveLandblock(replacement.LandblockPrefix); + for (int index = 0; index < replacement.EnvCellIdsToRemove.Count; index++) + _envCells.TryRemove(replacement.EnvCellIdsToRemove[index], out _); if (replacement.HasTerrain) { - _terrain[replacement.LandblockPrefix] = ( - replacement.Terrain!, - replacement.Origin); + _terrain[replacement.LandblockPrefix] = replacement.Terrain!; } - foreach ((uint id, EnvCell cell) in replacement.EnvCells) + else + { + _terrain.TryRemove(replacement.LandblockPrefix, out _); + } + for (int index = 0; index < replacement.EnvCells.Count; index++) + { + (uint id, EnvCell cell) = replacement.EnvCells[index]; _envCells[id] = cell; + } uint desiredCurrentCellId = (currentCellId & 0xFFFF0000u) == replacement.LandblockPrefix @@ -188,13 +179,101 @@ public sealed class CellGraph : 0u; if (desiredCurrentCellId != 0u) CurrCell = GetVisible(desiredCurrentCellId); + else if ((currentCellId & 0xFFFF0000u) + == replacement.LandblockPrefix) + CurrCell = null; + } + + internal sealed class LandblockReplacementBuilder : IDisposable + { + private readonly CellGraph _active; + private readonly CellGraph _staging; + private readonly uint _prefix; + private readonly List> _envCells = new(); + private readonly HashSet _stagingIds = new(); + private readonly List _removeIds = new(); + private IEnumerator>? _enumerator; + private int _phase; + + internal LandblockReplacementBuilder( + CellGraph active, + CellGraph staging, + uint landblockId) + { + _active = active; + _staging = staging; + _prefix = landblockId & 0xFFFF0000u; + _enumerator = staging._envCells.GetEnumerator(); + } + + internal bool Advance() + { + if (_phase == 0) + { + if (_enumerator!.MoveNext()) + { + KeyValuePair pair = _enumerator.Current; + if ((pair.Key & 0xFFFF0000u) == _prefix + && (pair.Key & 0xFFFFu) >= 0x0100u) + { + _envCells.Add(pair); + _stagingIds.Add(pair.Key); + } + return false; + } + _enumerator.Dispose(); + _enumerator = _active._envCells.GetEnumerator(); + _phase = 1; + return false; + } + if (_phase == 1) + { + if (_enumerator!.MoveNext()) + { + uint id = _enumerator.Current.Key; + if ((id & 0xFFFF0000u) == _prefix + && (id & 0xFFFFu) >= 0x0100u + && !_stagingIds.Contains(id)) + { + _removeIds.Add(id); + } + return false; + } + _enumerator.Dispose(); + _enumerator = null; + bool hasTerrain = _staging._terrain.TryGetValue( + _prefix, + out var terrain); + Prepared = new PreparedCellGraphLandblock( + _prefix, + _removeIds, + _envCells, + hasTerrain, + terrain, + _staging.CurrCell?.Id ?? 0u); + _phase = 2; + } + return true; + } + + internal PreparedCellGraphLandblock? Prepared { get; private set; } + + public void Dispose() + { + _enumerator?.Dispose(); + _enumerator = null; + } } } internal sealed record PreparedCellGraphLandblock( uint LandblockPrefix, - KeyValuePair[] EnvCells, + IReadOnlyList EnvCellIdsToRemove, + IReadOnlyList> EnvCells, bool HasTerrain, - TerrainSurface? Terrain, - Vector3 Origin, + CellGraphTerrain? Terrain, uint CurrentCellId); + +internal sealed record CellGraphTerrain( + TerrainSurface Terrain, + Vector3 Origin); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index 4baa3bdc..c903e04a 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -18,6 +18,77 @@ internal interface IHeadlessCollisionNeighborhood bool IsReady(uint fullCellId); } +internal static class HeadlessCollisionGenerationTransaction +{ + internal static RuntimeCollisionGenerationCommit Execute( + RuntimePhysicsState physics, + uint landblockId, + Action? afterAdmission, + Action stage) + { + ArgumentNullException.ThrowIfNull(physics); + ArgumentNullException.ThrowIfNull(stage); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(landblockId); + PreparedLandblockCollisionGeneration? prepared = null; + bool committed = false; + try + { + // This hook exists so the exact post-admission/pre-prepare failure + // boundary remains covered. Production does not install one. + afterAdmission?.Invoke(admission); + prepared = physics.PrepareCollisionGeneration(admission); + stage(admission, prepared); + + RuntimeCollisionOwnerCaptureStep ownerCapture; + do + { + ownerCapture = physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared); + } + while (!ownerCapture.Completed); + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (!seal.Completed) + { + throw new InvalidOperationException( + "Headless collision owner set changed during synchronous sealing."); + } + + RuntimeCollisionGenerationCommit result = + physics.CommitCollisionGeneration(admission, prepared); + if (!result.Committed) + { + throw new InvalidOperationException( + "Headless collision generation changed during synchronous publication."); + } + committed = true; + return result; + } + finally + { + if (!committed) + physics.CancelCollisionGeneration(admission, prepared); + } + } +} + /// /// Per-session mutable collision publication over process-shared immutable /// DAT and pak inputs. Every session retains its own engine, data cache, @@ -172,81 +243,58 @@ internal sealed class HeadlessCollisionNeighborhood _content.PreparedCollision, landblock); - RuntimePhysicsState physics = - _runtime.EntityObjects.Physics; - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(landblockId); - using PreparedLandblockCollisionGeneration prepared = - physics.PrepareCollisionGeneration(admission); - prepared.SetAssetClosure( - [.. collisions.GfxObjIds], - [.. collisions.SetupIds]); - PhysicsDataCache cache = prepared.DataCache; - TerrainSurface terrain = - LandblockPhysicsContentBuilder.BuildTerrainSurface( - landblock, - _content.HeightTable.AsSpan()); - var cellSurfaces = new List(); - var portalPlanes = new List(); - LandblockPhysicsContentBuilder.PublishPreparedCells( - cache, - landblock, - collisions, - origin, - cellSurfaces, - portalPlanes); - LandblockPhysicsContentBuilder.CacheBuildings( - cache, - landblock, - terrain, - origin); - LandblockPhysicsContentBuilder.CachePreparedObjects( - cache, - collisions); - - try - { - physics.StageCollisionAssets( - admission, - prepared, - new RuntimeLandblockCollisionAssets( - landblockId, - terrain, - cellSurfaces, - portalPlanes, - origin.X, - origin.Y, - currentCellId)); - _ = LandblockPhysicsContentBuilder - .PublishStaticCollision( - prepared.Engine, + RuntimePhysicsState physics = _runtime.EntityObjects.Physics; + _ = HeadlessCollisionGenerationTransaction.Execute( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + { + prepared.SetAssetClosure( + [.. collisions.GfxObjIds], + [.. collisions.SetupIds]); + PhysicsDataCache cache = prepared.DataCache; + TerrainSurface terrain = + LandblockPhysicsContentBuilder.BuildTerrainSurface( + landblock, + _content.HeightTable.AsSpan()); + var cellSurfaces = new List(); + var portalPlanes = new List(); + LandblockPhysicsContentBuilder.PublishPreparedCells( cache, landblock, collisions, + origin, + cellSurfaces, + portalPlanes); + LandblockPhysicsContentBuilder.CacheBuildings( + cache, + landblock, + terrain, origin); - foreach (uint ownerId in physics.CaptureCollisionDynamicOwners( - admission, - prepared)) - { - physics.RefreshCollisionDynamicOwner( + LandblockPhysicsContentBuilder.CachePreparedObjects( + cache, + collisions); + physics.StageCollisionAssets( admission, prepared, - ownerId); - } - RuntimeCollisionGenerationCommit commit = - physics.CommitCollisionGeneration(admission, prepared); - if (!commit.Committed) - { - throw new InvalidOperationException( - "Headless collision generation changed during synchronous publication."); - } - _resident.Add(CanonicalLandblock(landblockId)); - } - catch - { - _ = physics.WithdrawCollision(landblockId); - throw; - } + new RuntimeLandblockCollisionAssets( + landblockId, + terrain, + cellSurfaces, + portalPlanes, + origin.X, + origin.Y, + currentCellId)); + _ = LandblockPhysicsContentBuilder + .PublishStaticCollision( + prepared.Engine, + cache, + landblock, + collisions, + origin); + }); + _resident.Add(CanonicalLandblock(landblockId)); } private void RetireAll() diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index ba56b41b..e95dfcae 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -68,7 +68,7 @@ public readonly record struct RuntimeCollisionAcknowledgement( public readonly record struct RuntimeCollisionGenerationCommit( RuntimeCollisionAcknowledgement Acknowledgement, - uint[] DirtyDynamicOwnerIds) + uint[] DirtyRetainedOwnerIds) { public bool Committed => Acknowledgement.Ready; } @@ -87,7 +87,11 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable { private readonly RuntimePhysicsState _owner; private readonly RuntimeCollisionAdmission _admission; - private readonly Dictionary _dynamicOwnerVersions = new(); + private readonly Dictionary _retainedOwnerVersions = new(); + private readonly List _retainedOwnerIds = new(); + private ShadowObjectRegistry.RetainedRefloodOwnerScan? _retainedOwnerScan; + private PhysicsEngine.LandblockReplacementBuilder? _sealBuilder; + private PhysicsEngine.PreparedPhysicsEngineLandblock? _sealedReplacement; private bool _disposed; internal PreparedLandblockCollisionGeneration( @@ -106,9 +110,13 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal PhysicsEngine Engine { get; } internal uint[] GfxObjectIds { get; private set; } = Array.Empty(); internal uint[] SetupIds { get; private set; } = Array.Empty(); - internal IReadOnlyDictionary DynamicOwnerVersions => - _dynamicOwnerVersions; + internal IReadOnlyDictionary RetainedOwnerVersions => + _retainedOwnerVersions; internal bool IsDisposed => _disposed; + internal bool RetainedOwnerCaptureComplete { get; private set; } + internal ulong RetainedOwnerCaptureMutationVersion { get; private set; } + internal bool IsSealed => _sealedReplacement is not null; + internal ulong SealedShadowMutationVersion { get; private set; } internal bool Matches( RuntimePhysicsState owner, @@ -123,26 +131,155 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds)); } - internal void RefreshDynamicOwner(uint ownerId) + internal void RefreshRetainedOwner(uint ownerId) { EnsureUsable(); - bool retained = Engine.ShadowObjects.RefreshDynamicOwnerFrom( + bool retained = Engine.ShadowObjects.RefreshRetainedOwnerFrom( _owner.Engine.ShadowObjects, ownerId, _admission.LandblockId, out ulong version); if (retained) - _dynamicOwnerVersions[ownerId] = version; + _retainedOwnerVersions[ownerId] = version; else - _dynamicOwnerVersions.Remove(ownerId); + _retainedOwnerVersions.Remove(ownerId); } - internal uint[] FindDirtyDynamicOwners() + internal RuntimeCollisionOwnerCaptureStep AdvanceRetainedOwnerCapture() { EnsureUsable(); - return _owner.Engine.ShadowObjects.FindDirtyDynamicOwners( + if (RetainedOwnerCaptureComplete) + { + return new RuntimeCollisionOwnerCaptureStep( + Completed: true, + Restarted: false, + HasOwner: false, + OwnerId: 0u); + } + _retainedOwnerScan ??= _owner.Engine.ShadowObjects + .CreateRetainedRefloodOwnerScan(_admission.LandblockId); + ShadowObjectRegistry.RetainedRefloodOwnerScanStep step = + _retainedOwnerScan.Advance(); + if (step.Completed && !step.Stable) + { + ResetRetainedOwnerCapture(); + return new RuntimeCollisionOwnerCaptureStep( + Completed: false, + Restarted: true, + HasOwner: false, + OwnerId: 0u); + } + if (step.HasOwner) + _retainedOwnerIds.Add(step.OwnerId); + if (step.Completed) + { + _retainedOwnerScan.Dispose(); + _retainedOwnerScan = null; + RetainedOwnerCaptureMutationVersion = step.SourceMutationVersion; + RetainedOwnerCaptureComplete = true; + } + return new RuntimeCollisionOwnerCaptureStep( + RetainedOwnerCaptureComplete, + Restarted: false, + step.HasOwner, + step.OwnerId); + } + + internal IReadOnlyList RetainedOwnerIds + { + get + { + EnsureUsable(); + if (!RetainedOwnerCaptureComplete) + { + throw new InvalidOperationException( + "Retained collision-owner capture is incomplete."); + } + return _retainedOwnerIds; + } + } + + internal void ResetRetainedOwnerCapture() + { + EnsureUsable(); + _retainedOwnerScan?.Dispose(); + _retainedOwnerScan = null; + _retainedOwnerIds.Clear(); + _retainedOwnerVersions.Clear(); + RetainedOwnerCaptureComplete = false; + RetainedOwnerCaptureMutationVersion = 0UL; + _sealedReplacement = null; + _sealBuilder?.Dispose(); + _sealBuilder = null; + SealedShadowMutationVersion = 0UL; + } + + internal RuntimeCollisionSealStep AdvanceSeal() + { + EnsureUsable(); + if (!RetainedOwnerCaptureComplete) + { + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: true, + WorkUnits: 0); + } + if (_owner.Engine.ShadowObjects.MutationVersion + != RetainedOwnerCaptureMutationVersion) + { + ResetRetainedOwnerCapture(); + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: true, + WorkUnits: 0); + } + if (_retainedOwnerVersions.Count != _retainedOwnerIds.Count) + { + throw new InvalidOperationException( + "Every retained collision owner must refresh before sealing."); + } + + _sealBuilder ??= _owner.Engine.CreateLandblockReplacementBuilder( + Engine, _admission.LandblockId, - _dynamicOwnerVersions); + GfxObjectIds, + SetupIds, + _retainedOwnerVersions); + int before = _sealBuilder.WorkUnits; + bool completed = _sealBuilder.Advance(); + int workUnits = _sealBuilder.WorkUnits - before; + if (!completed) + { + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: false, + workUnits); + } + if (!_sealBuilder.IsStable || _sealBuilder.Prepared is null) + { + ResetRetainedOwnerCapture(); + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: true, + workUnits); + } + _sealedReplacement = _sealBuilder.Prepared; + _sealBuilder.Dispose(); + _sealBuilder = null; + SealedShadowMutationVersion = + _owner.Engine.ShadowObjects.MutationVersion; + return new RuntimeCollisionSealStep( + Completed: true, + Restarted: false, + workUnits); + } + + internal PhysicsEngine.PreparedPhysicsEngineLandblock TakeSealedReplacement() + { + EnsureUsable(); + return _sealedReplacement + ?? throw new InvalidOperationException( + "Collision generation must be sealed before activation."); } internal void MarkCommitted() @@ -156,7 +293,14 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable if (_disposed) return; Engine.Clear(); - _dynamicOwnerVersions.Clear(); + _retainedOwnerScan?.Dispose(); + _retainedOwnerScan = null; + _retainedOwnerIds.Clear(); + _retainedOwnerVersions.Clear(); + _sealBuilder?.Dispose(); + _sealBuilder = null; + _sealedReplacement = null; + SealedShadowMutationVersion = 0UL; _disposed = true; } @@ -167,6 +311,17 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable } } +internal readonly record struct RuntimeCollisionOwnerCaptureStep( + bool Completed, + bool Restarted, + bool HasOwner, + uint OwnerId); + +internal readonly record struct RuntimeCollisionSealStep( + bool Completed, + bool Restarted, + int WorkUnits); + /// /// Presentation-free mutable physics world for one Runtime/session owner. /// Immutable prepared collision inputs may be supplied by a graphical or @@ -1001,6 +1156,43 @@ public sealed class RuntimePhysicsState : IDisposable stagingEngine); } + /// + /// Cancels only the named unpublished generation. The currently active + /// collision world is never withdrawn. A stale receipt may dispose its + /// own staging storage but cannot invalidate a newer admission. + /// + internal void CancelCollisionGeneration( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration? prepared = null) + { + EnsureNotDisposed(); + EnsureCollisionMutationThread(); + ArgumentNullException.ThrowIfNull(admission); + if (!ReferenceEquals(admission.Owner, this)) + { + throw new ArgumentException( + "Collision admission belongs to another Runtime.", + nameof(admission)); + } + if (prepared is not null && !prepared.Matches(this, admission)) + { + throw new ArgumentException( + "Prepared collision generation belongs to another admission.", + nameof(prepared)); + } + + prepared?.Dispose(); + if (_collisionAdmissions.TryGetValue( + admission.LandblockId, + out RuntimeCollisionAdmission? current) + && ReferenceEquals(current, admission)) + { + _collisionAdmissions.Remove(admission.LandblockId); + _collisionGenerations[admission.LandblockId] = checked( + admission.Generation + 1UL); + } + } + internal void StageCollisionAssets( RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared, @@ -1043,18 +1235,17 @@ public sealed class RuntimePhysicsState : IDisposable admission.AssetsPrepared = true; } - internal uint[] CaptureCollisionDynamicOwners( + internal RuntimeCollisionOwnerCaptureStep AdvanceCollisionRetainedOwnerCapture( RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared) { ValidateAdmission(admission); EnsureCollisionMutationThread(); ValidatePreparedGeneration(admission, prepared); - return Engine.ShadowObjects.CaptureDynamicRefloodOwnersForLandblock( - admission.LandblockId); + return prepared.AdvanceRetainedOwnerCapture(); } - internal void RefreshCollisionDynamicOwner( + internal void RefreshCollisionRetainedOwner( RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared, uint ownerId) @@ -1062,7 +1253,32 @@ public sealed class RuntimePhysicsState : IDisposable ValidateAdmission(admission); EnsureCollisionMutationThread(); ValidatePreparedGeneration(admission, prepared); - prepared.RefreshDynamicOwner(ownerId); + prepared.RefreshRetainedOwner(ownerId); + } + + internal void RestartCollisionRetainedOwnerCapture( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + ValidateAdmission(admission); + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); + prepared.ResetRetainedOwnerCapture(); + } + + internal RuntimeCollisionSealStep AdvanceCollisionGenerationSeal( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + ValidateAdmission(admission); + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); + if (!admission.AssetsPrepared) + { + throw new InvalidOperationException( + "Collision generation cannot seal before its assets are prepared."); + } + return prepared.AdvanceSeal(); } internal RuntimeCollisionGenerationCommit CommitCollisionGeneration( @@ -1083,38 +1299,25 @@ public sealed class RuntimePhysicsState : IDisposable "Collision generation has already completed."); } - uint[] dirtyOwners = prepared.FindDirtyDynamicOwners(); - if (dirtyOwners.Length != 0) + if (!prepared.IsSealed) { + throw new InvalidOperationException( + "Collision generation cannot activate before sealing."); + } + if (Engine.ShadowObjects.MutationVersion + != prepared.SealedShadowMutationVersion) + { + prepared.ResetRetainedOwnerCapture(); return new RuntimeCollisionGenerationCommit( new RuntimeCollisionAcknowledgement( admission.LandblockId, admission.Generation, Engine.IsLandblockTerrainResident(admission.LandblockId), Ready: false), - dirtyOwners); + Array.Empty()); } - PhysicsEngine.PreparedPhysicsEngineLandblock replacement = - Engine.PrepareLandblockReplacement( - prepared.Engine, - admission.LandblockId, - prepared.GfxObjectIds, - prepared.SetupIds, - prepared.DynamicOwnerVersions); - if (!Engine.ValidateLandblockReplacement(replacement)) - { - dirtyOwners = prepared.FindDirtyDynamicOwners(); - return new RuntimeCollisionGenerationCommit( - new RuntimeCollisionAcknowledgement( - admission.LandblockId, - admission.Generation, - Engine.IsLandblockTerrainResident(admission.LandblockId), - Ready: false), - dirtyOwners); - } - - Engine.CommitLandblockReplacement(replacement); + Engine.CommitLandblockReplacement(prepared.TakeSealedReplacement()); admission.Completed = true; _collisionAdmissions.Remove(admission.LandblockId); prepared.MarkCommitted(); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index c29f2c04..b1d61c3b 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -976,6 +976,43 @@ public sealed class LandblockPhysicsPublisherTests new Dictionary()); } + [Fact] + public void ReloadedSeamAtomicallyRestoresAdjacentStaticAndClearsRepairMarker() + { + var fixture = Fixture(); + CacheCylinderSetup(fixture.Cache); + WorldEntity neighbor = CylinderEntity( + 0x80AAB401u, + new Vector3(192.25f, 12f, 0f)); + Publish(fixture.Publisher, Build(FirstLandblock)); + Publish(fixture.Publisher, Build(AdjacentLandblock, [neighbor])); + Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock( + neighbor.Id, + FirstLandblock)); + + fixture.Publisher.RemoveLandblock(FirstLandblock); + + Assert.Equal(1, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount); + Assert.False(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock( + neighbor.Id, + FirstLandblock)); + LandblockPhysicsPublication pending = Begin( + fixture.Publisher, + Build(FirstLandblock)); + Assert.Equal(1, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount); + + fixture.Publisher.CompletePublication(pending); + + Assert.Equal(0, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount); + Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock( + neighbor.Id, + FirstLandblock)); + Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock( + neighbor.Id, + AdjacentLandblock)); + Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + } + private static LandblockCollisionBuild FlatCellClosure( PhysicsDatBundle bundle, uint envCellId) diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 32823794..ffb9e650 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -12,6 +12,7 @@ using AcDream.Headless.Platform; using AcDream.Runtime; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; using AcDream.Runtime.Session; using AcDream.Runtime.World; @@ -373,6 +374,72 @@ public sealed class HeadlessSessionHostTests Assert.Equal(2, collision.CenterCount); } + [Fact] + public void CollisionTransactionCancelsPostAdmissionFaultWithoutWithdrawingActiveWorld() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint landblockId = 0xA9B4FFFFu; + _ = HeadlessCollisionGenerationTransaction.Execute( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 10f))); + + Assert.Throws(() => + HeadlessCollisionGenerationTransaction.Execute( + physics, + landblockId, + _ => throw new FixtureCollisionPublicationException(), + (_, _) => throw new InvalidOperationException( + "Staging must not run after the injected admission fault."))); + + Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f)); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(1, ownership.LandblockCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void CollisionTransactionCancelsStagingFaultWithoutWithdrawingActiveWorld() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint landblockId = 0xA9B4FFFFu; + _ = HeadlessCollisionGenerationTransaction.Execute( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 10f))); + + Assert.Throws(() => + HeadlessCollisionGenerationTransaction.Execute( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + { + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 25f)); + throw new FixtureCollisionPublicationException(); + })); + + Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f)); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(1, ownership.LandblockCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + private static HeadlessSessionDescriptor Descriptor( HeadlessCredentialProviderKind provider = HeadlessCredentialProviderKind.Environment, @@ -422,6 +489,25 @@ public sealed class HeadlessSessionHostTests runtime.MovementOwner.Controller = controller; } + private static RuntimeLandblockCollisionAssets CollisionAssets( + uint landblockId, + float terrainHeight) + { + var heights = new byte[81]; + var table = new float[256]; + table[0] = terrainHeight; + return new RuntimeLandblockCollisionAssets( + landblockId, + new TerrainSurface(heights, table), + Array.Empty(), + Array.Empty(), + 0f, + 0f, + 0u); + } + + private sealed class FixtureCollisionPublicationException : Exception; + private static void AddFlatLandblock(PhysicsEngine engine) { var heights = new byte[81]; diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index 75ce462f..e4e60922 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -322,7 +322,7 @@ public sealed class RuntimePhysicsStateTests prepared, CollisionAssets(0xA9B4FFFFu)); RuntimeCollisionGenerationCommit commit = - first.Physics.CommitCollisionGeneration(newer, prepared); + CommitPrepared(first.Physics, newer, prepared); RuntimeCollisionAcknowledgement completed = commit.Acknowledgement; Assert.True(commit.Committed); @@ -332,7 +332,7 @@ public sealed class RuntimePhysicsStateTests 0, first.Physics.CaptureOwnership().CollisionAdmissionCount); Assert.Throws(() => - first.Physics.CommitCollisionGeneration(newer, prepared)); + CommitPrepared(first.Physics, newer, prepared)); RuntimeCollisionAcknowledgement withdrawn = first.Physics.WithdrawCollision(0xA9B4FFFFu); @@ -355,7 +355,7 @@ public sealed class RuntimePhysicsStateTests firstAdmission, first, CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f)); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CommitPrepared(physics, firstAdmission, first).Committed); } @@ -375,7 +375,7 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(0, notifications); RuntimeCollisionGenerationCommit committed = - physics.CommitCollisionGeneration( + CommitPrepared(physics, replacementAdmission, replacement); @@ -401,7 +401,11 @@ public sealed class RuntimePhysicsStateTests RuntimeCollisionAdmission current = physics.BeginCollisionAdmission(0xA9B4FFFFu); Assert.Throws(() => - physics.CommitCollisionGeneration(stale, preparedStale)); + CommitPrepared(physics, stale, preparedStale)); + physics.CancelCollisionGeneration(stale, preparedStale); + Assert.Equal( + 1, + physics.CaptureOwnership().CollisionAdmissionCount); Assert.False(physics.Engine.IsLandblockTerrainResident(0xA9B4FFFFu)); using PreparedLandblockCollisionGeneration preparedCurrent = @@ -410,14 +414,14 @@ public sealed class RuntimePhysicsStateTests current, preparedCurrent, CollisionAssets(0xA9B4FFFFu, terrainHeight: 20f)); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CommitPrepared(physics, current, preparedCurrent).Committed); Assert.Equal(20f, physics.Engine.SampleTerrainZ(1f, 1f)); } [Fact] - public void MovingDynamicOwnerDuringStagingMustRefreshBeforeCommit() + public void WithdrawnOwnerStateChangeRejectsSealUntilRefreshed() { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; @@ -430,7 +434,7 @@ public sealed class RuntimePhysicsStateTests initialAdmission, initial, CollisionAssets(0x0101FFFFu, terrainHeight: 5f)); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CommitPrepared(physics, initialAdmission, initial).Committed); } @@ -445,6 +449,9 @@ public sealed class RuntimePhysicsStateTests 0x0101FFFFu, seedCellId: 0x01010001u, isStatic: false); + physics.Engine.ShadowObjects.RemoveLandblock(0x0101FFFFu); + Assert.Equal(0, physics.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(1, physics.Engine.ShadowObjects.RetainedRegistrationCount); RuntimeCollisionAdmission admission = physics.BeginCollisionAdmission(0x0101FFFFu); @@ -454,33 +461,125 @@ public sealed class RuntimePhysicsStateTests admission, prepared, CollisionAssets(0x0101FFFFu, terrainHeight: 15f)); - uint owner = Assert.Single(physics.CaptureCollisionDynamicOwners( - admission, - prepared)); - physics.RefreshCollisionDynamicOwner(admission, prepared, owner); - physics.Engine.ShadowObjects.UpdatePosition( - owner, - new Vector3(30f, 10f, 0f), - Quaternion.Identity, - 0f, - 0f, - 0x0101FFFFu, - seedCellId: 0x01010009u); + uint owner = Assert.Single(SealPrepared(physics, admission, prepared)); + physics.Engine.ShadowObjects.UpdatePhysicsState(owner, 0x14u); RuntimeCollisionGenerationCommit rejected = physics.CommitCollisionGeneration(admission, prepared); Assert.False(rejected.Committed); - Assert.Equal(owner, Assert.Single(rejected.DirtyDynamicOwnerIds)); Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f)); - physics.RefreshCollisionDynamicOwner(admission, prepared, owner); + Assert.Equal(owner, Assert.Single(SealPrepared( + physics, + admission, + prepared))); Assert.True(physics.CommitCollisionGeneration( admission, prepared).Committed); Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); - Assert.Contains( - physics.Engine.ShadowObjects.GetObjectsInCell(0x01010009u), - entry => entry.EntityId == owner); + ShadowEntry restored = Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(owner, restored.EntityId); + Assert.Equal(0x14u, restored.State); + } + + [Fact] + public void DenseReplacementSealsOneWorkUnitPerStepAndActivatesWithoutAllocation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint landblockId = 0x0101FFFFu; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(landblockId); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(landblockId, terrainHeight: 5f)); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + + const int ownerCount = 256; + for (uint index = 0; index < ownerCount; index++) + { + physics.Engine.ShadowObjects.Register( + entityId: 1000u + index, + gfxObjId: 0x01000001u, + worldPos: new Vector3( + 8f + (index % 16u) * 0.25f, + 8f + (index / 16u) * 0.25f, + 0f), + rotation: Quaternion.Identity, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId, + seedCellId: 0x01010001u, + isStatic: false); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(landblockId); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, terrainHeight: 15f)); + + int captureSteps = 0; + RuntimeCollisionOwnerCaptureStep capture; + do + { + capture = physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared); + captureSteps++; + } + while (!capture.Completed); + Assert.True(captureSteps >= ownerCount); + Assert.Equal(ownerCount, prepared.RetainedOwnerIds.Count); + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + + int sealSteps = 0; + int workUnits = 0; + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + sealSteps++; + Assert.InRange(seal.WorkUnits, 0, 1); + workUnits += seal.WorkUnits; + } + while (!seal.Completed); + Assert.False(seal.Restarted); + Assert.True(sealSteps > ownerCount); + Assert.True(workUnits > ownerCount); + + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + long allocated = + GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(commit.Committed); + Assert.Equal(0L, allocated); + Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); + Assert.Equal(ownerCount, physics.Engine.ShadowObjects.TotalRegistered); } [Fact] @@ -496,7 +595,7 @@ public sealed class RuntimePhysicsStateTests admission, prepared, CollisionAssets(0x0101FFFFu, terrainHeight: 12f)); - Assert.Empty(physics.CaptureCollisionDynamicOwners(admission, prepared)); + Assert.Empty(SealPrepared(physics, admission, prepared)); physics.Engine.ShadowObjects.Register( 77u, @@ -512,16 +611,17 @@ public sealed class RuntimePhysicsStateTests RuntimeCollisionGenerationCommit spawned = physics.CommitCollisionGeneration(admission, prepared); Assert.False(spawned.Committed); - Assert.Equal(77u, Assert.Single(spawned.DirtyDynamicOwnerIds)); - physics.RefreshCollisionDynamicOwner(admission, prepared, 77u); + Assert.Equal(77u, Assert.Single(SealPrepared( + physics, + admission, + prepared))); physics.Engine.ShadowObjects.Deregister(77u); RuntimeCollisionGenerationCommit deleted = physics.CommitCollisionGeneration(admission, prepared); Assert.False(deleted.Committed); - Assert.Equal(77u, Assert.Single(deleted.DirtyDynamicOwnerIds)); - physics.RefreshCollisionDynamicOwner(admission, prepared, 77u); + Assert.Empty(SealPrepared(physics, admission, prepared)); Assert.True(physics.CommitCollisionGeneration( admission, prepared).Committed); @@ -546,7 +646,7 @@ public sealed class RuntimePhysicsStateTests admission, prepared, CollisionAssets(0x0101FFFFu)); - _ = lifetime.Physics.CommitCollisionGeneration(admission, prepared); + _ = CommitPrepared(lifetime.Physics, admission, prepared); lifetime.Physics.Engine.ShadowObjects.Register( entityId: record.LocalEntityId!.Value, gfxObjId: 0x01000001u, @@ -930,6 +1030,49 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(record.SpatialAuthorityVersion, observed.SpatialAuthorityVersion); } + private static RuntimeCollisionGenerationCommit CommitPrepared( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + _ = SealPrepared(physics, admission, prepared); + return physics.CommitCollisionGeneration(admission, prepared); + } + + private static uint[] SealPrepared( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + while (true) + { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.InRange(seal.WorkUnits, 0, 1); + } + while (!seal.Completed && !seal.Restarted); + if (seal.Completed) + break; + } + return [.. prepared.RetainedOwnerIds]; + } + private static RuntimeLandblockCollisionAssets CollisionAssets( uint landblockId, float terrainHeight = 0f) From 6b28ff999cf847816fdb18b7ea3c20b4c9a58f00 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 18:34:46 +0200 Subject: [PATCH 24/73] fix(physics): make collision activation starvation-free --- docs/architecture/acdream-architecture.md | 48 +- .../retail-divergence-register.md | 2 +- .../2026-07-31-atomic-collision-generation.md | 145 +- memory/project_collision_port.md | 37 +- .../Streaming/LandblockPhysicsPublisher.cs | 24 +- .../Physics/CollisionWorldState.cs | 86 + src/AcDream.Core/Physics/PhysicsDataCache.cs | 127 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 838 ++++++- .../Physics/ShadowObjectRegistry.cs | 639 ++++-- src/AcDream.Core/World/Cells/CellGraph.cs | 104 +- .../Physics/RuntimePhysicsState.cs | 1099 +++++++++- .../LandblockPhysicsPublisherTests.cs | 4 + .../Physics/PhysicsEngineTests.cs | 12 +- .../Physics/RuntimePhysicsStateTests.cs | 1946 ++++++++++++++++- 14 files changed, 4637 insertions(+), 474 deletions(-) create mode 100644 src/AcDream.Core/Physics/CollisionWorldState.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index d1d8dac8..b46152fa 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -494,13 +494,47 @@ What exists and is active: target prefix (including a withdrawn repair marker); target-root statics come from the authored replacement. Runtime mutation-gates their exact capture, refreshes each through the host work meter, and builds every cache/graph/ - shadow replacement list through one-work-unit seal cursors. The final update- - thread activation performs one mutation-version check and installs the sealed - records without heap allocation before emitting - `CollisionGenerationCommitted`. Cancellation disposes only the named staging - generation and never withdraws the previous active world. Thus readers see - the complete old generation or complete new generation, never a mixed - cell/cache/shadow world. + shadow replacement through one-work-unit seal cursors. Stable per-prefix + owner slots replace the former registry-global mutation gate. One Runtime- + scoped versioned journal coalesces repeated live mutations by owner instead + of copying the owner into every draft on every event. Each draft reconciles + only the latest exact state for owners changed during its lifetime, one owner + per seal step, so unrelated or continuously moving owners cannot restart the + target cursors. Once discovered, a relevant owner receives exact subscribed + updates without restoring global fanout. First entry to or departure from a + target after the global slot cursor has passed is routed through the owner's + changed prefix to that one matching draft. During topology construction a + visited unrelated owner receives only a cheap coalesced dirty notification; + its exact mirror is deferred to one metered seal unit. Once the topology seal + exists, observed owners temporarily write through exactly until activation, + so the finite pre-seal queue drains even when several unrelated owners move + continuously. Production activates in that same update-thread call. Slots older than a + newer draft's captured root are superseded at the tail rather than reused + behind live cursors; new drafts start at their captured suffix, obsolete + slots compact incrementally, and the journal clears with the last draft. + Empty prefix containers are reclaimed under GUID churn; seal cursors retain + their captured slot lists. + Cache, CellGraph, engine, and shadow topology share one complete off-side + `CollisionWorldState`. Admission captures the current root reference in O(1) + and materializes the non-target leaves through the same one-work-unit frame + meter; a dense resident world is never cloned synchronously. After an older + preparation commits, its exact landblock delta queues into every later draft + and drains one cache, graph, landblock, or owner leaf per seal step. A later + demotion or withdrawal cancels matching queued/active rebases and tombstones + that prefix in unfinished source scans, then retires one owner/cache/graph/ + outdoor leaf per seal step from growable retirement storage. Commit rechecks + both retirement and rebase state after sealing, so retired topology cannot + return or cause a drafts-times-world-size update spike. + The host immediately performs the zero-work root transfer in the same update- + thread call that completes final reconciliation, so continuous unrelated + movement cannot manufacture a required quiet frame between seal and commit. + Deterministic preparation order prevents a later draft from exposing early, + inheriting cancelled topology, or overwriting a committed prefix. Final activation is one + zero-allocation volatile root transfer that preserves public facade identity, + revokes staging, and then emits `CollisionGenerationCommitted`. Cancellation disposes only the named + staging generation and never withdraws the previous active world. Thus + readers see the complete old generation or complete new generation, never a + mixed cell/cache/shadow world. - `ShadowObjectRegistry` gives movement a per-cell broadphase over nearby objects and buildings. Streaming reflood is structurally part of the Runtime collision-generation commit; there is no independent post-publication diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1b19d620..8281655c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -89,7 +89,7 @@ AD-53..AD-55 (Campaign P response-layer findings). | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | -| ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build off-side through mutation-stable, one-work-unit capture/seal cursors; rowless owner state/payload updates, movement, spawn, deletion, and seam-static changes reject stale sealing. The complete previous generation remains queryable until one allocation-free update-thread activation. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. The commit installs precomputed cell rows and clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | +| ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | | ~~AD-11~~ | **RETIRED 2026-07-23** — the matching binary disproved the old nonzero interpretation: `ItemUses::IsUseable` executes `not bitfield; and eax,1`, so absent/reset zero is usable and only `USEABLE_NO` disables use. Toolbar, item policy, and world interaction now share that exact Core predicate. | `src/AcDream.Core/Items/ClientObject.cs` (`ItemUseability.IsUseable`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs` | — | — | `ItemUses::IsUseable @ 0x004FCCC0`; matching v11.4186 instructions recorded in `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | diff --git a/docs/research/2026-07-31-atomic-collision-generation.md b/docs/research/2026-07-31-atomic-collision-generation.md index 0e92fba1..f5affecb 100644 --- a/docs/research/2026-07-31-atomic-collision-generation.md +++ b/docs/research/2026-07-31-atomic-collision-generation.md @@ -20,35 +20,86 @@ generation, and correctness depended on a later optional landblock callback. The asynchronous unit is now one Runtime-owned collision generation: 1. `BeginCollisionAdmission` issues the exact Runtime/landblock generation. -2. `PrepareCollisionGeneration` clones the bounded resident spatial records - into a private cache, graph, engine, and shadow registry. Global immutable - GfxObj/Setup catalogs are not copied; the accepted build's exact closure is - populated by the existing cursors. -3. App and Headless publish terrain, EnvCells, topology, buildings, prepared +2. `PrepareCollisionGeneration` creates empty private cache, graph, engine, and + shadow facades and retains the active aggregate root reference in O(1). + Global immutable GfxObj/Setup catalogs are not copied; the accepted build's + exact closure is populated by the existing cursors. +3. Stable + landblock and logical-owner slot suffixes then materialize each non-target + cache, CellGraph, engine, and shadow leaf into an empty private root under + the host's existing frame meter. The 32-resident-landblock gate proves + admission performs no resident copy and every advance reports at most one + work unit. +4. App and Headless publish terrain, EnvCells, topology, buildings, prepared collision assets, and target-root static owners only into that private generation. -4. A mutation-stable cursor captures every non-suspended owner that touches — - or has a withdrawn repair marker for — the target prefix. That includes - live dynamic owners and statics rooted in an adjacent landblock. Only a +5. Stable per-prefix owner slots capture every non-suspended owner that touches + or has a withdrawn repair marker for the target prefix. That includes live + dynamic owners and statics rooted in an adjacent landblock. Only a target-root static is omitted, because the authored replacement supersedes - it. Each retained owner refresh captures its exact - `ShadowObjectRegistry` mutation version; a rowless withdrawn owner therefore - remains freshness-gated when state or payload changes. -5. Explicit one-work-unit cursors build the complete replacement before the + it. The scan has a fixed slot suffix and is unaffected by mutations in other + prefixes. Vacated slots are tombstoned and reused rather than retained for + the whole session. A single Runtime-scoped versioned journal records each + mutation once and coalesces repeated changes by logical owner, independently + of the number of live drafts. After topology sealing, each draft reconciles + the latest exact state of owners changed during that draft's lifetime one + owner per seal call. A discovered relevant owner then receives scoped exact + updates, preserving continuous-motion progress without restoring global + fanout. A membership transition is routed by the owner's changed landblock + prefix to the one matching draft, so an owner first entering or leaving the + target after its global journal slot was visited is still reconciled once. + During topology construction, a visited unrelated owner retains only a cheap + coalesced notification; its exact mirror runs later as one metered seal unit + rather than once per draft on the mutation path. Once the topology seal + exists, observed owners temporarily write through exactly until same-call + activation. The finite pre-seal queue therefore drains even when two or more + unrelated owners mutate before every host step. Slots predating a newer root snapshot are superseded + by a tail slot, not reused behind live cursors. New drafts begin at their + captured suffix, obsolete slots compact one visit per seal call, and the + journal clears when its last draft closes. Unrelated and continuously moving + owners therefore never restart capture or sealing. +6. Explicit one-work-unit cursors build the complete replacement before the activation frame: requested global collision records, cells/topology, buildings, cell graph removals, affected static owners, retained-owner - states, and removal lists. An active-owner mutation restarts capture and - sealing without touching the active world. -6. `CommitCollisionGeneration` performs only the final mutation-version check - and installs the already sealed replacement synchronously on the update - thread. The dense 256-owner gate measures zero managed bytes in this final - activation. Only after the complete replacement does Runtime emit - `CollisionGenerationCommitted` and a ready acknowledgement. + states, and removal lists. A late unarmed relevant owner consumes at most + one refresh unit on a seal call; when that drains the queue an already-built + seal is immediately ready. Immutable global GfxObj/Setup closure entries are + preinstalled during these metered steps, not during activation. +7. Cache, CellGraph, engine-landblock, and shadow topology share one + `CollisionWorldStateSlot`. `CommitCollisionGeneration` transfers the + complete off-side aggregate through one volatile reference on the update + thread, then revokes the staging slot. The public `PhysicsDataCache`, + `CellGraph`, `PhysicsEngine`, and `ShadowObjectRegistry` facade identities + stay stable. Warm 256-owner, cold + first-load, changed EnvCell/building, and new static-bucket gates all measure + exactly zero managed bytes in final activation. Only afterwards does Runtime + emit `CollisionGenerationCommitted` and a ready acknowledgement. +8. Multiple landblocks may prepare concurrently. Preparation order is the + activation order. Only after an older generation commits is its exact delta + queued into every later draft. Each additional seal call applies at most one + cache, CellGraph, synthesized outdoor-cell, engine-landblock, or logical + owner leaf. Later generations retain their own completed target seal but + cannot activate before every committed delta drains. Cancelled older drafts + therefore contribute nothing, final activation performs no peer work, and a + later root cannot overwrite or expose an older snapshot. Seam-crossing + statics are forcibly re-evaluated against the later topology. Demotion and + withdrawal cancel a matching queued or active rebase, suppress that prefix + in unfinished source scans, and retire one shadow owner, cache/graph leaf, + authored outdoor cell, or landblock leaf per later seal call before + activation. Retirement storage is growable rather than coupled to the + concurrent-preparation limit, and final commit rechecks both pending rebase + and retirement work after the seal-to-commit gap. + The host performs the zero-work root transfer in the same update-thread call + that completes final reconciliation, eliminating a seal-to-next-frame quiet + window for continuously moving unrelated owners. +9. GfxObj/Setup closure entries are immutable content-addressed catalog data, + not world topology. Their metered early installation may survive a cancelled + generation as ordinary process cache residency; no cell, building, + landblock, or shadow becomes visible through that catalog alone. -The stable borrowed `PhysicsEngine` and `PhysicsDataCache` object identities do -not change. Presentation and no-window hosts use the same Runtime transaction. -Network workers still enqueue immutable messages and cannot mutate collision or -shadow state. +Presentation and no-window hosts use the same Runtime transaction. Network +workers still enqueue immutable messages and cannot mutate collision or shadow +state. ## Failure and lifetime rules @@ -71,17 +122,51 @@ The focused Runtime/App tests pin: - previous terrain/cells/buildings/statics remain visible until commit; - exactly one notification after a successful complete activation; - stale admission replacement has no active-world side effect; -- movement during staging rejects, refreshes only the dirty owner, and then - installs its latest cell set; -- an authoritative state change on a retained rowless owner rejects a stale - seal and installs the refreshed state on retry; +- unrelated movement on every capture/seal step never restarts the target; +- two relevant owners moving on every seal step converge without restarting + the topology meter and install their latest positions at activation; +- an authoritative state change on a retained rowless owner updates an + already-sealed generation without a global restart; - a neighboring static whose shadow crossed the seam is restored atomically on reload and its withdrawn-prefix marker clears only at activation; -- spawn and deletion during staging both reject stale activation; +- a late spawn blocks activation until its one metered refresh; deletion of an + armed owner writes through directly; - Headless faults immediately after admission and after staging preserve the prior complete world and leave no collision admission behind; -- dense sealing consumes at most one work unit per call, while its final - 256-owner activation allocates zero managed bytes; +- dense sealing consumes at most one work unit per call, while warm 256-owner, + cold first-load, changed EnvCell/building, and new static-bucket activation + all allocate zero managed bytes; +- concurrently prepared landblocks rebase and preserve both terrain roots and + static-shadow owners across their activation order, with zero-byte final + commits and revoked staging access; +- dense 32-landblock admission performs no resident copy, stays within its + constant allocation envelope, and materializes at most one leaf per advance; +- cancelled older drafts contribute no topology to later roots, while a live + owner mutation after the older commit wins over the queued rebase; +- a newly committed seam-crossing static refloods against the later draft's + topology before that draft may activate; +- post-seal arrivals drain one owner per seal call without resetting capture; +- an unrelated owner entering the target after its journal slot was visited is + routed by prefix and reconciled in one metered seal unit; +- target departure and same-ID reuse preserve the exact new-prefix owner rows; +- unrelated state mutation publishes only after every row changes; +- unrelated demotion/withdrawal and the live `CurrCell` cannot be resurrected + or rolled back by a later draft; +- queued and partially applied peer rebases cannot resurrect a later demoted or + withdrawn landblock; +- deleting an outgoing target static before, during, or after staging cannot + erase an authored same-ID replacement; +- 10,000 repeated mutations with 32 drafts retain one coalesced journal entry + and allocate no more than the owner mutation itself; +- 512 unique changed owners reconcile in exactly 512 metered seal units and + the final activation still allocates zero managed bytes; +- compacted journal slots are never reused behind a live cursor, while a + 4,096-slot obsolete tail retires incrementally and a later draft starts at + its captured suffix rather than scanning old tombstones; +- post-seal retirement blocks activation until its cursor drains, and more + than 256 distinct retirements remain metered and lossless; +- prefix-owner slots remain bounded under GUID churn and empty containers are + reclaimed across unique prefixes without invalidating a live seal cursor; - graphical and no-window publishers use the same Runtime transaction; - removal and terminal teardown converge the active ownership ledger. diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md index f75de062..6ee52a74 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -15,11 +15,38 @@ as "delete everything and start over." A partial retail transition port exists: engine snapshot, buildings, statics, and versioned retained-owner refloods. Retained means every non-suspended dynamic or adjacent-root static touching (or withdrawn from) the target prefix; only authored target-root statics are - superseded. All scans and replacement construction run through bounded - capture/seal cursors. `RuntimePhysicsState.CommitCollisionGeneration` does - one final mutation-version check and activates the sealed generation without - managed allocation on the single update thread. Cancellation tears down only - the named staging generation. The old generation remains queryable until + superseded. Stable per-prefix owner slots bound capture independently of + unrelated movement. One Runtime-scoped versioned journal records a live + mutation once, coalesces repeated changes by owner, and lets every draft + reconcile only that owner's latest exact state one owner per seal step. + Relevant owners subscribe only after discovery, preserving exact continuous + updates without the former preparations-by-mutations global fanout or a quiet + frame. A changed prefix routes first target entry/departure to the one + matching draft after its journal cursor has passed. During topology build, + visited unrelated owners receive only coalesced dirty notifications; one + later seal unit performs the exact mirror. After the topology seal exists, + observed owners temporarily write through exactly until same-call activation, + guaranteeing that the finite dirty queue drains under multi-owner motion. + Old slots are superseded at the tail rather than reused behind + live cursors, new drafts start at their captured suffix, and obsolete slots + plus empty prefix containers are reclaimed under churn. + Admission captures the active aggregate + root reference in O(1), then stable landblock/owner suffixes materialize the + non-target cache, CellGraph, engine, and shadow leaves one work unit per host + step. All scans and replacement construction run + through bounded preparation/capture/seal cursors. Cache, CellGraph, engine, and shadow + topology share one complete off-side `CollisionWorldState`; final activation + is a single zero-allocation volatile root transfer that preserves borrowed + facade identities and revokes staging. Deterministically ordered concurrent + preparations queue their deltas into later drafts only after commit; each + seal call rebases at most one cache/graph/landblock/owner leaf. Demotion or + withdrawal cancels matching queued/active rebases, suppresses stale source- + clone leaves, and retires one owner/cache/graph/outdoor leaf per seal step + from growable storage; commit rechecks pending retirement after sealing. + The host performs the zero-work root transfer in the same update-thread call + that completes final reconciliation, avoiding a quiet-frame requirement. + Cancellation tears down + only the named staging generation. The old generation remains queryable until commit. See `docs/research/2026-07-31-atomic-collision-generation.md`. - `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects. diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 8f34842d..1144cf08 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -265,6 +265,13 @@ public sealed class LandblockPhysicsPublisher if (publication.PreparationCommitted) return true; + if (!_physics.AdvanceCollisionGenerationPreparation( + publication.CollisionAdmission, + publication.PreparedGeneration).Completed) + { + return false; + } + IReadOnlyList entities = publication.Build.Landblock.Entities; if (publication.Build.Collisions is { } collisions) @@ -548,7 +555,13 @@ public sealed class LandblockPhysicsPublisher publication.RefloodCommitted = false; } } - else + + // Seal reconciliation and the zero-work root transfer are one host + // update-thread transaction. Deferring this commit to the next frame + // would let continuously moving, unrelated live owners dirty their + // already-visited journal slots forever even though each metered replay + // had just caught up exactly. + if (publication.SealCommitted && !publication.CompletionCommitted) { RuntimeCollisionGenerationCommit commit = _physics.CommitCollisionGeneration( @@ -556,12 +569,9 @@ public sealed class LandblockPhysicsPublisher publication.PreparedGeneration); if (!commit.Committed) { - _physics.RestartCollisionRetainedOwnerCapture( - publication.CollisionAdmission, - publication.PreparedGeneration); - publication.RefloodOwnerIds = null; - publication.RefloodCursor = 0; - publication.RefloodCommitted = false; + // Runtime coalesces post-seal arrivals in its owner journal. + // Resume that seal tail rather than restarting the + // completed generation-wide capture/reflood pass. publication.SealCommitted = false; _completePublishTicks += Stopwatch.GetTimestamp() - started; return false; diff --git a/src/AcDream.Core/Physics/CollisionWorldState.cs b/src/AcDream.Core/Physics/CollisionWorldState.cs new file mode 100644 index 00000000..a2a182c2 --- /dev/null +++ b/src/AcDream.Core/Physics/CollisionWorldState.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; +using AcDream.Core.World.Cells; + +namespace AcDream.Core.Physics; + +/// +/// One exclusive-by-ownership collision-world root. A preparation mutates only +/// its private root; activation transfers the complete root through one volatile +/// reference publication shared by every collision facade. +/// +internal sealed class CollisionWorldState +{ + internal Dictionary Landblocks { get; } = new(); + internal List LandblockSlots { get; } = new(); + internal Dictionary LandblockIndices { get; } = new(); + internal Stack LandblockFreeSlots { get; } = new(); + internal ConcurrentDictionary CellStruct { get; } = new(); + internal ConcurrentDictionary + FlatCellStruct { get; } = new(); + internal ConcurrentDictionary FlatEnvCell { get; } = new(); + internal ConcurrentDictionary Buildings { get; } = new(); + internal ConcurrentDictionary EnvCells { get; } = new(); + internal ConcurrentDictionary Terrain { get; } = new(); + internal ConcurrentDictionary OutdoorCells { get; } = new(); + internal Dictionary> ShadowCells { get; } = new(); + internal Dictionary> ShadowEntityCells { get; } = new(); + internal HashSet SuspendedShadowEntities { get; } = new(); + internal Dictionary> WithdrawnPrefixesByOwner { get; } = new(); + internal Dictionary> ShadowEntityShapes { get; } = new(); + internal Dictionary + ShadowEntityRegistrations { get; } = new(); + internal Dictionary ShadowOwnerVersions { get; } = new(); + internal Dictionary> ShadowOwnerPrefixes { get; } = new(); + internal Dictionary> ShadowPrefixOwnerSlots { get; } = new(); + internal Dictionary> ShadowPrefixOwnerIndices { get; } = new(); + internal Dictionary> ShadowPrefixFreeSlots { get; } = new(); + internal List ShadowOwnerSlots { get; } = new(); + internal Dictionary ShadowOwnerIndices { get; } = new(); + internal Stack ShadowOwnerFreeSlots { get; } = new(); +} + +/// +/// Stable indirection shared by PhysicsEngine, PhysicsDataCache, CellGraph, +/// and ShadowObjectRegistry. Readers observe either complete root, never a +/// mixture assembled by several facade assignments. +/// +internal sealed class CollisionWorldStateSlot +{ + private CollisionWorldState? _current = new(); + private bool _revoked; + + internal CollisionWorldStateSlot() + { + } + + internal CollisionWorldStateSlot(CollisionWorldState current) + { + _current = current ?? throw new ArgumentNullException(nameof(current)); + } + + internal CollisionWorldState Current + { + get + { + if (_revoked) + throw new ObjectDisposedException("Transferred collision generation"); + return Volatile.Read(ref _current) + ?? throw new ObjectDisposedException("Transferred collision generation"); + } + } + + internal CollisionWorldState TransferTo(CollisionWorldStateSlot destination) + { + ArgumentNullException.ThrowIfNull(destination); + if (_revoked) + throw new ObjectDisposedException("Transferred collision generation"); + CollisionWorldState transferred = _current + ?? throw new ObjectDisposedException("Transferred collision generation"); + _revoked = true; + Volatile.Write(ref destination._current, transferred); + _current = null; + return transferred; + } + + internal CollisionWorldState Capture() => Current; +} diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 8be11764..ad2a2b9d 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -23,18 +23,20 @@ public sealed class PhysicsDataCache { private readonly bool _requirePreparedCollision; private PhysicsDataCache? _readFallback; + private readonly CollisionWorldStateSlot _collisionWorld; private readonly ConcurrentDictionary _gfxObj = new(); private readonly ConcurrentDictionary _visualBounds = new(); private readonly ConcurrentDictionary _setup = new(); - private readonly ConcurrentDictionary _cellStruct = new(); + private ConcurrentDictionary _cellStruct => + _collisionWorld.Current.CellStruct; private readonly ConcurrentDictionary _flatGfxObj = new(); private readonly ConcurrentDictionary _flatSetup = new(); - private readonly ConcurrentDictionary - _flatCellStruct = new(); - private readonly ConcurrentDictionary - _flatEnvCell = new(); + private ConcurrentDictionary + _flatCellStruct => _collisionWorld.Current.FlatCellStruct; + private ConcurrentDictionary + _flatEnvCell => _collisionWorld.Current.FlatEnvCell; public PhysicsDataCache() : this(requirePreparedCollision: false) @@ -42,8 +44,18 @@ public sealed class PhysicsDataCache } private PhysicsDataCache(bool requirePreparedCollision) + : this(requirePreparedCollision, new CollisionWorldStateSlot()) + { + } + + private PhysicsDataCache( + bool requirePreparedCollision, + CollisionWorldStateSlot collisionWorld) { _requirePreparedCollision = requirePreparedCollision; + _collisionWorld = collisionWorld + ?? throw new ArgumentNullException(nameof(collisionWorld)); + CellGraph = new UcgCellGraph(_collisionWorld); if (!requirePreparedCollision && PhysicsDiagnostics.CollisionShadowSampleEvery > 0) { @@ -67,6 +79,18 @@ public sealed class PhysicsDataCache return cache; } + internal static PhysicsDataCache CreateProduction( + CollisionWorldStateSlot collisionWorld) + { + var cache = new PhysicsDataCache( + requirePreparedCollision: true, + collisionWorld) + { + CollisionTraversalMode = CollisionTraversalMode.Flat, + }; + return cache; + } + internal CollisionShadowVerifier? CollisionShadow { get; set; } public CollisionShadowStats CollisionShadowStats => @@ -80,7 +104,8 @@ public sealed class PhysicsDataCache CollisionTraversalMode.Graph; // ── Phase 2: building portal cache for outdoor→indoor entry ─────────── - private readonly ConcurrentDictionary _buildings = new(); + private ConcurrentDictionary _buildings => + _collisionWorld.Current.Buildings; /// /// The unified cell graph (UCG): the active id->cell resolver and registry. @@ -94,31 +119,23 @@ public sealed class PhysicsDataCache /// (TryGetTerrainOrigin, read by CellTransit's pick + transit /// paths). No longer inert. /// - public UcgCellGraph CellGraph { get; private set; } = new(); + public UcgCellGraph CellGraph { get; } + + internal CollisionWorldStateSlot CollisionWorld => _collisionWorld; /// - /// Copies the currently committed immutable collision records into an - /// off-side cache. Streaming may replace one landblock in this copy over - /// many frames without exposing a partially withdrawn cell graph to live - /// physics queries. + /// Creates the empty off-side facade used by Runtime's metered root + /// materializer. Global immutable catalogs fall through to this cache; + /// mutable world topology is installed one leaf per host step. /// - internal PhysicsDataCache CreateCollisionStagingCopy() + internal PhysicsDataCache CreateEmptyCollisionStaging( + CollisionWorldStateSlot collisionWorld) { - var copy = new PhysicsDataCache(_requirePreparedCollision) + return new PhysicsDataCache(_requirePreparedCollision, collisionWorld) { CollisionTraversalMode = CollisionTraversalMode, - CellGraph = CellGraph.CreateCollisionStagingCopy(), _readFallback = this, }; - // Global immutable GfxObj/Setup records are not copied wholesale. - // The accepted build's exact closure is staged cursor-by-cursor below; - // copying the process-retained asset catalog here would turn every - // landblock publication into an unbounded frame spike. - CopyDictionary(_cellStruct, copy._cellStruct); - CopyDictionary(_flatCellStruct, copy._flatCellStruct); - CopyDictionary(_flatEnvCell, copy._flatEnvCell); - CopyDictionary(_buildings, copy._buildings); - return copy; } internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( @@ -132,56 +149,6 @@ public sealed class PhysicsDataCache gfxObjectIds, setupIds); - internal void CommitLandblockReplacement( - PreparedPhysicsDataCacheLandblock replacement) - { - RemoveEntries(_cellStruct, replacement.CellIdsToRemove); - RemoveEntries(_flatCellStruct, replacement.FlatCellIdsToRemove); - RemoveEntries(_flatEnvCell, replacement.FlatEnvCellIdsToRemove); - RemoveEntries(_buildings, replacement.BuildingIdsToRemove); - CommitEntries(_gfxObj, replacement.GfxObjects, replace: false); - CommitEntries(_visualBounds, replacement.VisualBounds, replace: false); - CommitEntries(_flatGfxObj, replacement.FlatGfxObjects, replace: false); - CommitEntries(_setup, replacement.Setups, replace: false); - CommitEntries(_flatSetup, replacement.FlatSetups, replace: false); - CommitEntries(_cellStruct, replacement.Cells, replace: true); - CommitEntries(_flatCellStruct, replacement.FlatCells, replace: true); - CommitEntries(_flatEnvCell, replacement.FlatEnvCells, replace: true); - CommitEntries(_buildings, replacement.Buildings, replace: true); - CellGraph.CommitLandblockReplacement(replacement.CellGraph); - } - - private static void CopyDictionary( - ConcurrentDictionary source, - ConcurrentDictionary destination) - { - foreach ((uint id, T value) in source) - destination.TryAdd(id, value); - } - - private static void CommitEntries( - ConcurrentDictionary destination, - IReadOnlyList> entries, - bool replace) - { - for (int index = 0; index < entries.Count; index++) - { - (uint id, T value) = entries[index]; - if (replace) - destination[id] = value; - else - destination.TryAdd(id, value); - } - } - - private static void RemoveEntries( - ConcurrentDictionary destination, - IReadOnlyList ids) - { - for (int index = 0; index < ids.Count; index++) - destination.TryRemove(ids[index], out _); - } - /// /// Extract and cache the physics BSP + polygon data from a GfxObj, /// PLUS always cache a visual AABB from the vertex data regardless of @@ -1085,6 +1052,9 @@ public sealed class PhysicsDataCache if (_cursor < _gfxIds.Length) { uint id = _gfxIds[_cursor++]; + Preinstall(_staging._gfxObj, _active._gfxObj, id); + Preinstall(_staging._visualBounds, _active._visualBounds, id); + Preinstall(_staging._flatGfxObj, _active._flatGfxObj, id); Capture(_staging._gfxObj, id, _gfx); Capture(_staging._visualBounds, id, _bounds); Capture(_staging._flatGfxObj, id, _flatGfx); @@ -1098,6 +1068,8 @@ public sealed class PhysicsDataCache if (_cursor < _setupIds.Length) { uint id = _setupIds[_cursor++]; + Preinstall(_staging._setup, _active._setup, id); + Preinstall(_staging._flatSetup, _active._flatSetup, id); Capture(_staging._setup, id, _setups); Capture(_staging._flatSetup, id, _flatSetups); WorkUnits++; @@ -1229,6 +1201,15 @@ public sealed class PhysicsDataCache destination.Add(new KeyValuePair(id, value)); } + private static void Preinstall( + ConcurrentDictionary source, + ConcurrentDictionary destination, + uint id) + { + if (source.TryGetValue(id, out T? value)) + destination.TryAdd(id, value); + } + private static bool CapturePrefixOne( IEnumerator> enumerator, uint prefix, diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index cd7e6b07..d4d7610b 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Numerics; using AcDream.Core.Items; +using AcDream.Core.World.Cells; namespace AcDream.Core.Physics; @@ -28,7 +29,15 @@ internal readonly record struct TerrainWalkableSample( /// public sealed class PhysicsEngine { - private readonly Dictionary _landblocks = new(); + private CollisionWorldStateSlot _collisionWorld; + private Dictionary _landblocks => + _collisionWorld.Current.Landblocks; + private List _landblockSlots => + _collisionWorld.Current.LandblockSlots; + private Dictionary _landblockIndices => + _collisionWorld.Current.LandblockIndices; + private Stack _landblockFreeSlots => + _collisionWorld.Current.LandblockFreeSlots; private readonly TransitionScratchArena? _transitionScratch; public PhysicsEngine() @@ -43,6 +52,8 @@ public sealed class PhysicsEngine /// internal PhysicsEngine(bool reuseTransitionScratch) { + _collisionWorld = new CollisionWorldStateSlot(); + ShadowObjects = new ShadowObjectRegistry(_collisionWorld); _transitionScratch = reuseTransitionScratch ? new TransitionScratchArena() : null; @@ -57,6 +68,8 @@ public sealed class PhysicsEngine /// Number of registered landblocks (diagnostic). public int LandblockCount => _landblocks.Count; + internal CollisionWorldStateSlot CollisionWorld => _collisionWorld; + /// /// Optional high-volume collision trace sink. Production leaves this /// unset; focused diagnostic gates may opt in explicitly. @@ -85,7 +98,7 @@ public sealed class PhysicsEngine public bool IsLandblockTerrainResident(uint cellOrLandblockId) { uint prefix = cellOrLandblockId & 0xFFFF0000u; - foreach (var key in _landblocks.Keys) + foreach ((uint key, _) in _landblocks) if ((key & 0xFFFF0000u) == prefix) return true; return false; } @@ -103,7 +116,8 @@ public sealed class PhysicsEngine public bool IsNeighborhoodTerrainResident(uint cellOrLandblockId, int radius) { var resident = new HashSet(); - foreach (var key in _landblocks.Keys) resident.Add(key & 0xFFFF0000u); + foreach ((uint key, _) in _landblocks) + resident.Add(key & 0xFFFF0000u); int cx = (int)((cellOrLandblockId >> 24) & 0xFFu); int cy = (int)((cellOrLandblockId >> 16) & 0xFFu); @@ -122,7 +136,7 @@ public sealed class PhysicsEngine /// Cell-based spatial index for static object collision. /// Populated during landblock streaming; queried by the Transition system. /// - public ShadowObjectRegistry ShadowObjects { get; } = new(); + public ShadowObjectRegistry ShadowObjects { get; } /// /// Physics BSP cache shared with the streaming loader. Set once by the @@ -135,10 +149,61 @@ public sealed class PhysicsEngine public PhysicsDataCache? DataCache { get => _dataCache; - set { _dataCache = value; ShadowObjects.DataCache = value; } + set + { + if (value is not null + && !ReferenceEquals(_collisionWorld, value.CollisionWorld)) + { + if (ShadowObjects.TotalRegistered != 0) + { + throw new InvalidOperationException( + "A populated physics engine cannot change collision roots."); + } + // Legacy/test construction commonly installs terrain before + // attaching its first cache. Preserve that one-time ordering + // without permitting a live populated root swap: move only + // the engine-owned landblock index into the still-private + // cache root, then bind the stable facades. + if (_dataCache is null && _landblocks.Count != 0) + CopyDetachedLandblocksTo(value.CollisionWorld); + else if (_landblocks.Count != 0) + throw new InvalidOperationException( + "A populated physics engine cannot change collision roots."); + _collisionWorld = value.CollisionWorld; + ShadowObjects.AttachCollisionWorld(_collisionWorld); + } + _dataCache = value; + ShadowObjects.DataCache = value; + } } private PhysicsDataCache? _dataCache; + private void CopyDetachedLandblocksTo( + CollisionWorldStateSlot destinationSlot) + { + CollisionWorldState source = _collisionWorld.Current; + CollisionWorldState destination = destinationSlot.Current; + if (destination.Landblocks.Count != 0 + || destination.LandblockSlots.Count != 0 + || destination.LandblockIndices.Count != 0 + || destination.LandblockFreeSlots.Count != 0) + { + throw new InvalidOperationException( + "A cache collision root already owns engine landblocks."); + } + foreach ((uint landblockId, LandblockPhysics landblock) in + source.Landblocks) + { + destination.Landblocks.Add(landblockId, landblock); + } + destination.LandblockSlots.AddRange(source.LandblockSlots); + foreach ((uint landblockId, int slot) in source.LandblockIndices) + destination.LandblockIndices.Add(landblockId, slot); + int[] freeSlots = source.LandblockFreeSlots.ToArray(); + for (int index = freeSlots.Length - 1; index >= 0; index--) + destination.LandblockFreeSlots.Push(freeSlots[index]); + } + /// /// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): optional live /// weenie-object table, consulted ONLY by @@ -165,21 +230,16 @@ public sealed class PhysicsEngine /// Streaming modifies this copy only; the active engine and its borrowed /// cache/registry identities remain stable until Runtime commits. /// - internal PhysicsEngine CreateCollisionStagingCopy( - PhysicsDataCache stagingCache) + internal CollisionStagingBuilder CreateCollisionStagingBuilder( + uint targetLandblockId) { - ArgumentNullException.ThrowIfNull(stagingCache); - var staging = new PhysicsEngine - { - DataCache = stagingCache, - Objects = Objects, - }; - foreach ((uint id, LandblockPhysics landblock) in _landblocks) - staging._landblocks[id] = landblock; - staging.ShadowObjects.CopyCollisionStateFrom( - ShadowObjects, - stagingCache); - return staging; + PhysicsDataCache activeCache = DataCache + ?? throw new InvalidOperationException( + "Active collision engine has no data cache."); + return new CollisionStagingBuilder( + this, + activeCache, + targetLandblockId & 0xFFFF0000u); } internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( @@ -187,7 +247,7 @@ public sealed class PhysicsEngine uint landblockId, uint[] gfxObjectIds, uint[] setupIds, - IReadOnlyDictionary expectedRetainedVersions) + IReadOnlyList expectedRetainedOwners) { ArgumentNullException.ThrowIfNull(staging); uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; @@ -204,6 +264,7 @@ public sealed class PhysicsEngine return new LandblockReplacementBuilder( canonical, landblock, + staging, (DataCache ?? throw new InvalidOperationException( "Active collision engine has no data cache.")) .CreateLandblockReplacementBuilder( @@ -214,17 +275,721 @@ public sealed class PhysicsEngine ShadowObjects.CreateLandblockReplacementBuilder( staging.ShadowObjects, canonical, - expectedRetainedVersions)); + expectedRetainedOwners)); } internal void CommitLandblockReplacement( PreparedPhysicsEngineLandblock replacement) { - (DataCache ?? throw new InvalidOperationException( - "Active collision engine has no data cache.")) - .CommitLandblockReplacement(replacement.DataCache); - _landblocks[replacement.LandblockId] = replacement.Landblock; - ShadowObjects.CommitLandblockReplacement(replacement.Shadows); + PhysicsDataCache activeCache = DataCache + ?? throw new InvalidOperationException( + "Active collision engine has no data cache."); + PhysicsDataCache stagingCache = replacement.Staging.DataCache + ?? throw new InvalidOperationException( + "Staging collision engine has no data cache."); + uint activeCurrentCellId = activeCache.CellGraph.CurrCell?.Id ?? 0u; + stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld); + if ((activeCurrentCellId & 0xFFFF0000u) + == (replacement.LandblockId & 0xFFFF0000u)) + { + activeCache.CellGraph.CurrCell = + activeCache.CellGraph.GetVisible(activeCurrentCellId); + } + } + + internal LandblockReplacementApplyCursor + CreateLandblockReplacementApplyCursor( + PreparedPhysicsEngineLandblock replacement) => + new(this, replacement); + + internal readonly record struct LandblockReplacementApplyStep( + bool Completed, + bool Worked, + bool HasOwner, + uint OwnerId); + + internal LandblockRetirementCursor CreateLandblockRetirementCursor( + PhysicsEngine authoritative, + uint landblockId, + bool withdraw) => new( + this, + authoritative, + landblockId, + withdraw); + + /// + /// Applies one demotion/withdrawal to an off-side root without a whole- + /// world synchronous scan. Every advance inspects or mutates at most one + /// stable owner slot, dictionary leaf, or authored outdoor cell. + /// + internal sealed class LandblockRetirementCursor : IDisposable + { + private readonly PhysicsEngine _destinationEngine; + private readonly PhysicsDataCache _destinationCache; + private readonly CollisionWorldState _destination; + private readonly CollisionWorldState _authoritative; + private readonly uint _canonical; + private readonly uint _prefix; + private readonly bool _withdraw; + private readonly List _ownerSlots; + private readonly int _ownerSlotLimit; + private readonly LandblockPhysics? _demotedLandblock; + private readonly CellGraphTerrain? _demotedTerrain; + private IEnumerator>? _cells; + private IEnumerator>? + _flatCells; + private IEnumerator>? _flatEnvCells; + private IEnumerator>? _buildings; + private IEnumerator>? _envCells; + private int _ownerIndex; + private int _outdoorIndex; + private int _phase; + + internal LandblockRetirementCursor( + PhysicsEngine destination, + PhysicsEngine authoritative, + uint landblockId, + bool withdraw) + { + _destinationEngine = destination; + _destinationCache = destination.DataCache + ?? throw new InvalidOperationException( + "Collision engine has no data cache."); + _destination = destination._collisionWorld.Capture(); + _authoritative = authoritative._collisionWorld.Capture(); + _canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + _prefix = landblockId & 0xFFFF0000u; + _withdraw = withdraw; + _ownerSlots = _destination.ShadowOwnerSlots; + _ownerSlotLimit = _ownerSlots.Count; + if (!withdraw) + { + _authoritative.Landblocks.TryGetValue( + _canonical, + out _demotedLandblock); + _authoritative.Terrain.TryGetValue( + _prefix, + out _demotedTerrain); + } + } + + internal uint LandblockId => _canonical; + + internal LandblockRetirementStep Advance() + { + while (true) + { + switch (_phase) + { + case 0: + if (_ownerIndex < _ownerSlotLimit) + { + uint ownerId = _ownerSlots[_ownerIndex++]; + if (ownerId != 0u) + { + _destinationEngine.ShadowObjects + .RetireOwnerFromLandblock( + ownerId, + _canonical); + } + return Worked(); + } + _phase++; + continue; + case 1: + _cells ??= _destination.CellStruct.GetEnumerator(); + if (RemoveOneInPrefix(_cells, _destination.CellStruct)) + return Worked(); + DisposeEnumerator(ref _cells); + _phase++; + continue; + case 2: + _flatCells ??= _destination.FlatCellStruct.GetEnumerator(); + if (RemoveOneInPrefix( + _flatCells, + _destination.FlatCellStruct)) + return Worked(); + DisposeEnumerator(ref _flatCells); + _phase++; + continue; + case 3: + _flatEnvCells ??= _destination.FlatEnvCell.GetEnumerator(); + if (RemoveOneInPrefix( + _flatEnvCells, + _destination.FlatEnvCell)) + return Worked(); + DisposeEnumerator(ref _flatEnvCells); + _phase++; + continue; + case 4: + _buildings ??= _destination.Buildings.GetEnumerator(); + if (RemoveOneInPrefix( + _buildings, + _destination.Buildings)) + return Worked(); + DisposeEnumerator(ref _buildings); + _phase++; + continue; + case 5: + _envCells ??= _destination.EnvCells.GetEnumerator(); + if (RemoveOneInPrefix(_envCells, _destination.EnvCells)) + return Worked(); + DisposeEnumerator(ref _envCells); + _phase++; + continue; + case 6: + if (_outdoorIndex < 0x40) + { + uint id = _prefix | (uint)++_outdoorIndex; + _destination.ShadowCells.Remove(id); + if (_withdraw) + { + _destination.OutdoorCells.TryRemove(id, out _); + } + else if (_authoritative.OutdoorCells.TryGetValue( + id, + out ObjCell? outdoor)) + { + _destination.OutdoorCells[id] = outdoor; + } + return Worked(); + } + _phase++; + continue; + case 7: + if (_withdraw) + { + _destinationEngine._landblocks.Remove(_canonical); + _destinationEngine.RemoveLandblockSlot(_canonical); + _destination.Terrain.TryRemove(_prefix, out _); + } + else + { + if (_demotedLandblock is not null) + { + _destinationEngine._landblocks[_canonical] = + _demotedLandblock; + _destinationEngine.EnsureLandblockSlot(_canonical); + } + if (_demotedTerrain is not null) + _destination.Terrain[_prefix] = _demotedTerrain; + } + _phase++; + return Worked(); + case 8: + uint currentCellId = + _destinationCache.CellGraph.CurrCell?.Id ?? 0u; + if ((currentCellId & 0xFFFF0000u) == _prefix + && (_withdraw + || (currentCellId & 0xFFFFu) >= 0x0100u)) + { + _destinationCache.CellGraph.CurrCell = null; + } + _phase++; + return new LandblockRetirementStep( + Completed: true, + Worked: false); + default: + return new LandblockRetirementStep( + Completed: true, + Worked: false); + } + } + } + + private LandblockRetirementStep Worked() => new( + Completed: false, + Worked: true); + + private bool RemoveOneInPrefix( + IEnumerator> source, + IDictionary destination) + { + if (!source.MoveNext()) + return false; + uint id = source.Current.Key; + if ((id & 0xFFFF0000u) == _prefix) + { + destination.Remove(id); + _destination.ShadowCells.Remove(id); + } + return true; + } + + private static void DisposeEnumerator( + ref IEnumerator>? enumerator) + { + enumerator?.Dispose(); + enumerator = null; + } + + public void Dispose() + { + DisposeEnumerator(ref _cells); + DisposeEnumerator(ref _flatCells); + DisposeEnumerator(ref _flatEnvCells); + DisposeEnumerator(ref _buildings); + DisposeEnumerator(ref _envCells); + } + } + + internal readonly record struct LandblockRetirementStep( + bool Completed, + bool Worked); + + /// + /// Applies one already-committed landblock delta to a later off-side root. + /// Each advance mutates at most one dictionary leaf, one synthesized + /// outdoor cell, or one logical shadow owner. + /// + internal sealed class LandblockReplacementApplyCursor : IDisposable + { + private readonly PhysicsEngine _destinationEngine; + private readonly PhysicsDataCache _destinationCache; + private readonly CollisionWorldState _destination; + private readonly PreparedPhysicsEngineLandblock _replacement; + private int _phase; + private int _index; + + internal LandblockReplacementApplyCursor( + PhysicsEngine destination, + PreparedPhysicsEngineLandblock replacement) + { + _destinationEngine = destination; + _destinationCache = destination.DataCache + ?? throw new InvalidOperationException( + "Collision engine has no data cache."); + _destination = _destinationCache.CollisionWorld.Capture(); + _replacement = replacement; + } + + internal uint LandblockId => _replacement.LandblockId; + + internal LandblockReplacementApplyStep Advance() + { + PreparedPhysicsDataCacheLandblock data = _replacement.DataCache; + PreparedCellGraphLandblock graph = data.CellGraph; + while (true) + { + switch (_phase) + { + case 0: + if (RemoveOne(_destination.CellStruct, data.CellIdsToRemove)) + return Worked(); + NextPhase(); + continue; + case 1: + if (InstallOne(_destination.CellStruct, data.Cells)) + return Worked(); + NextPhase(); + continue; + case 2: + if (RemoveOne(_destination.FlatCellStruct, data.FlatCellIdsToRemove)) + return Worked(); + NextPhase(); + continue; + case 3: + if (InstallOne(_destination.FlatCellStruct, data.FlatCells)) + return Worked(); + NextPhase(); + continue; + case 4: + if (RemoveOne(_destination.FlatEnvCell, data.FlatEnvCellIdsToRemove)) + return Worked(); + NextPhase(); + continue; + case 5: + if (InstallOne(_destination.FlatEnvCell, data.FlatEnvCells)) + return Worked(); + NextPhase(); + continue; + case 6: + if (RemoveOne(_destination.Buildings, data.BuildingIdsToRemove)) + return Worked(); + NextPhase(); + continue; + case 7: + if (InstallOne(_destination.Buildings, data.Buildings)) + return Worked(); + NextPhase(); + continue; + case 8: + if (RemoveOne(_destination.EnvCells, graph.EnvCellIdsToRemove)) + return Worked(); + NextPhase(); + continue; + case 9: + if (graph.HasTerrain) + { + if (_index == 0) + { + _destination.Terrain[graph.LandblockPrefix] = + graph.Terrain!; + _index++; + return Worked(); + } + if (_index <= 0x40) + { + uint low = (uint)_index++; + CellGraphTerrain terrain = graph.Terrain!; + int cellIndex = (int)(low - 1u); + uint id = graph.LandblockPrefix | low; + _destination.OutdoorCells[id] = + LandCell.Synthesize( + id, + terrain.Terrain, + terrain.Origin, + cellIndex / 8, + cellIndex % 8); + return Worked(); + } + } + else + { + if (_index == 0) + { + _destination.Terrain.TryRemove( + graph.LandblockPrefix, + out _); + _index++; + return Worked(); + } + if (_index <= 0x40) + { + uint low = (uint)_index++; + _destination.OutdoorCells.TryRemove( + graph.LandblockPrefix | low, + out _); + return Worked(); + } + } + NextPhase(); + continue; + case 10: + if (InstallOne(_destination.EnvCells, graph.EnvCells)) + return Worked(); + NextPhase(); + continue; + case 11: + _destinationEngine.InstallLandblockClone( + _replacement.LandblockId, + _replacement.Landblock); + NextPhase(); + return Worked(); + case 12: + if (_index < _replacement.Shadows.OwnerIds.Count) + { + uint ownerId = + _replacement.Shadows.OwnerIds[_index++]; + return new LandblockReplacementApplyStep( + Completed: false, + Worked: true, + HasOwner: true, + ownerId); + } + NextPhase(); + continue; + case 13: + uint currentCellId = + _destinationCache.CellGraph.CurrCell?.Id ?? 0u; + if ((currentCellId & 0xFFFF0000u) + == graph.LandblockPrefix) + { + _destinationCache.CellGraph.CurrCell = + _destinationCache.CellGraph.GetVisible( + currentCellId); + } + _phase++; + return new LandblockReplacementApplyStep( + Completed: true, + Worked: false, + HasOwner: false, + OwnerId: 0u); + default: + return new LandblockReplacementApplyStep( + Completed: true, + Worked: false, + HasOwner: false, + OwnerId: 0u); + } + } + } + + private LandblockReplacementApplyStep Worked() => new( + Completed: false, + Worked: true, + HasOwner: false, + OwnerId: 0u); + + private void NextPhase() + { + _phase++; + _index = 0; + } + + private bool RemoveOne( + IDictionary destination, + IReadOnlyList ids) + { + if (_index >= ids.Count) + return false; + destination.Remove(ids[_index++]); + return true; + } + + private bool InstallOne( + IDictionary destination, + IReadOnlyList> entries) + { + if (_index >= entries.Count) + return false; + KeyValuePair pair = entries[_index++]; + destination[pair.Key] = pair.Value; + return true; + } + + public void Dispose() + { + } + } + + /// + /// Retained one-leaf-at-a-time materializer for an off-side collision + /// generation. Construction captures the current root reference only; no + /// resident dictionary is copied until . Runtime's + /// owner journal reconciles mutations that occur while this cursor walks. + /// + internal sealed class CollisionStagingBuilder : IDisposable + { + private readonly PhysicsEngine _active; + private readonly CollisionWorldState _source; + private readonly CollisionWorldState _destination; + private readonly ShadowObjectRegistry _sourceShadows; + private readonly uint _targetPrefix; + private readonly HashSet _suppressedPrefixes = new(); + private readonly int _landblockSlotLimit; + private readonly int _ownerSlotLimit; + private IEnumerator>? _cells; + private IEnumerator>? _flatCells; + private IEnumerator>? _flatEnvCells; + private IEnumerator>? _buildings; + private IEnumerator>? _envCells; + private IEnumerator>? _terrain; + private IEnumerator>? _outdoorCells; + private int _landblockIndex; + private int _ownerIndex; + private int _phase; + + internal CollisionStagingBuilder( + PhysicsEngine active, + PhysicsDataCache activeCache, + uint targetPrefix) + { + _active = active; + _targetPrefix = targetPrefix; + _source = active._collisionWorld.Capture(); + var stagingSlot = new CollisionWorldStateSlot(); + StagingCache = activeCache.CreateEmptyCollisionStaging(stagingSlot); + StagingEngine = new PhysicsEngine + { + DataCache = StagingCache, + Objects = active.Objects, + }; + _destination = stagingSlot.Capture(); + _sourceShadows = new ShadowObjectRegistry( + new CollisionWorldStateSlot(_source)); + _landblockSlotLimit = _source.LandblockSlots.Count; + _ownerSlotLimit = _source.ShadowOwnerSlots.Count; + } + + internal PhysicsDataCache StagingCache { get; } + internal PhysicsEngine StagingEngine { get; } + internal int WorkUnits { get; private set; } + internal bool Completed => _phase == 9; + + /// + /// Prevent a landblock retired after this cursor captured its source + /// root from being copied back into the draft by a later phase. + /// Already-copied leaves are retired by the caller before cloning + /// resumes; this tombstone covers every leaf not visited yet. + /// + internal void SuppressLandblock(uint landblockId) => + _suppressedPrefixes.Add(landblockId & 0xFFFF0000u); + + internal bool Advance() + { + switch (_phase) + { + case 0: + if (_landblockIndex < _landblockSlotLimit) + { + uint id = _source.LandblockSlots[_landblockIndex++]; + if (id != 0u + && (id & 0xFFFF0000u) != _targetPrefix + && !_suppressedPrefixes.Contains( + id & 0xFFFF0000u) + && _source.Landblocks.TryGetValue( + id, + out LandblockPhysics? landblock)) + { + StagingEngine.InstallLandblockClone(id, landblock); + } + WorkUnits++; + return false; + } + _phase++; + return false; + case 1: + _cells ??= _source.CellStruct.GetEnumerator(); + if (CopyOneOutsideTarget(_cells, _destination.CellStruct)) + return CountOne(); + DisposeEnumerator(ref _cells); + _phase++; + return false; + case 2: + _flatCells ??= _source.FlatCellStruct.GetEnumerator(); + if (CopyOneOutsideTarget(_flatCells, _destination.FlatCellStruct)) + return CountOne(); + DisposeEnumerator(ref _flatCells); + _phase++; + return false; + case 3: + _flatEnvCells ??= _source.FlatEnvCell.GetEnumerator(); + if (CopyOneOutsideTarget(_flatEnvCells, _destination.FlatEnvCell)) + return CountOne(); + DisposeEnumerator(ref _flatEnvCells); + _phase++; + return false; + case 4: + _buildings ??= _source.Buildings.GetEnumerator(); + if (CopyOneOutsideTarget(_buildings, _destination.Buildings)) + return CountOne(); + DisposeEnumerator(ref _buildings); + _phase++; + return false; + case 5: + _envCells ??= _source.EnvCells.GetEnumerator(); + if (CopyOneOutsideTarget(_envCells, _destination.EnvCells)) + return CountOne(); + DisposeEnumerator(ref _envCells); + _phase++; + return false; + case 6: + _terrain ??= _source.Terrain.GetEnumerator(); + if (CopyOneOutsideTarget(_terrain, _destination.Terrain)) + return CountOne(); + DisposeEnumerator(ref _terrain); + _phase++; + return false; + case 7: + _outdoorCells ??= _source.OutdoorCells.GetEnumerator(); + if (CopyOneOutsideTarget(_outdoorCells, _destination.OutdoorCells)) + return CountOne(); + DisposeEnumerator(ref _outdoorCells); + _phase++; + return false; + case 8: + if (_ownerIndex < _ownerSlotLimit) + { + uint ownerId = _source.ShadowOwnerSlots[_ownerIndex++]; + if (ownerId != 0u + && !_sourceShadows.IsStaticOwnerRootedIn( + ownerId, + _targetPrefix) + && !IsSuppressedStaticOwner(ownerId) + && !StagingEngine.ShadowObjects.HasLogicalOwner( + ownerId)) + { + StagingEngine.ShadowObjects.MirrorOwnerFrom( + _sourceShadows, + ownerId); + } + WorkUnits++; + return false; + } + uint currentCellId = _active.DataCache?.CellGraph.CurrCell?.Id ?? 0u; + StagingCache.CellGraph.CurrCell = + StagingCache.CellGraph.GetVisible(currentCellId); + _phase++; + return true; + default: + return true; + } + } + + private bool CountOne() + { + WorkUnits++; + return false; + } + + private bool CopyOneOutsideTarget( + IEnumerator> source, + IDictionary destination) + { + if (!source.MoveNext()) + return false; + KeyValuePair pair = source.Current; + uint prefix = pair.Key & 0xFFFF0000u; + if (prefix != _targetPrefix + && !_suppressedPrefixes.Contains(prefix)) + destination[pair.Key] = pair.Value; + return true; + } + + private bool IsSuppressedStaticOwner(uint ownerId) + => _sourceShadows.TryGetStaticOwnerRootPrefix( + ownerId, + out uint prefix) + && _suppressedPrefixes.Contains(prefix); + + private static void DisposeEnumerator( + ref IEnumerator>? enumerator) + { + enumerator?.Dispose(); + enumerator = null; + } + + public void Dispose() + { + DisposeEnumerator(ref _cells); + DisposeEnumerator(ref _flatCells); + DisposeEnumerator(ref _flatEnvCells); + DisposeEnumerator(ref _buildings); + DisposeEnumerator(ref _envCells); + DisposeEnumerator(ref _terrain); + DisposeEnumerator(ref _outdoorCells); + } + } + + private void InstallLandblockClone( + uint landblockId, + LandblockPhysics landblock) + { + _landblocks[landblockId] = landblock; + EnsureLandblockSlot(landblockId); + } + + private void EnsureLandblockSlot(uint landblockId) + { + if (_landblockIndices.ContainsKey(landblockId)) + return; + if (_landblockFreeSlots.TryPop(out int freeIndex)) + { + _landblockSlots[freeIndex] = landblockId; + _landblockIndices[landblockId] = freeIndex; + return; + } + _landblockIndices[landblockId] = _landblockSlots.Count; + _landblockSlots.Add(landblockId); + } + + private void RemoveLandblockSlot(uint landblockId) + { + if (!_landblockIndices.Remove(landblockId, out int slotIndex)) + return; + _landblockSlots[slotIndex] = 0u; + _landblockFreeSlots.Push(slotIndex); } internal sealed class PreparedPhysicsEngineLandblock @@ -232,17 +997,20 @@ public sealed class PhysicsEngine internal PreparedPhysicsEngineLandblock( uint landblockId, LandblockPhysics landblock, + PhysicsEngine staging, PreparedPhysicsDataCacheLandblock dataCache, ShadowObjectRegistry.PreparedLandblockShadowReplacement shadows) { LandblockId = landblockId; Landblock = landblock; + Staging = staging; DataCache = dataCache; Shadows = shadows; } internal uint LandblockId { get; } internal LandblockPhysics Landblock { get; } + internal PhysicsEngine Staging { get; } internal PreparedPhysicsDataCacheLandblock DataCache { get; } internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; } } @@ -251,6 +1019,7 @@ public sealed class PhysicsEngine { private readonly uint _landblockId; private readonly LandblockPhysics _landblock; + private readonly PhysicsEngine _staging; private readonly PhysicsDataCache.LandblockReplacementBuilder _data; private readonly ShadowObjectRegistry.LandblockReplacementBuilder _shadows; private int _phase; @@ -258,19 +1027,23 @@ public sealed class PhysicsEngine internal LandblockReplacementBuilder( uint landblockId, LandblockPhysics landblock, + PhysicsEngine staging, PhysicsDataCache.LandblockReplacementBuilder data, ShadowObjectRegistry.LandblockReplacementBuilder shadows) { _landblockId = landblockId; _landblock = landblock; + _staging = staging; _data = data; _shadows = shadows; } internal int WorkUnits => _data.WorkUnits + _shadows.WorkUnits; - internal bool IsStable => _shadows.IsStable; internal PreparedPhysicsEngineLandblock? Prepared { get; private set; } + internal void RefreshRetainedOwner(uint ownerId) => + _shadows.RefreshOwner(ownerId); + internal bool Advance() { if (_phase == 0) @@ -284,12 +1057,13 @@ public sealed class PhysicsEngine { if (!_shadows.Advance()) return false; - if (IsStable && _data.Prepared is not null + if (_data.Prepared is not null && _shadows.Prepared is not null) { - Prepared = new PreparedPhysicsEngineLandblock( - _landblockId, - _landblock, + Prepared = new PreparedPhysicsEngineLandblock( + _landblockId, + _landblock, + _staging, _data.Prepared, _shadows.Prepared); } @@ -314,6 +1088,7 @@ public sealed class PhysicsEngine float worldOffsetX, float worldOffsetY) { _landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY); + EnsureLandblockSlot(landblockId); // UCG Stage 1: mirror terrain into the unified graph (inert this stage). DataCache?.CellGraph.RegisterTerrain(landblockId, terrain, new Vector3(worldOffsetX, worldOffsetY, 0f)); @@ -325,6 +1100,7 @@ public sealed class PhysicsEngine public void RemoveLandblock(uint landblockId) { _landblocks.Remove(landblockId); + RemoveLandblockSlot(landblockId); ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId); ShadowObjects.RemoveLandblock(landblockId); DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 2c2d6fdc..d79e14c5 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -26,20 +26,26 @@ namespace AcDream.Core.Physics; /// public sealed class ShadowObjectRegistry { - private readonly Dictionary> _cells = new(); - private readonly Dictionary> _entityToCells = new(); // for deregistration - private readonly HashSet _suspendedEntities = new(); + private CollisionWorldStateSlot _collisionWorld; + private Dictionary> _cells => + _collisionWorld.Current.ShadowCells; + private Dictionary> _entityToCells => + _collisionWorld.Current.ShadowEntityCells; // for deregistration + private HashSet _suspendedEntities => + _collisionWorld.Current.SuspendedShadowEntities; // Rows withdrawn because a touched landblock streamed out. The owner may // be seeded in an adjacent still-resident landblock, so its remaining rows // cannot by themselves tell RefloodLandblock that this prefix needs repair. - private readonly Dictionary> _withdrawnPrefixesByOwner = new(); + private Dictionary> _withdrawnPrefixesByOwner => + _collisionWorld.Current.WithdrawnPrefixesByOwner; /// /// A6.P4 door fix (2026-05-24): per-entity original shape list, used by /// to recompose part world-transforms when /// the entity moves. Cleared by . /// - private readonly Dictionary> _entityShapes = new(); + private Dictionary> _entityShapes => + _collisionWorld.Current.ShadowEntityShapes; /// /// BR-7: per-entity registration arguments, kept so a registration can be @@ -50,9 +56,50 @@ public sealed class ShadowObjectRegistry /// gets its cell set recomputed afterwards. /// is the streaming-side trigger. /// - private readonly Dictionary _entityReg = new(); - private readonly Dictionary _ownerVersions = new(); - private ulong _mutationVersion; + private Dictionary _entityReg => + _collisionWorld.Current.ShadowEntityRegistrations; + private Dictionary _ownerVersions => + _collisionWorld.Current.ShadowOwnerVersions; + private Dictionary> _ownerPrefixes => + _collisionWorld.Current.ShadowOwnerPrefixes; + private Dictionary> _prefixOwnerSlots => + _collisionWorld.Current.ShadowPrefixOwnerSlots; + private Dictionary> _prefixOwnerIndices => + _collisionWorld.Current.ShadowPrefixOwnerIndices; + private Dictionary> _prefixFreeSlots => + _collisionWorld.Current.ShadowPrefixFreeSlots; + private List _ownerSlots => + _collisionWorld.Current.ShadowOwnerSlots; + private Dictionary _ownerIndices => + _collisionWorld.Current.ShadowOwnerIndices; + private Stack _ownerFreeSlots => + _collisionWorld.Current.ShadowOwnerFreeSlots; + private readonly HashSet _prefixScratch = new(); + private readonly List _removedPrefixScratch = new(); + internal event Action? OwnerMutated; + internal event Action? OwnerPrefixMembershipChanged; + + public ShadowObjectRegistry() + : this(new CollisionWorldStateSlot()) + { + } + + internal ShadowObjectRegistry(CollisionWorldStateSlot collisionWorld) + { + _collisionWorld = collisionWorld + ?? throw new ArgumentNullException(nameof(collisionWorld)); + } + + internal void AttachCollisionWorld(CollisionWorldStateSlot collisionWorld) + { + ArgumentNullException.ThrowIfNull(collisionWorld); + if (_cells.Count != 0 || _entityReg.Count != 0) + { + throw new InvalidOperationException( + "A populated shadow registry cannot change collision roots."); + } + _collisionWorld = collisionWorld; + } internal sealed record RegistrationRecord( uint SeedCellId, @@ -76,31 +123,38 @@ public sealed class ShadowObjectRegistry private void BumpOwnerVersion(uint entityId) { - _ownerVersions[entityId] = checked(GetOwnerVersion(entityId) + 1UL); - _mutationVersion = checked(_mutationVersion + 1UL); + ulong version = checked(GetOwnerVersion(entityId) + 1UL); + _ownerVersions[entityId] = version; + RefreshOwnerPrefixIndex(entityId); + OwnerMutated?.Invoke(entityId, version); } - internal ulong MutationVersion => _mutationVersion; - internal RetainedRefloodOwnerScan CreateRetainedRefloodOwnerScan( - uint landblockId) => new(this, landblockId & 0xFFFF0000u); + uint landblockId) + { + uint prefix = landblockId & 0xFFFF0000u; + _prefixOwnerSlots.TryGetValue(prefix, out List? slots); + return new RetainedRefloodOwnerScan(this, prefix, slots); + } internal sealed class RetainedRefloodOwnerScan : IDisposable { private readonly ShadowObjectRegistry _owner; private readonly uint _prefix; - private readonly ulong _sourceMutationVersion; - private Dictionary.Enumerator _enumerator; + private readonly List? _slots; + private readonly int _limit; + private int _index; private bool _completed; internal RetainedRefloodOwnerScan( ShadowObjectRegistry owner, - uint prefix) + uint prefix, + List? slots) { _owner = owner; _prefix = prefix; - _sourceMutationVersion = owner.MutationVersion; - _enumerator = owner._entityReg.GetEnumerator(); + _slots = slots; + _limit = slots?.Count ?? 0; } internal RetainedRefloodOwnerScanStep Advance() @@ -109,55 +163,169 @@ public sealed class ShadowObjectRegistry { return new RetainedRefloodOwnerScanStep( Completed: true, - Stable: _owner.MutationVersion == _sourceMutationVersion, HasOwner: false, - OwnerId: 0u, - SourceMutationVersion: _sourceMutationVersion); + OwnerId: 0u); } - if (_owner.MutationVersion != _sourceMutationVersion) + if (_slots is null || _index >= _limit) { _completed = true; return new RetainedRefloodOwnerScanStep( Completed: true, - Stable: false, HasOwner: false, - OwnerId: 0u, - SourceMutationVersion: _sourceMutationVersion); - } - if (!_enumerator.MoveNext()) - { - _completed = true; - return new RetainedRefloodOwnerScanStep( - Completed: true, - Stable: true, - HasOwner: false, - OwnerId: 0u, - SourceMutationVersion: _sourceMutationVersion); + OwnerId: 0u); } - (uint ownerId, RegistrationRecord registration) = - _enumerator.Current; - bool retained = !_owner._suspendedEntities.Contains(ownerId) - && (!registration.IsStatic - || (registration.SeedCellId & 0xFFFF0000u) != _prefix) - && _owner.OwnerTouchesLandblock(ownerId, _prefix); + uint ownerId = _slots[_index++]; + bool retained = _owner.IsRetainedRefloodOwner(ownerId, _prefix); return new RetainedRefloodOwnerScanStep( Completed: false, - Stable: true, HasOwner: retained, - OwnerId: retained ? ownerId : 0u, - SourceMutationVersion: _sourceMutationVersion); + OwnerId: retained ? ownerId : 0u); } - public void Dispose() => _enumerator.Dispose(); + public void Dispose() { } + } + + private void RefreshOwnerPrefixIndex(uint entityId) + { + if (!_entityReg.ContainsKey(entityId)) + { + RemoveOwnerPrefixMembership(entityId); + return; + } + EnsureOwnerSlot(entityId); + _prefixScratch.Clear(); + if (_entityReg.TryGetValue(entityId, out RegistrationRecord? registration)) + _prefixScratch.Add(registration.SeedCellId & 0xFFFF0000u); + if (_entityToCells.TryGetValue(entityId, out List? cells)) + { + for (int index = 0; index < cells.Count; index++) + _prefixScratch.Add(cells[index] & 0xFFFF0000u); + } + if (_withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? withdrawn)) + { + foreach (uint prefix in withdrawn) + _prefixScratch.Add(prefix & 0xFFFF0000u); + } + + if (!_ownerPrefixes.TryGetValue(entityId, out HashSet? current)) + { + current = new HashSet(); + _ownerPrefixes[entityId] = current; + } + + _removedPrefixScratch.Clear(); + foreach (uint prefix in current) + { + if (!_prefixScratch.Contains(prefix)) + _removedPrefixScratch.Add(prefix); + } + for (int index = 0; index < _removedPrefixScratch.Count; index++) + { + uint prefix = _removedPrefixScratch[index]; + current.Remove(prefix); + if (_prefixOwnerIndices.TryGetValue( + prefix, + out Dictionary? indices) + && indices.Remove(entityId, out int slotIndex)) + { + _prefixOwnerSlots[prefix][slotIndex] = 0u; + _prefixFreeSlots[prefix].Push(slotIndex); + ReleaseEmptyPrefixContainer(prefix, indices); + } + OwnerPrefixMembershipChanged?.Invoke(entityId, prefix); + } + + foreach (uint prefix in _prefixScratch) + { + if (!current.Add(prefix)) + continue; + if (!_prefixOwnerSlots.TryGetValue(prefix, out List? slots)) + { + slots = new List(); + _prefixOwnerSlots[prefix] = slots; + _prefixOwnerIndices[prefix] = new Dictionary(); + _prefixFreeSlots[prefix] = new Stack(); + } + Dictionary indices = _prefixOwnerIndices[prefix]; + if (indices.ContainsKey(entityId)) + continue; + Stack free = _prefixFreeSlots[prefix]; + if (free.TryPop(out int freeIndex)) + { + slots[freeIndex] = entityId; + indices[entityId] = freeIndex; + } + else + { + indices[entityId] = slots.Count; + slots.Add(entityId); + } + OwnerPrefixMembershipChanged?.Invoke(entityId, prefix); + } + + } + + private void RemoveOwnerPrefixMembership(uint entityId) + { + if (_ownerPrefixes.Remove(entityId, out HashSet? prefixes)) + { + foreach (uint prefix in prefixes) + { + if (!_prefixOwnerIndices.TryGetValue( + prefix, + out Dictionary? indices) + || !indices.Remove(entityId, out int slotIndex)) + { + continue; + } + + _prefixOwnerSlots[prefix][slotIndex] = 0u; + _prefixFreeSlots[prefix].Push(slotIndex); + ReleaseEmptyPrefixContainer(prefix, indices); + OwnerPrefixMembershipChanged?.Invoke(entityId, prefix); + } + } + if (_ownerIndices.Remove(entityId, out int ownerSlot)) + { + _ownerSlots[ownerSlot] = 0u; + _ownerFreeSlots.Push(ownerSlot); + } + } + + private void EnsureOwnerSlot(uint entityId) + { + if (_ownerIndices.ContainsKey(entityId)) + return; + if (_ownerFreeSlots.TryPop(out int freeIndex)) + { + _ownerSlots[freeIndex] = entityId; + _ownerIndices[entityId] = freeIndex; + return; + } + _ownerIndices[entityId] = _ownerSlots.Count; + _ownerSlots.Add(entityId); + } + + private void ReleaseEmptyPrefixContainer( + uint prefix, + Dictionary indices) + { + if (indices.Count != 0) + return; + // An in-flight scan retains its captured List reference and observes + // only tombstones. A future owner gets a fresh compact container. + _prefixOwnerSlots.Remove(prefix); + _prefixOwnerIndices.Remove(prefix); + _prefixFreeSlots.Remove(prefix); } internal readonly record struct RetainedRefloodOwnerScanStep( bool Completed, - bool Stable, bool HasOwner, - uint OwnerId, - ulong SourceMutationVersion); + uint OwnerId); /// /// The flood's data source (cells, buildings, terrain origins). Wired by @@ -193,7 +361,8 @@ public sealed class ShadowObjectRegistry uint state = 0u, EntityCollisionFlags flags = EntityCollisionFlags.None, uint seedCellId = 0u, - bool isStatic = true) + bool isStatic = true, + bool publishMutation = true) { // Flood FIRST: retail keeps the previous shadows when the new cell // array would be empty (SetPositionInternal num_cells gate, @@ -211,7 +380,7 @@ public sealed class ShadowObjectRegistry FloodCache, seed, spheres, spheres.Length, isStatic); if (cellSet.Count == 0) return; - Deregister(entityId); + DeregisterCore(entityId, publishMutation: false); var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius, collisionType, cylHeight, scale, state, flags); @@ -227,7 +396,10 @@ public sealed class ShadowObjectRegistry _entityReg[entityId] = new RegistrationRecord( seed, worldPos, rotation, state, flags, isStatic, IsMultiPart: false, gfxObjId, radius, collisionType, cylHeight, scale); - BumpOwnerVersion(entityId); + if (publishMutation) + BumpOwnerVersion(entityId); + else + RefreshOwnerPrefixIndex(entityId); } /// @@ -256,7 +428,8 @@ public sealed class ShadowObjectRegistry EntityCollisionFlags flags, float worldOffsetX, float worldOffsetY, uint landblockId, uint seedCellId = 0u, - bool isStatic = false) + bool isStatic = false, + bool publishMutation = true) { if (shapes.Count == 0) { Deregister(entityId); return; } @@ -271,7 +444,7 @@ public sealed class ShadowObjectRegistry FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic); if (cellSet.Count == 0) return; - Deregister(entityId); + DeregisterCore(entityId, publishMutation: false); _entityShapes[entityId] = shapes; var allCells = new List(cellSet.Count); @@ -307,7 +480,10 @@ public sealed class ShadowObjectRegistry seed, entityWorldPos, entityWorldRot, state, flags, isStatic, IsMultiPart: true, GfxObjId: 0u, Radius: 0f, CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f); - BumpOwnerVersion(entityId); + if (publishMutation) + BumpOwnerVersion(entityId); + else + RefreshOwnerPrefixIndex(entityId); } /// @@ -635,7 +811,8 @@ public sealed class ShadowObjectRegistry 0f, lbPrefix, reg.SeedCellId, - reg.IsStatic); + reg.IsStatic, + publishMutation: false); } else { @@ -654,7 +831,8 @@ public sealed class ShadowObjectRegistry reg.State, reg.Flags, reg.SeedCellId, - reg.IsStatic); + reg.IsStatic, + publishMutation: false); } // Register is also the authoritative movement/replacement API and @@ -674,6 +852,7 @@ public sealed class ShadowObjectRegistry if (withdrawn.Count == 0) _withdrawnPrefixesByOwner.Remove(entityId); } + BumpOwnerVersion(entityId); } /// @@ -711,12 +890,16 @@ public sealed class ShadowObjectRegistry if (retained) { _entityReg[entityId] = retainedRegistration! with { State = newState }; - BumpOwnerVersion(entityId); } if (!_entityToCells.TryGetValue(entityId, out var cellIds)) + { + if (retained) + BumpOwnerVersion(entityId); return; // not registered — no-op + } + foreach (var cellId in cellIds) { if (!_cells.TryGetValue(cellId, out var list)) continue; @@ -727,10 +910,16 @@ public sealed class ShadowObjectRegistry } } + if (retained) + BumpOwnerVersion(entityId); + } /// Remove an entity from all cells it was registered in. public void Deregister(uint entityId) + => DeregisterCore(entityId, publishMutation: true); + + private void DeregisterCore(uint entityId, bool publishMutation) { bool existed = _entityReg.ContainsKey(entityId) || _entityToCells.ContainsKey(entityId) @@ -749,8 +938,12 @@ public sealed class ShadowObjectRegistry _entityReg.Remove(entityId); _suspendedEntities.Remove(entityId); _withdrawnPrefixesByOwner.Remove(entityId); - if (existed) + if (existed && publishMutation) + { BumpOwnerVersion(entityId); + RemoveOwnerPrefixMembership(entityId); + _ownerVersions.Remove(entityId); + } } private static void RemoveOwnerRows( @@ -879,6 +1072,59 @@ public sealed class ShadowObjectRegistry BumpOwnerVersion(entityId); } + /// + /// Retires one logical owner's rows from a streamed-out prefix. This is + /// the owner-granular form used by the collision-generation retirement + /// cursor; it preserves the same static/dynamic lifetime rules as + /// without scanning the complete registry. + /// + internal void RetireOwnerFromLandblock(uint entityId, uint landblockId) + { + uint prefix = landblockId & 0xFFFF0000u; + if (_entityReg.TryGetValue( + entityId, + out RegistrationRecord? registration) + && registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) == prefix) + { + DeregisterCore(entityId, publishMutation: false); + RemoveOwnerPrefixMembership(entityId); + _ownerVersions.Remove(entityId); + return; + } + if (!_entityToCells.TryGetValue(entityId, out List? cells)) + return; + + bool touched = false; + for (int index = cells.Count - 1; index >= 0; index--) + { + uint cellId = cells[index]; + if ((cellId & 0xFFFF0000u) != prefix) + continue; + touched = true; + cells.RemoveAt(index); + if (_cells.TryGetValue(cellId, out List? entries)) + { + RemoveOwnerRows(entries, entityId); + if (entries.Count == 0) + _cells.Remove(cellId); + } + } + if (!touched) + return; + if (!_withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? withdrawn)) + { + withdrawn = new HashSet(); + _withdrawnPrefixesByOwner[entityId] = withdrawn; + } + withdrawn.Add(prefix); + if (cells.Count == 0) + _entityToCells.Remove(entityId); + BumpOwnerVersion(entityId); + } + /// /// All objects registered in a specific cell — retail /// CObjCell::find_obj_collisions iterating only @@ -925,44 +1171,35 @@ public sealed class ShadowObjectRegistry (cell & 0xFFFF0000u) == (landblockId & 0xFFFF0000u)); /// - /// Copies the committed registry into an off-side collision generation. - /// All mutable lists and sets are cloned; immutable registration and shape - /// payloads may be shared. + /// Mirrors one ordinary active-world mutation into an off-side generation. + /// Target-prefix owners may subsequently be reflooded against the staged + /// topology; unrelated owners retain these exact active rows. /// - internal void CopyCollisionStateFrom( + internal void MirrorOwnerFrom( ShadowObjectRegistry source, - PhysicsDataCache stagingCache) + uint entityId) { ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(stagingCache); - Clear(); - DataCache = stagingCache; - foreach ((uint cellId, List entries) in source._cells) - _cells[cellId] = new List(entries); - foreach ((uint ownerId, List cells) in source._entityToCells) - _entityToCells[ownerId] = new List(cells); - foreach (uint ownerId in source._suspendedEntities) - _suspendedEntities.Add(ownerId); - foreach ((uint ownerId, HashSet prefixes) in - source._withdrawnPrefixesByOwner) + DeregisterCore(entityId, publishMutation: false); + if (source.TryCaptureOwnerState( + entityId, + out PreparedShadowOwnerState? state) + && state is not null) { - _withdrawnPrefixesByOwner[ownerId] = new HashSet(prefixes); + InstallOwnerState(state); + _ownerVersions[entityId] = source.GetOwnerVersion(entityId); } - foreach ((uint ownerId, IReadOnlyList shapes) in - source._entityShapes) + else { - _entityShapes[ownerId] = shapes; + RemoveOwnerPrefixMembership(entityId); + _ownerVersions.Remove(entityId); } - foreach ((uint ownerId, RegistrationRecord registration) in - source._entityReg) - { - _entityReg[ownerId] = registration; - } - foreach ((uint ownerId, ulong version) in source._ownerVersions) - _ownerVersions[ownerId] = version; - _mutationVersion = source._mutationVersion; } + internal int CaptureOwnerSlotLimit() => _ownerSlots.Count; + + internal uint GetOwnerSlot(int index) => _ownerSlots[index]; + /// /// Refreshes one staging owner from the exact active payload, then floods /// it against the staging generation's complete cell graph. The returned @@ -975,19 +1212,27 @@ public sealed class ShadowObjectRegistry out ulong sourceVersion) { ArgumentNullException.ThrowIfNull(source); - Deregister(entityId); sourceVersion = source.GetOwnerVersion(entityId); if (!source._entityReg.TryGetValue( entityId, out RegistrationRecord? registration) || source._suspendedEntities.Contains(entityId) - || (registration.IsStatic - && (registration.SeedCellId & 0xFFFF0000u) - == (landblockId & 0xFFFF0000u)) || !source.OwnerTouchesLandblock(entityId, landblockId)) { + // A target-local refresh is not a global owner deletion. Preserve + // the exact active rows when the live owner has moved elsewhere. + MirrorOwnerFrom(source, entityId); return false; } + if (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) + == (landblockId & 0xFFFF0000u)) + { + // Target statics come from the staged landblock itself. + return false; + } + + DeregisterCore(entityId, publishMutation: false); if (registration.IsMultiPart && source._entityShapes.TryGetValue( @@ -1005,7 +1250,8 @@ public sealed class ShadowObjectRegistry 0f, landblockId, registration.SeedCellId, - isStatic: registration.IsStatic); + isStatic: registration.IsStatic, + publishMutation: false); } else { @@ -1024,7 +1270,8 @@ public sealed class ShadowObjectRegistry registration.State, registration.Flags, registration.SeedCellId, - isStatic: registration.IsStatic); + isStatic: registration.IsStatic, + publishMutation: false); } if (source._withdrawnPrefixesByOwner.TryGetValue( @@ -1041,28 +1288,21 @@ public sealed class ShadowObjectRegistry if (retainedWithdrawn.Count != 0) _withdrawnPrefixesByOwner[entityId] = retainedWithdrawn; } + RefreshOwnerPrefixIndex(entityId); + _ownerVersions[entityId] = sourceVersion; return true; } internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( ShadowObjectRegistry staging, uint landblockId, - IReadOnlyDictionary expectedRetainedVersions) => new( + IReadOnlyList expectedRetainedOwners) => new( this, staging, landblockId, - expectedRetainedVersions); + expectedRetainedOwners); - internal void CommitLandblockReplacement( - PreparedLandblockShadowReplacement replacement) - { - for (int index = 0; index < replacement.OwnerIds.Count; index++) - Deregister(replacement.OwnerIds[index]); - for (int index = 0; index < replacement.OwnerStates.Count; index++) - InstallOwnerState(replacement.OwnerStates[index]); - } - - private bool OwnerTouchesLandblock(uint entityId, uint landblockId) + internal bool OwnerTouchesLandblock(uint entityId, uint landblockId) { uint prefix = landblockId & 0xFFFF0000u; if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? record)) @@ -1080,6 +1320,43 @@ public sealed class ShadowObjectRegistry && withdrawn.Contains(prefix); } + internal bool IsStaticOwnerRootedIn(uint entityId, uint landblockId) => + _entityReg.TryGetValue(entityId, out RegistrationRecord? registration) + && registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) + == (landblockId & 0xFFFF0000u); + + internal bool TryGetStaticOwnerRootPrefix( + uint entityId, + out uint landblockPrefix) + { + if (_entityReg.TryGetValue( + entityId, + out RegistrationRecord? registration) + && registration.IsStatic) + { + landblockPrefix = registration.SeedCellId & 0xFFFF0000u; + return true; + } + landblockPrefix = 0u; + return false; + } + + internal bool HasLogicalOwner(uint entityId) => + _entityReg.ContainsKey(entityId); + + public int PrefixOwnerSlotCapacityForDiagnostics(uint landblockId) => + _prefixOwnerSlots.TryGetValue( + landblockId & 0xFFFF0000u, + out List? slots) + ? slots.Count + : 0; + + public int OwnerVersionCountForDiagnostics => _ownerVersions.Count; + + public int PrefixOwnerContainerCountForDiagnostics => + _prefixOwnerSlots.Count; + private bool TryCaptureOwnerState( uint entityId, out PreparedShadowOwnerState? state) @@ -1148,63 +1425,65 @@ public sealed class ShadowObjectRegistry private readonly ShadowObjectRegistry _active; private readonly ShadowObjectRegistry _staging; private readonly uint _prefix; - private readonly ulong _sourceMutationVersion; + private readonly IReadOnlyList _expected; + private readonly List? _activeSlots; + private readonly List? _stagingSlots; + private readonly int _activeSlotLimit; + private readonly int _stagingSlotLimit; private readonly HashSet _owners = new(); private readonly List _ownerIds = new(); - private readonly List _states = new(); - private IEnumerator>? _expectedEnumerator; - private Dictionary.Enumerator _registrationEnumerator; - private HashSet.Enumerator _ownerEnumerator; + private readonly List _states = new(); + private readonly Dictionary _stateIndex = new(); + private int _expectedIndex; + private int _activeSlotIndex; + private int _stagingSlotIndex; + private int _ownerIndex; private int _phase; internal LandblockReplacementBuilder( ShadowObjectRegistry active, ShadowObjectRegistry staging, uint landblockId, - IReadOnlyDictionary expected) + IReadOnlyList expected) { _active = active; _staging = staging; _prefix = landblockId & 0xFFFF0000u; - _sourceMutationVersion = active.MutationVersion; - _expectedEnumerator = expected.GetEnumerator(); + _expected = expected; + active._prefixOwnerSlots.TryGetValue( + _prefix, + out _activeSlots); + staging._prefixOwnerSlots.TryGetValue( + _prefix, + out _stagingSlots); + _activeSlotLimit = _activeSlots?.Count ?? 0; + _stagingSlotLimit = _stagingSlots?.Count ?? 0; } internal int WorkUnits { get; private set; } - internal bool IsStable => - _active.MutationVersion == _sourceMutationVersion; internal PreparedLandblockShadowReplacement? Prepared { get; private set; } internal bool Advance() { - if (!IsStable) - return true; switch (_phase) { case 0: - if (_expectedEnumerator!.MoveNext()) + if (_expectedIndex < _expected.Count) { - (uint ownerId, ulong version) = _expectedEnumerator.Current; - if (_active.GetOwnerVersion(ownerId) != version - || !_active.IsRetainedRefloodOwner(ownerId, _prefix)) - { - return true; - } - AddOwner(ownerId); + AddOwner(_expected[_expectedIndex++]); WorkUnits++; return false; } - _expectedEnumerator.Dispose(); - _expectedEnumerator = null; - _registrationEnumerator = _active._entityReg.GetEnumerator(); _phase++; return false; case 1: - if (_registrationEnumerator.MoveNext()) + if (_activeSlotIndex < _activeSlotLimit) { - (uint ownerId, RegistrationRecord registration) = - _registrationEnumerator.Current; - if (registration.IsStatic + uint ownerId = _activeSlots![_activeSlotIndex++]; + if (_active._entityReg.TryGetValue( + ownerId, + out RegistrationRecord? registration) + && registration.IsStatic && (registration.SeedCellId & 0xFFFF0000u) == _prefix) { AddOwner(ownerId); @@ -1212,16 +1491,16 @@ public sealed class ShadowObjectRegistry WorkUnits++; return false; } - _registrationEnumerator.Dispose(); - _registrationEnumerator = _staging._entityReg.GetEnumerator(); _phase++; return false; case 2: - if (_registrationEnumerator.MoveNext()) + if (_stagingSlotIndex < _stagingSlotLimit) { - (uint ownerId, RegistrationRecord registration) = - _registrationEnumerator.Current; - if (registration.IsStatic + uint ownerId = _stagingSlots![_stagingSlotIndex++]; + if (_staging._entityReg.TryGetValue( + ownerId, + out RegistrationRecord? registration) + && registration.IsStatic && (registration.SeedCellId & 0xFFFF0000u) == _prefix) { AddOwner(ownerId); @@ -1229,32 +1508,24 @@ public sealed class ShadowObjectRegistry WorkUnits++; return false; } - _registrationEnumerator.Dispose(); - _ownerEnumerator = _owners.GetEnumerator(); _phase++; return false; case 3: - if (_ownerEnumerator.MoveNext()) + if (_ownerIndex < _ownerIds.Count) { - uint ownerId = _ownerEnumerator.Current; - if (_staging.TryCaptureOwnerState( - ownerId, - out PreparedShadowOwnerState? state) - && state is not null) - { - _states.Add(state); - } + uint ownerId = _ownerIds[_ownerIndex++]; + _staging.TryCaptureOwnerState( + ownerId, + out PreparedShadowOwnerState? state); + _stateIndex[ownerId] = _states.Count; + _states.Add(new PreparedShadowOwnerSlot(ownerId, state)); WorkUnits++; return false; } - _ownerEnumerator.Dispose(); - if (IsStable) - { - Prepared = new PreparedLandblockShadowReplacement( - _prefix, - _ownerIds, - _states); - } + Prepared = new PreparedLandblockShadowReplacement( + _prefix, + _ownerIds, + _states); _phase++; return true; default: @@ -1262,20 +1533,34 @@ public sealed class ShadowObjectRegistry } } - private void AddOwner(uint ownerId) + internal void AddOwner(uint ownerId) { if (_owners.Add(ownerId)) _ownerIds.Add(ownerId); } - public void Dispose() + internal void RefreshOwner(uint ownerId) { - _expectedEnumerator?.Dispose(); - if (_phase is 1 or 2) - _registrationEnumerator.Dispose(); - if (_phase == 3) - _ownerEnumerator.Dispose(); + AddOwner(ownerId); + if (_stateIndex.TryGetValue(ownerId, out int index)) + { + _staging.TryCaptureOwnerState( + ownerId, + out PreparedShadowOwnerState? state); + _states[index].State = state; + return; + } + if (_phase > 3) + { + _staging.TryCaptureOwnerState( + ownerId, + out PreparedShadowOwnerState? state); + _stateIndex[ownerId] = _states.Count; + _states.Add(new PreparedShadowOwnerSlot(ownerId, state)); + } } + + public void Dispose() { } } private bool IsRetainedRefloodOwner(uint ownerId, uint landblockId) @@ -1296,7 +1581,7 @@ public sealed class ShadowObjectRegistry internal PreparedLandblockShadowReplacement( uint landblockPrefix, IReadOnlyList ownerIds, - IReadOnlyList ownerStates) + IReadOnlyList ownerStates) { LandblockPrefix = landblockPrefix; OwnerIds = ownerIds; @@ -1305,7 +1590,21 @@ public sealed class ShadowObjectRegistry internal uint LandblockPrefix { get; } internal IReadOnlyList OwnerIds { get; } - internal IReadOnlyList OwnerStates { get; } + internal IReadOnlyList OwnerStates { get; } + } + + internal sealed class PreparedShadowOwnerSlot + { + internal PreparedShadowOwnerSlot( + uint entityId, + PreparedShadowOwnerState? state) + { + EntityId = entityId; + State = state; + } + + internal uint EntityId { get; } + internal PreparedShadowOwnerState? State { get; set; } } internal sealed record PreparedShadowOwnerState( @@ -1334,7 +1633,15 @@ public sealed class ShadowObjectRegistry _entityShapes.Clear(); _entityReg.Clear(); _ownerVersions.Clear(); - _mutationVersion = 0UL; + _ownerPrefixes.Clear(); + _prefixOwnerSlots.Clear(); + _prefixOwnerIndices.Clear(); + _prefixFreeSlots.Clear(); + _ownerSlots.Clear(); + _ownerIndices.Clear(); + _ownerFreeSlots.Clear(); + _prefixScratch.Clear(); + _removedPrefixScratch.Clear(); _fallback = null; } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 021c6d46..7b4f05b7 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -18,8 +18,24 @@ namespace AcDream.Core.World.Cells; /// public sealed class CellGraph { - private readonly ConcurrentDictionary _envCells = new(); - private readonly ConcurrentDictionary _terrain = new(); + private readonly CollisionWorldStateSlot _collisionWorld; + private ConcurrentDictionary _envCells => + _collisionWorld.Current.EnvCells; + private ConcurrentDictionary _terrain => + _collisionWorld.Current.Terrain; + private ConcurrentDictionary _outdoorCells => + _collisionWorld.Current.OutdoorCells; + + public CellGraph() + : this(new CollisionWorldStateSlot()) + { + } + + internal CellGraph(CollisionWorldStateSlot collisionWorld) + { + _collisionWorld = collisionWorld + ?? throw new ArgumentNullException(nameof(collisionWorld)); + } /// The player's current cell — the render/lighting root. Written ONLY at the /// player chokepoint @@ -34,8 +50,21 @@ public sealed class CellGraph /// Any id in the cell's landblock; masked to (id & 0xFFFF0000). public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin) - => _terrain[landblockPrefix & 0xFFFF0000u] = - new CellGraphTerrain(terrain, worldOrigin); + { + uint prefix = landblockPrefix & 0xFFFF0000u; + _terrain[prefix] = new CellGraphTerrain(terrain, worldOrigin); + for (uint low = 1u; low <= 0x40u; low++) + { + uint id = prefix | low; + int index = (int)(low - 1u); + _outdoorCells[id] = LandCell.Synthesize( + id, + terrain, + worldOrigin, + index / 8, + index % 8); + } + } /// /// World origin (SW corner) of the landblock containing , @@ -65,6 +94,8 @@ public sealed class CellGraph CurrCell = null; } _terrain.TryRemove(lb, out _); + for (uint low = 1u; low <= 0x40u; low++) + _outdoorCells.TryRemove(lb | low, out _); foreach (var id in new List(_envCells.Keys)) if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); } @@ -95,9 +126,9 @@ public sealed class CellGraph uint low = id & 0xFFFFu; if (low < 1u || low > 0x40u) return null; - if (!_terrain.TryGetValue(id & 0xFFFF0000u, out var t)) return null; - int idx = (int)(low - 1u); - return LandCell.Synthesize(id, t.Terrain, t.Origin, idx / 8, idx % 8); + return _outdoorCells.TryGetValue(id, out ObjCell? cell) + ? cell + : null; } /// @@ -127,63 +158,10 @@ public sealed class CellGraph return null; } - /// - /// Creates an immutable-reference snapshot for collision-generation - /// preparation. EnvCell and TerrainSurface records are immutable after - /// publication, so copying the registries is sufficient; the active graph - /// remains untouched while the staging graph is rebuilt. - /// - internal CellGraph CreateCollisionStagingCopy() - { - var copy = new CellGraph { CurrCell = CurrCell }; - foreach ((uint id, EnvCell cell) in _envCells) - copy._envCells.TryAdd(id, cell); - foreach ((uint id, CellGraphTerrain terrain) in _terrain) - { - copy._terrain.TryAdd(id, terrain); - } - return copy; - } - internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( CellGraph staging, uint landblockId) => new(this, staging, landblockId); - internal void CommitLandblockReplacement( - PreparedCellGraphLandblock replacement) - { - uint currentCellId = CurrCell?.Id ?? 0u; - for (int index = 0; index < replacement.EnvCellIdsToRemove.Count; index++) - _envCells.TryRemove(replacement.EnvCellIdsToRemove[index], out _); - if (replacement.HasTerrain) - { - _terrain[replacement.LandblockPrefix] = replacement.Terrain!; - } - else - { - _terrain.TryRemove(replacement.LandblockPrefix, out _); - } - for (int index = 0; index < replacement.EnvCells.Count; index++) - { - (uint id, EnvCell cell) = replacement.EnvCells[index]; - _envCells[id] = cell; - } - - uint desiredCurrentCellId = - (currentCellId & 0xFFFF0000u) == replacement.LandblockPrefix - ? currentCellId - : currentCellId == 0u - && (replacement.CurrentCellId & 0xFFFF0000u) - == replacement.LandblockPrefix - ? replacement.CurrentCellId - : 0u; - if (desiredCurrentCellId != 0u) - CurrCell = GetVisible(desiredCurrentCellId); - else if ((currentCellId & 0xFFFF0000u) - == replacement.LandblockPrefix) - CurrCell = null; - } - internal sealed class LandblockReplacementBuilder : IDisposable { private readonly CellGraph _active; @@ -249,8 +227,7 @@ public sealed class CellGraph _removeIds, _envCells, hasTerrain, - terrain, - _staging.CurrCell?.Id ?? 0u); + terrain); _phase = 2; } return true; @@ -271,8 +248,7 @@ internal sealed record PreparedCellGraphLandblock( IReadOnlyList EnvCellIdsToRemove, IReadOnlyList> EnvCells, bool HasTerrain, - CellGraphTerrain? Terrain, - uint CurrentCellId); + CellGraphTerrain? Terrain); internal sealed record CellGraphTerrain( TerrainSurface Terrain, diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index e95dfcae..95ccf9dc 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -78,45 +78,545 @@ public readonly record struct RuntimeCollisionGenerationCommitted( ulong Generation, bool Ready); +/// +/// One process-local, versioned owner-mutation stream shared by every +/// collision draft. A live mutation is appended once; drafts consume only the +/// newest still-relevant record for each owner at their own metered cursor. +/// +internal sealed class CollisionOwnerMutationJournal +{ + private readonly List _entries = new(capacity: 256); + private readonly Dictionary _entryIndexByOwner = new(); + private long _nextSequence = 1; + private long _compactionThreshold; + private int _compactionIndex; + + internal long NextSequence => _nextSequence; + internal int Count => _entries.Count; + internal int ActiveCount => _entryIndexByOwner.Count; + internal bool HasPendingCompaction => _compactionThreshold != 0L; + + internal MutationRecord Record( + uint ownerId, + ulong ownerVersion, + long latestPreparationStartSequence) + { + long sequence = _nextSequence; + _nextSequence = checked(_nextSequence + 1L); + if (_entryIndexByOwner.TryGetValue(ownerId, out int index)) + { + if (_entries[index].Sequence >= latestPreparationStartSequence) + { + _entries[index] = new Entry(sequence, ownerId, ownerVersion); + return new MutationRecord(sequence, index); + } + // At least one newer draft captured the live root after this slot. + // Leave a tombstone for older cursors and append one new coalescing + // slot that every draft created since then can observe. + _entries[index] = default; + } + _entryIndexByOwner[ownerId] = _entries.Count; + _entries.Add(new Entry(sequence, ownerId, ownerVersion)); + return new MutationRecord( + sequence, + _entries.Count - 1); + } + + internal Entry Get(int index) => _entries[index]; + + internal bool TryGet( + uint ownerId, + out Entry entry, + out int slotIndex) + { + if (_entryIndexByOwner.TryGetValue(ownerId, out slotIndex)) + { + entry = _entries[slotIndex]; + return true; + } + entry = default; + slotIndex = -1; + return false; + } + + internal void RequestCompactionBefore(long sequence) + { + if (sequence <= _compactionThreshold) + return; + _compactionThreshold = sequence; + _compactionIndex = 0; + } + + internal bool AdvanceCompaction() + { + if (!HasPendingCompaction) + return false; + if (_compactionIndex < _entries.Count) + { + int index = _compactionIndex++; + Entry entry = _entries[index]; + if (entry.OwnerId != 0u + && entry.Sequence < _compactionThreshold + && _entryIndexByOwner.TryGetValue( + entry.OwnerId, + out int currentIndex) + && currentIndex == index) + { + _entryIndexByOwner.Remove(entry.OwnerId); + _entries[index] = default; + } + return true; + } + if (_entries.Count != 0 && _entries[^1].OwnerId == 0u) + { + _entries.RemoveAt(_entries.Count - 1); + return true; + } + _compactionThreshold = 0L; + _compactionIndex = 0; + return false; + } + + internal void Clear() + { + _entries.Clear(); + _entryIndexByOwner.Clear(); + _compactionThreshold = 0L; + _compactionIndex = 0; + } + + internal readonly record struct Entry( + long Sequence, + uint OwnerId, + ulong OwnerVersion); + + internal readonly record struct MutationRecord( + long Sequence, + int SlotIndex); +} + /// /// One off-side collision generation. It owns a private cache, cell graph, -/// engine, and shadow registry cloned from the previous complete generation. -/// Hosts may populate it incrementally, but only Runtime can activate it. +/// engine, and shadow registry materialized incrementally from an O(1) root +/// snapshot. Hosts may populate it incrementally, but only Runtime can activate +/// it. /// internal sealed class PreparedLandblockCollisionGeneration : IDisposable { + internal const int MaxConcurrentCollisionPreparations = 256; private readonly RuntimePhysicsState _owner; private readonly RuntimeCollisionAdmission _admission; private readonly Dictionary _retainedOwnerVersions = new(); private readonly List _retainedOwnerIds = new(); + private readonly HashSet _retainedOwnerSet = new(); + private readonly HashSet _armedOwners = new(); + private readonly HashSet _pendingOwners = new(); + private readonly Queue _pendingOwnerQueue = new(); + private readonly PhysicsEngine.PreparedPhysicsEngineLandblock?[] + _pendingCommittedRebases = + new PhysicsEngine.PreparedPhysicsEngineLandblock?[ + MaxConcurrentCollisionPreparations]; + private int _pendingCommittedRebaseHead; + private int _pendingCommittedRebaseCount; + private PhysicsEngine.LandblockReplacementApplyCursor? + _activeCommittedRebase; + private readonly Dictionary _retiredLandblocks = new(); + private readonly HashSet _pendingRetirementSet = new(); + private readonly Queue _pendingRetirements = new(); + private PhysicsEngine.LandblockRetirementCursor? _activeRetirement; + private readonly HashSet _pendingCloneOwnerMutations = new(); + private readonly Queue _pendingCloneOwnerMutationQueue = new(); + private readonly HashSet _pendingRoutedOwnerMutations = new(); + private readonly Queue _pendingRoutedOwnerMutationQueue = new(); + private PhysicsEngine.CollisionStagingBuilder? _stagingBuilder; private ShadowObjectRegistry.RetainedRefloodOwnerScan? _retainedOwnerScan; private PhysicsEngine.LandblockReplacementBuilder? _sealBuilder; private PhysicsEngine.PreparedPhysicsEngineLandblock? _sealedReplacement; + private readonly CollisionOwnerMutationJournal _ownerMutationJournal; + private readonly long _ownerMutationStartSequence; + private readonly Dictionary _observedOwnerMutationSequences = new(); + private readonly HashSet _subscribedOwners = new(); + private readonly HashSet _exactSubscribedOwners = new(); + private long _ownerMutationScanEpoch; + private int _ownerMutationScanIndex; + private bool _sealedExactWriteThrough; private bool _disposed; internal PreparedLandblockCollisionGeneration( RuntimePhysicsState owner, RuntimeCollisionAdmission admission, - PhysicsDataCache dataCache, - PhysicsEngine engine) + PhysicsEngine.CollisionStagingBuilder stagingBuilder, + CollisionOwnerMutationJournal ownerMutationJournal, + long sequence) { _owner = owner; _admission = admission; - DataCache = dataCache; - Engine = engine; + _stagingBuilder = stagingBuilder + ?? throw new ArgumentNullException(nameof(stagingBuilder)); + _ownerMutationJournal = ownerMutationJournal + ?? throw new ArgumentNullException(nameof(ownerMutationJournal)); + _ownerMutationStartSequence = ownerMutationJournal.NextSequence; + _ownerMutationScanEpoch = ownerMutationJournal.NextSequence; + _ownerMutationScanIndex = ownerMutationJournal.Count; + DataCache = stagingBuilder.StagingCache; + Engine = stagingBuilder.StagingEngine; + Sequence = sequence; } internal PhysicsDataCache DataCache { get; } internal PhysicsEngine Engine { get; } + internal long Sequence { get; } + internal long OwnerMutationStartSequence => _ownerMutationStartSequence; internal uint[] GfxObjectIds { get; private set; } = Array.Empty(); internal uint[] SetupIds { get; private set; } = Array.Empty(); internal IReadOnlyDictionary RetainedOwnerVersions => _retainedOwnerVersions; internal bool IsDisposed => _disposed; internal bool RetainedOwnerCaptureComplete { get; private set; } - internal ulong RetainedOwnerCaptureMutationVersion { get; private set; } + internal bool StagingCloneComplete { get; private set; } internal bool IsSealed => _sealedReplacement is not null; - internal ulong SealedShadowMutationVersion { get; private set; } + internal bool IsReadyForActivation => + _sealedReplacement is not null + && _pendingOwners.Count == 0 + && _pendingRoutedOwnerMutations.Count == 0; + + internal bool HasPendingCommittedRebase => + _activeCommittedRebase is not null + || _pendingCommittedRebaseCount != 0; + + internal bool HasPendingRetirement => + _activeRetirement is not null + || _pendingRetirements.Count != 0; + + internal bool IsOwnerMutationReconciliationCurrent => + _ownerMutationScanEpoch == _ownerMutationJournal.NextSequence + && _ownerMutationScanIndex >= _ownerMutationJournal.Count; + + internal RuntimeCollisionJournalStep AdvanceOwnerMutationReconciliation() + { + EnsureUsable(); + if (!StagingCloneComplete) + { + throw new InvalidOperationException( + "Collision owner mutations cannot reconcile before staging materialization."); + } + long epoch = _ownerMutationJournal.NextSequence; + // Already-visited slots are subscribed and receive either exact + // target write-through or a coalesced metered replay. + // Continue from the current cursor when the journal epoch advances; + // restarting at zero would let one continuously-moving unrelated + // owner starve every later slot. + _ownerMutationScanEpoch = epoch; + if (_ownerMutationScanIndex < _ownerMutationJournal.Count) + { + CollisionOwnerMutationJournal.Entry entry = + _ownerMutationJournal.Get(_ownerMutationScanIndex++); + if (entry.OwnerId == 0u) + { + return new RuntimeCollisionJournalStep( + Completed: _ownerMutationScanIndex + >= _ownerMutationJournal.Count, + Worked: true); + } + + // Every visited slot gets a cheap notification subscription so a + // later same-prefix mutation can enqueue one metered replay. Only + // target-relevant owners are promoted to exact write-through. + SubscribeOwner(entry.OwnerId, exact: false); + + if (entry.Sequence < _ownerMutationStartSequence + || (_observedOwnerMutationSequences.TryGetValue( + entry.OwnerId, + out long observed) + && observed >= entry.Sequence)) + { + return new RuntimeCollisionJournalStep( + Completed: _ownerMutationScanIndex + >= _ownerMutationJournal.Count, + Worked: true); + } + _observedOwnerMutationSequences[entry.OwnerId] = entry.Sequence; + if (ObserveOwnerMutation(entry.OwnerId)) + SubscribeOwner(entry.OwnerId, exact: true); + return new RuntimeCollisionJournalStep( + Completed: _ownerMutationScanIndex + >= _ownerMutationJournal.Count, + Worked: true); + } + return new RuntimeCollisionJournalStep( + Completed: true, + Worked: false); + } + + internal void ObserveSubscribedOwnerMutation( + uint ownerId, + long sequence, + long epochBefore, + int countBefore) + { + if (_disposed) + return; + bool wasCurrent = _ownerMutationScanEpoch == epochBefore + && _ownerMutationScanIndex >= countBefore; + _observedOwnerMutationSequences[ownerId] = sequence; + if (!_sealedExactWriteThrough + && !_exactSubscribedOwners.Contains(ownerId)) + { + if (_pendingRoutedOwnerMutations.Add(ownerId)) + _pendingRoutedOwnerMutationQueue.Enqueue(ownerId); + return; + } + _ = ObserveOwnerMutation(ownerId); + if (wasCurrent) + { + _ownerMutationScanEpoch = _ownerMutationJournal.NextSequence; + _ownerMutationScanIndex = _ownerMutationJournal.Count; + } + } + + internal void ObserveRoutedOwnerMembershipMutation(uint ownerId) + { + if (_disposed || _exactSubscribedOwners.Contains(ownerId)) + return; + if (_sealedExactWriteThrough) + { + _ = ObserveOwnerMutation(ownerId); + SubscribeOwner(ownerId, exact: true); + return; + } + if (_pendingRoutedOwnerMutations.Add(ownerId)) + _pendingRoutedOwnerMutationQueue.Enqueue(ownerId); + } + + internal RuntimeCollisionJournalStep AdvanceRoutedOwnerMutation() + { + EnsureUsable(); + while (_pendingRoutedOwnerMutationQueue.Count != 0) + { + uint ownerId = _pendingRoutedOwnerMutationQueue.Dequeue(); + if (!_pendingRoutedOwnerMutations.Remove(ownerId)) + continue; + if (ObserveOwnerMutation(ownerId)) + SubscribeOwner(ownerId, exact: true); + if (_ownerMutationJournal.TryGet( + ownerId, + out CollisionOwnerMutationJournal.Entry entry, + out int slotIndex)) + { + _observedOwnerMutationSequences[ownerId] = entry.Sequence; + if (slotIndex == _ownerMutationScanIndex) + { + _ownerMutationScanIndex++; + _ownerMutationScanEpoch = + _ownerMutationJournal.NextSequence; + } + else if (_ownerMutationScanIndex + >= _ownerMutationJournal.Count) + { + // This slot was already behind the cursor. The routed + // prefix transition is the only previously-unsubscribed + // mutation that can make it target-relevant, so observing + // its latest coalesced entry closes the current epoch. + _ownerMutationScanEpoch = + _ownerMutationJournal.NextSequence; + } + } + return new RuntimeCollisionJournalStep( + Completed: _pendingRoutedOwnerMutations.Count == 0, + Worked: true); + } + return new RuntimeCollisionJournalStep( + Completed: true, + Worked: false); + } + + internal RuntimeCollisionPreparationStep AdvanceStagingClone() + { + EnsureUsable(); + if (!StagingCloneComplete) + { + int before = _stagingBuilder!.WorkUnits; + if (!_stagingBuilder.Advance()) + { + return new RuntimeCollisionPreparationStep( + Completed: false, + WorkUnits: _stagingBuilder.WorkUnits - before); + } + _stagingBuilder.Dispose(); + _stagingBuilder = null; + StagingCloneComplete = true; + } + + while (_pendingCloneOwnerMutationQueue.Count != 0) + { + uint ownerId = _pendingCloneOwnerMutationQueue.Dequeue(); + if (!_pendingCloneOwnerMutations.Remove(ownerId)) + continue; + ObserveOwnerMutation(ownerId); + return new RuntimeCollisionPreparationStep( + Completed: false, + WorkUnits: 1); + } + return new RuntimeCollisionPreparationStep( + Completed: true, + WorkUnits: 0); + } + + internal void EnqueueCommittedRebase( + PhysicsEngine.PreparedPhysicsEngineLandblock replacement) + { + EnsureUsable(); + if (_pendingCommittedRebaseCount + == _pendingCommittedRebases.Length) + { + throw new InvalidOperationException( + "Collision preparation rebase capacity was exceeded."); + } + int tail = (_pendingCommittedRebaseHead + + _pendingCommittedRebaseCount) + % _pendingCommittedRebases.Length; + _pendingCommittedRebases[tail] = replacement; + _pendingCommittedRebaseCount++; + } + + internal void RecordDemotion(uint landblockId) + { + EnsureUsable(); + uint canonical = CanonicalLandblock(landblockId); + InvalidateCommittedRebases(canonical); + _stagingBuilder?.SuppressLandblock(canonical); + EnqueueRetirement(canonical, withdraw: false); + } + + internal void RecordWithdrawal(uint landblockId) + { + EnsureUsable(); + uint canonical = CanonicalLandblock(landblockId); + InvalidateCommittedRebases(canonical); + _stagingBuilder?.SuppressLandblock(canonical); + EnqueueRetirement(canonical, withdraw: true); + } + + internal PhysicsEngine.LandblockRetirementStep AdvanceRetirement() + { + EnsureUsable(); + if (_activeRetirement is null) + { + uint canonical = DequeueRetirement(); + _activeRetirement = Engine.CreateLandblockRetirementCursor( + _owner.Engine, + canonical, + _retiredLandblocks[canonical]); + } + PhysicsEngine.LandblockRetirementStep step = + _activeRetirement.Advance(); + if (step.Completed) + { + _activeRetirement.Dispose(); + _activeRetirement = null; + } + return step; + } + + internal PhysicsEngine.LandblockReplacementApplyStep + AdvanceCommittedRebase() + { + EnsureUsable(); + if (_activeCommittedRebase is null) + { + PhysicsEngine.PreparedPhysicsEngineLandblock replacement = + DequeueCommittedRebase(); + if (_retiredLandblocks.ContainsKey(replacement.LandblockId)) + { + return new PhysicsEngine.LandblockReplacementApplyStep( + Completed: false, + Worked: false, + HasOwner: false, + OwnerId: 0u); + } + _activeCommittedRebase = + Engine.CreateLandblockReplacementApplyCursor(replacement); + } + PhysicsEngine.LandblockReplacementApplyStep step = + _activeCommittedRebase.Advance(); + if (step.HasOwner) + ForceOwnerReflood(step.OwnerId); + if (step.Completed) + { + _activeCommittedRebase.Dispose(); + _activeCommittedRebase = null; + } + return step; + } + + private PhysicsEngine.PreparedPhysicsEngineLandblock + DequeueCommittedRebase() + { + PhysicsEngine.PreparedPhysicsEngineLandblock replacement = + _pendingCommittedRebases[_pendingCommittedRebaseHead] + ?? throw new InvalidOperationException( + "Collision rebase queue contained an empty slot."); + _pendingCommittedRebases[_pendingCommittedRebaseHead] = null; + _pendingCommittedRebaseHead = (_pendingCommittedRebaseHead + 1) + % _pendingCommittedRebases.Length; + _pendingCommittedRebaseCount--; + return replacement; + } + + private void ClearCommittedRebases() + { + while (_pendingCommittedRebaseCount != 0) + _ = DequeueCommittedRebase(); + _pendingCommittedRebaseHead = 0; + } + + private void InvalidateCommittedRebases(uint landblockId) + { + uint canonical = CanonicalLandblock(landblockId); + if (_activeCommittedRebase?.LandblockId == canonical) + { + _activeCommittedRebase.Dispose(); + _activeCommittedRebase = null; + } + + // Queued entries are left in their fixed ring and skipped one per + // later seal step. This keeps retirement admission O(1). + } + + private void EnqueueRetirement(uint canonical, bool withdraw) + { + bool changed = !_retiredLandblocks.TryGetValue( + canonical, + out bool previousWithdraw) + || (withdraw && !previousWithdraw); + _retiredLandblocks[canonical] = previousWithdraw || withdraw; + if (!changed) + return; + if (_activeRetirement?.LandblockId == canonical) + { + _activeRetirement.Dispose(); + _activeRetirement = null; + } + if (!_pendingRetirementSet.Add(canonical)) + return; + _pendingRetirements.Enqueue(canonical); + } + + private uint DequeueRetirement() + { + uint canonical = _pendingRetirements.Dequeue(); + _pendingRetirementSet.Remove(canonical); + return canonical; + } + + private void ClearRetirements() + { + _pendingRetirements.Clear(); + _activeRetirement?.Dispose(); + _activeRetirement = null; + _retiredLandblocks.Clear(); + _pendingRetirementSet.Clear(); + } internal bool Matches( RuntimePhysicsState owner, @@ -134,15 +634,85 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void RefreshRetainedOwner(uint ownerId) { EnsureUsable(); - bool retained = Engine.ShadowObjects.RefreshRetainedOwnerFrom( + EnsureRetainedOwner(ownerId); + _ = Engine.ShadowObjects.RefreshRetainedOwnerFrom( _owner.Engine.ShadowObjects, ownerId, _admission.LandblockId, out ulong version); - if (retained) - _retainedOwnerVersions[ownerId] = version; - else - _retainedOwnerVersions.Remove(ownerId); + _retainedOwnerVersions[ownerId] = version; + _pendingOwners.Remove(ownerId); + _armedOwners.Add(ownerId); + _sealBuilder?.RefreshRetainedOwner(ownerId); + SubscribeOwner(ownerId); + } + + internal bool ObserveOwnerMutation(uint ownerId) + { + if (_disposed) + return false; + if (!StagingCloneComplete) + { + if (_pendingCloneOwnerMutations.Add(ownerId)) + _pendingCloneOwnerMutationQueue.Enqueue(ownerId); + return false; + } + if (_owner.Engine.ShadowObjects.IsStaticOwnerRootedIn( + ownerId, + _admission.LandblockId) + || Engine.ShadowObjects.IsStaticOwnerRootedIn( + ownerId, + _admission.LandblockId)) + { + // The staged build is authoritative for target-root statics. Never + // mirror the outgoing generation back over an omitted/replaced + // authored owner merely because its old live state changed. + return false; + } + bool relevantBefore = Engine.ShadowObjects.OwnerTouchesLandblock( + ownerId, + _admission.LandblockId); + Engine.ShadowObjects.MirrorOwnerFrom( + _owner.Engine.ShadowObjects, + ownerId); + bool relevant = _armedOwners.Contains(ownerId) + || relevantBefore + || _owner.Engine.ShadowObjects.OwnerTouchesLandblock( + ownerId, + _admission.LandblockId); + if (!relevant) + return false; + + EnsureRetainedOwner(ownerId); + RefreshRetainedOwner(ownerId); + return true; + } + + private void ForceOwnerReflood(uint ownerId) + { + if (_disposed) + return; + if (!StagingCloneComplete) + { + if (_pendingCloneOwnerMutations.Add(ownerId)) + _pendingCloneOwnerMutationQueue.Enqueue(ownerId); + return; + } + if (_owner.Engine.ShadowObjects.IsStaticOwnerRootedIn( + ownerId, + _admission.LandblockId) + || Engine.ShadowObjects.IsStaticOwnerRootedIn( + ownerId, + _admission.LandblockId)) + { + return; + } + + Engine.ShadowObjects.MirrorOwnerFrom( + _owner.Engine.ShadowObjects, + ownerId); + EnsureRetainedOwner(ownerId); + RefreshRetainedOwner(ownerId); } internal RuntimeCollisionOwnerCaptureStep AdvanceRetainedOwnerCapture() @@ -160,22 +730,12 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable .CreateRetainedRefloodOwnerScan(_admission.LandblockId); ShadowObjectRegistry.RetainedRefloodOwnerScanStep step = _retainedOwnerScan.Advance(); - if (step.Completed && !step.Stable) - { - ResetRetainedOwnerCapture(); - return new RuntimeCollisionOwnerCaptureStep( - Completed: false, - Restarted: true, - HasOwner: false, - OwnerId: 0u); - } if (step.HasOwner) - _retainedOwnerIds.Add(step.OwnerId); + EnsureRetainedOwner(step.OwnerId); if (step.Completed) { _retainedOwnerScan.Dispose(); _retainedOwnerScan = null; - RetainedOwnerCaptureMutationVersion = step.SourceMutationVersion; RetainedOwnerCaptureComplete = true; } return new RuntimeCollisionOwnerCaptureStep( @@ -202,16 +762,24 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void ResetRetainedOwnerCapture() { EnsureUsable(); + ClearOwnerSubscriptions(); _retainedOwnerScan?.Dispose(); _retainedOwnerScan = null; _retainedOwnerIds.Clear(); + _retainedOwnerSet.Clear(); _retainedOwnerVersions.Clear(); + _armedOwners.Clear(); + _pendingOwners.Clear(); + _pendingOwnerQueue.Clear(); + _pendingCloneOwnerMutations.Clear(); + _pendingCloneOwnerMutationQueue.Clear(); + _pendingRoutedOwnerMutations.Clear(); + _pendingRoutedOwnerMutationQueue.Clear(); + _sealedExactWriteThrough = false; RetainedOwnerCaptureComplete = false; - RetainedOwnerCaptureMutationVersion = 0UL; _sealedReplacement = null; _sealBuilder?.Dispose(); _sealBuilder = null; - SealedShadowMutationVersion = 0UL; } internal RuntimeCollisionSealStep AdvanceSeal() @@ -224,14 +792,23 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable Restarted: true, WorkUnits: 0); } - if (_owner.Engine.ShadowObjects.MutationVersion - != RetainedOwnerCaptureMutationVersion) + while (_pendingOwnerQueue.Count != 0) { - ResetRetainedOwnerCapture(); + uint ownerId = _pendingOwnerQueue.Dequeue(); + if (!_pendingOwners.Remove(ownerId)) + continue; + RefreshRetainedOwner(ownerId); + if (_sealedReplacement is not null) + { + return new RuntimeCollisionSealStep( + Completed: _pendingOwners.Count == 0, + Restarted: false, + WorkUnits: 1); + } return new RuntimeCollisionSealStep( Completed: false, - Restarted: true, - WorkUnits: 0); + Restarted: false, + WorkUnits: 1); } if (_retainedOwnerVersions.Count != _retainedOwnerIds.Count) { @@ -244,7 +821,7 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable _admission.LandblockId, GfxObjectIds, SetupIds, - _retainedOwnerVersions); + _retainedOwnerIds); int before = _sealBuilder.WorkUnits; bool completed = _sealBuilder.Advance(); int workUnits = _sealBuilder.WorkUnits - before; @@ -255,7 +832,7 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable Restarted: false, workUnits); } - if (!_sealBuilder.IsStable || _sealBuilder.Prepared is null) + if (_sealBuilder.Prepared is null) { ResetRetainedOwnerCapture(); return new RuntimeCollisionSealStep( @@ -264,10 +841,11 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable workUnits); } _sealedReplacement = _sealBuilder.Prepared; - _sealBuilder.Dispose(); - _sealBuilder = null; - SealedShadowMutationVersion = - _owner.Engine.ShadowObjects.MutationVersion; + // From this point through the same-call activation, every already- + // observed owner mutation writes through exactly. The finite dirty + // queue accumulated during topology construction can now drain even + // when several unrelated owners keep moving every update tick. + _sealedExactWriteThrough = true; return new RuntimeCollisionSealStep( Completed: true, Restarted: false, @@ -285,6 +863,27 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void MarkCommitted() { EnsureUsable(); + _sealedExactWriteThrough = false; + _sealBuilder = null; + _sealedReplacement = null; + _retainedOwnerIds.Clear(); + _retainedOwnerSet.Clear(); + _retainedOwnerVersions.Clear(); + _armedOwners.Clear(); + _pendingOwners.Clear(); + _pendingOwnerQueue.Clear(); + ClearCommittedRebases(); + ClearRetirements(); + _activeCommittedRebase?.Dispose(); + _activeCommittedRebase = null; + _pendingCloneOwnerMutations.Clear(); + _pendingCloneOwnerMutationQueue.Clear(); + _pendingRoutedOwnerMutations.Clear(); + _pendingRoutedOwnerMutationQueue.Clear(); + _observedOwnerMutationSequences.Clear(); + ClearOwnerSubscriptions(); + _stagingBuilder?.Dispose(); + _stagingBuilder = null; _disposed = true; } @@ -296,14 +895,55 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable _retainedOwnerScan?.Dispose(); _retainedOwnerScan = null; _retainedOwnerIds.Clear(); + _retainedOwnerSet.Clear(); _retainedOwnerVersions.Clear(); + _armedOwners.Clear(); + _pendingOwners.Clear(); + _pendingOwnerQueue.Clear(); + ClearCommittedRebases(); + ClearRetirements(); + _activeCommittedRebase?.Dispose(); + _activeCommittedRebase = null; + _pendingCloneOwnerMutations.Clear(); + _pendingCloneOwnerMutationQueue.Clear(); + _pendingRoutedOwnerMutations.Clear(); + _pendingRoutedOwnerMutationQueue.Clear(); + _observedOwnerMutationSequences.Clear(); + ClearOwnerSubscriptions(); + _stagingBuilder?.Dispose(); + _stagingBuilder = null; _sealBuilder?.Dispose(); _sealBuilder = null; _sealedReplacement = null; - SealedShadowMutationVersion = 0UL; + _sealedExactWriteThrough = false; _disposed = true; } + private void EnsureRetainedOwner(uint ownerId) + { + if (_retainedOwnerSet.Add(ownerId)) + _retainedOwnerIds.Add(ownerId); + } + + private void SubscribeOwner(uint ownerId, bool exact = true) + { + if (exact) + _exactSubscribedOwners.Add(ownerId); + if (_subscribedOwners.Add(ownerId)) + _owner.SubscribeCollisionOwner(ownerId, this); + } + + private void ClearOwnerSubscriptions() + { + foreach (uint ownerId in _subscribedOwners) + _owner.UnsubscribeCollisionOwner(ownerId, this); + _subscribedOwners.Clear(); + _exactSubscribedOwners.Clear(); + } + + private static uint CanonicalLandblock(uint value) => + (value & 0xFFFF0000u) | 0xFFFFu; + private void EnsureUsable() { if (_disposed) @@ -317,6 +957,14 @@ internal readonly record struct RuntimeCollisionOwnerCaptureStep( bool HasOwner, uint OwnerId); +internal readonly record struct RuntimeCollisionPreparationStep( + bool Completed, + int WorkUnits); + +internal readonly record struct RuntimeCollisionJournalStep( + bool Completed, + bool Worked); + internal readonly record struct RuntimeCollisionSealStep( bool Completed, bool Restarted, @@ -340,12 +988,34 @@ public sealed class RuntimePhysicsState : IDisposable private readonly Dictionary _collisionGenerations = new(); private readonly Dictionary _collisionAdmissions = new(); + private readonly Dictionary + _preparedCollisionGenerations = new(); + private readonly CollisionOwnerMutationJournal _collisionOwnerJournal = new(); + private readonly Dictionary> + _collisionOwnerSubscribers = new(); private int _collisionMutationThreadId; + private bool _suppressCollisionOwnerJournal; + private long _nextCollisionPreparationSequence; + private long _latestCollisionPreparationStartSequence; + private readonly List> + _collisionGenerationCommittedObservers = new(); private bool _disposed; public event Action? CellCommitted; public event Action? - CollisionGenerationCommitted; + CollisionGenerationCommitted + { + add + { + if (value is not null) + _collisionGenerationCommittedObservers.Add(value); + } + remove + { + if (value is not null) + _collisionGenerationCommittedObservers.Remove(value); + } + } internal RuntimePhysicsState( RuntimeEntityDirectory entities, @@ -359,6 +1029,9 @@ public sealed class RuntimePhysicsState : IDisposable { DataCache = DataCache, }; + Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; + Engine.ShadowObjects.OwnerPrefixMembershipChanged += + OnCollisionOwnerPrefixMembershipChanged; } internal RuntimePhysicsState( @@ -369,8 +1042,12 @@ public sealed class RuntimePhysicsState : IDisposable Entities = entities ?? throw new ArgumentNullException(nameof(entities)); _timeProvider = timeProvider ?? TimeProvider.System; Engine = engine ?? throw new ArgumentNullException(nameof(engine)); - DataCache = engine.DataCache ?? PhysicsDataCache.CreateProduction(); + DataCache = engine.DataCache + ?? PhysicsDataCache.CreateProduction(engine.CollisionWorld); Engine.DataCache = DataCache; + Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; + Engine.ShadowObjects.OwnerPrefixMembershipChanged += + OnCollisionOwnerPrefixMembershipChanged; } internal RuntimeEntityDirectory Entities { get; } @@ -379,6 +1056,8 @@ public sealed class RuntimePhysicsState : IDisposable public int SpatialRootCount => _spatialRoots.Count; public int SpatialRemoteCount => _spatialRemotes.Count; public int SpatialProjectileCount => _spatialProjectiles.Count; + internal int CollisionOwnerJournalEntryCountForDiagnostics => + _collisionOwnerJournal.ActiveCount; internal double UtcNowSeconds => (_timeProvider.GetUtcNow() - DateTimeOffset.UnixEpoch) .TotalSeconds; @@ -1146,14 +1825,40 @@ public sealed class RuntimePhysicsState : IDisposable { ValidateAdmission(admission); EnsureCollisionMutationThread(); - PhysicsDataCache stagingCache = DataCache.CreateCollisionStagingCopy(); - PhysicsEngine stagingEngine = - Engine.CreateCollisionStagingCopy(stagingCache); - return new PreparedLandblockCollisionGeneration( + if (_preparedCollisionGenerations.Count + >= PreparedLandblockCollisionGeneration + .MaxConcurrentCollisionPreparations + && !_preparedCollisionGenerations.ContainsKey( + admission.LandblockId)) + { + throw new InvalidOperationException( + "Too many collision generations are being prepared concurrently."); + } + PhysicsEngine.CollisionStagingBuilder stagingBuilder = + Engine.CreateCollisionStagingBuilder(admission.LandblockId); + var prepared = new PreparedLandblockCollisionGeneration( this, admission, - stagingCache, - stagingEngine); + stagingBuilder, + _collisionOwnerJournal, + checked(++_nextCollisionPreparationSequence)); + _preparedCollisionGenerations[admission.LandblockId] = prepared; + _latestCollisionPreparationStartSequence = Math.Max( + _latestCollisionPreparationStartSequence, + prepared.OwnerMutationStartSequence); + return prepared; + } + + internal RuntimeCollisionPreparationStep + AdvanceCollisionGenerationPreparation( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + ValidateAdmission(admission); + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); + RuntimeCollisionPreparationStep clone = prepared.AdvanceStagingClone(); + return clone; } /// @@ -1182,6 +1887,14 @@ public sealed class RuntimePhysicsState : IDisposable } prepared?.Dispose(); + if (prepared is not null + && _preparedCollisionGenerations.TryGetValue( + admission.LandblockId, + out PreparedLandblockCollisionGeneration? currentPrepared) + && ReferenceEquals(currentPrepared, prepared)) + { + _preparedCollisionGenerations.Remove(admission.LandblockId); + } if (_collisionAdmissions.TryGetValue( admission.LandblockId, out RuntimeCollisionAdmission? current) @@ -1191,6 +1904,7 @@ public sealed class RuntimePhysicsState : IDisposable _collisionGenerations[admission.LandblockId] = checked( admission.Generation + 1UL); } + TrimCollisionOwnerJournal(); } internal void StageCollisionAssets( @@ -1242,6 +1956,14 @@ public sealed class RuntimePhysicsState : IDisposable ValidateAdmission(admission); EnsureCollisionMutationThread(); ValidatePreparedGeneration(admission, prepared); + if (!prepared.AdvanceStagingClone().Completed) + { + return new RuntimeCollisionOwnerCaptureStep( + Completed: false, + Restarted: false, + HasOwner: false, + OwnerId: 0u); + } return prepared.AdvanceRetainedOwnerCapture(); } @@ -1278,7 +2000,76 @@ public sealed class RuntimePhysicsState : IDisposable throw new InvalidOperationException( "Collision generation cannot seal before its assets are prepared."); } - return prepared.AdvanceSeal(); + if (!prepared.StagingCloneComplete) + { + RuntimeCollisionPreparationStep preparation = + prepared.AdvanceStagingClone(); + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: false, + WorkUnits: preparation.WorkUnits); + } + RuntimeCollisionJournalStep routed = + prepared.AdvanceRoutedOwnerMutation(); + if (routed.Worked) + { + return new RuntimeCollisionSealStep( + Completed: routed.Completed + && prepared.IsOwnerMutationReconciliationCurrent + && prepared.IsReadyForActivation, + Restarted: false, + WorkUnits: 1); + } + if (prepared.HasPendingRetirement) + { + PhysicsEngine.LandblockRetirementStep retirement = + prepared.AdvanceRetirement(); + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: false, + WorkUnits: retirement.Worked ? 1 : 0); + } + if (prepared.HasPendingCommittedRebase) + { + PhysicsEngine.LandblockReplacementApplyStep rebase = + prepared.AdvanceCommittedRebase(); + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: false, + WorkUnits: rebase.Worked ? 1 : 0); + } + RuntimeCollisionSealStep seal = prepared.AdvanceSeal(); + if (!seal.Completed) + return seal; + if (seal.WorkUnits != 0 + && !prepared.IsOwnerMutationReconciliationCurrent) + { + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: false, + WorkUnits: seal.WorkUnits); + } + RuntimeCollisionJournalStep journal = + prepared.AdvanceOwnerMutationReconciliation(); + if (journal.Worked) + { + return new RuntimeCollisionSealStep( + Completed: journal.Completed, + Restarted: false, + WorkUnits: 1); + } + if (journal.Completed + && _collisionOwnerJournal.AdvanceCompaction()) + { + return new RuntimeCollisionSealStep( + Completed: true, + Restarted: false, + WorkUnits: 1); + } + return new RuntimeCollisionSealStep( + Completed: journal.Completed, + Restarted: false, + WorkUnits: seal.WorkUnits); } internal RuntimeCollisionGenerationCommit CommitCollisionGeneration( @@ -1299,15 +2090,17 @@ public sealed class RuntimePhysicsState : IDisposable "Collision generation has already completed."); } - if (!prepared.IsSealed) + if (!prepared.IsOwnerMutationReconciliationCurrent + || !prepared.IsReadyForActivation + || HasOlderPreparedGeneration(prepared) + || prepared.HasPendingCommittedRebase + || prepared.HasPendingRetirement) { - throw new InvalidOperationException( - "Collision generation cannot activate before sealing."); - } - if (Engine.ShadowObjects.MutationVersion - != prepared.SealedShadowMutationVersion) - { - prepared.ResetRetainedOwnerCapture(); + if (!prepared.IsSealed) + { + throw new InvalidOperationException( + "Collision generation cannot activate before sealing."); + } return new RuntimeCollisionGenerationCommit( new RuntimeCollisionAcknowledgement( admission.LandblockId, @@ -1316,11 +2109,29 @@ public sealed class RuntimePhysicsState : IDisposable Ready: false), Array.Empty()); } - - Engine.CommitLandblockReplacement(prepared.TakeSealedReplacement()); + PhysicsEngine.PreparedPhysicsEngineLandblock replacement = + prepared.TakeSealedReplacement(); + bool suppressOwnerJournal = _suppressCollisionOwnerJournal; + _suppressCollisionOwnerJournal = true; + try + { + Engine.CommitLandblockReplacement(replacement); + } + finally + { + _suppressCollisionOwnerJournal = suppressOwnerJournal; + } + foreach ((_, PreparedLandblockCollisionGeneration later) in + _preparedCollisionGenerations) + { + if (later.Sequence > prepared.Sequence) + later.EnqueueCommittedRebase(replacement); + } admission.Completed = true; _collisionAdmissions.Remove(admission.LandblockId); + _preparedCollisionGenerations.Remove(admission.LandblockId); prepared.MarkCommitted(); + TrimCollisionOwnerJournal(); var acknowledgement = new RuntimeCollisionAcknowledgement( admission.LandblockId, admission.Generation, @@ -1344,7 +2155,21 @@ public sealed class RuntimePhysicsState : IDisposable uint canonical = CanonicalLandblock(landblockId); bool resident = Engine.IsLandblockTerrainResident(canonical); InvalidateCollisionAdmission(canonical); - Engine.DemoteLandblockToTerrain(canonical); + bool suppressOwnerJournal = _suppressCollisionOwnerJournal; + _suppressCollisionOwnerJournal = true; + try + { + Engine.DemoteLandblockToTerrain(canonical); + } + finally + { + _suppressCollisionOwnerJournal = suppressOwnerJournal; + } + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + prepared.RecordDemotion(canonical); + } return new RuntimeCollisionAcknowledgement( canonical, _collisionGenerations[canonical], @@ -1360,7 +2185,21 @@ public sealed class RuntimePhysicsState : IDisposable uint canonical = CanonicalLandblock(landblockId); bool resident = Engine.IsLandblockTerrainResident(canonical); InvalidateCollisionAdmission(canonical); - Engine.RemoveLandblock(canonical); + bool suppressOwnerJournal = _suppressCollisionOwnerJournal; + _suppressCollisionOwnerJournal = true; + try + { + Engine.RemoveLandblock(canonical); + } + finally + { + _suppressCollisionOwnerJournal = suppressOwnerJournal; + } + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + prepared.RecordWithdrawal(canonical); + } return new RuntimeCollisionAcknowledgement( canonical, _collisionGenerations[canonical], @@ -1372,6 +2211,18 @@ public sealed class RuntimePhysicsState : IDisposable { if (_disposed) return; + _suppressCollisionOwnerJournal = true; + Engine.ShadowObjects.OwnerMutated -= OnCollisionOwnerMutated; + Engine.ShadowObjects.OwnerPrefixMembershipChanged -= + OnCollisionOwnerPrefixMembershipChanged; + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + prepared.Dispose(); + } + _preparedCollisionGenerations.Clear(); + _collisionOwnerJournal.Clear(); + _collisionOwnerSubscribers.Clear(); Engine.Clear(); _spatialRemotes.Clear(); _spatialProjectiles.Clear(); @@ -1379,7 +2230,7 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions.Clear(); _collisionGenerations.Clear(); CellCommitted = null; - CollisionGenerationCommitted = null; + _collisionGenerationCommittedObservers.Clear(); _disposed = true; } @@ -1510,13 +2361,13 @@ public sealed class RuntimePhysicsState : IDisposable private void PublishCollisionGenerationCommitted( RuntimeCollisionGenerationCommitted committed) { - Delegate[] observers = CollisionGenerationCommitted? - .GetInvocationList() ?? Array.Empty(); - foreach (Delegate observer in observers) + for (int index = 0; + index < _collisionGenerationCommittedObservers.Count; + index++) { try { - ((Action)observer)(committed); + _collisionGenerationCommittedObservers[index](committed); } catch (Exception error) { @@ -1536,6 +2387,126 @@ public sealed class RuntimePhysicsState : IDisposable : 1UL; _collisionGenerations[landblockId] = generation; _collisionAdmissions.Remove(landblockId); + if (_preparedCollisionGenerations.Remove( + landblockId, + out PreparedLandblockCollisionGeneration? prepared)) + { + prepared.Dispose(); + } + TrimCollisionOwnerJournal(); + } + + private void OnCollisionOwnerMutated(uint ownerId, ulong version) + { + _ = version; + if (_suppressCollisionOwnerJournal || _disposed) + return; + if (_preparedCollisionGenerations.Count == 0) + return; + long epochBefore = _collisionOwnerJournal.NextSequence; + int countBefore = _collisionOwnerJournal.Count; + CollisionOwnerMutationJournal.MutationRecord mutation = + _collisionOwnerJournal.Record( + ownerId, + version, + _latestCollisionPreparationStartSequence); + if (!_collisionOwnerSubscribers.TryGetValue( + ownerId, + out List? subscribers)) + { + return; + } + // Subscription mutation is update-thread confined. Iterate by index so + // no delegate-array or enumerator allocation enters the hot path. + for (int index = 0; index < subscribers.Count; index++) + { + subscribers[index].ObserveSubscribedOwnerMutation( + ownerId, + mutation.Sequence, + epochBefore, + countBefore); + } + } + + private void OnCollisionOwnerPrefixMembershipChanged( + uint ownerId, + uint landblockPrefix) + { + if (_suppressCollisionOwnerJournal || _disposed) + return; + uint canonical = (landblockPrefix & 0xFFFF0000u) | 0xFFFFu; + if (_preparedCollisionGenerations.TryGetValue( + canonical, + out PreparedLandblockCollisionGeneration? prepared)) + { + prepared.ObserveRoutedOwnerMembershipMutation(ownerId); + } + } + + internal void SubscribeCollisionOwner( + uint ownerId, + PreparedLandblockCollisionGeneration prepared) + { + if (!_collisionOwnerSubscribers.TryGetValue( + ownerId, + out List? subscribers)) + { + subscribers = new List(); + _collisionOwnerSubscribers[ownerId] = subscribers; + } + if (!subscribers.Contains(prepared)) + subscribers.Add(prepared); + } + + internal void UnsubscribeCollisionOwner( + uint ownerId, + PreparedLandblockCollisionGeneration prepared) + { + if (!_collisionOwnerSubscribers.TryGetValue( + ownerId, + out List? subscribers)) + { + return; + } + subscribers.Remove(prepared); + if (subscribers.Count == 0) + _collisionOwnerSubscribers.Remove(ownerId); + } + + private void TrimCollisionOwnerJournal() + { + if (_preparedCollisionGenerations.Count == 0) + { + _collisionOwnerJournal.Clear(); + _latestCollisionPreparationStartSequence = 0L; + return; + } + long minimumStart = long.MaxValue; + long latestStart = 0L; + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + minimumStart = Math.Min( + minimumStart, + prepared.OwnerMutationStartSequence); + latestStart = Math.Max( + latestStart, + prepared.OwnerMutationStartSequence); + } + _latestCollisionPreparationStartSequence = latestStart; + _collisionOwnerJournal.RequestCompactionBefore(minimumStart); + } + + private bool HasOlderPreparedGeneration( + PreparedLandblockCollisionGeneration candidate) + { + foreach ((_, PreparedLandblockCollisionGeneration other) in + _preparedCollisionGenerations) + { + if (other.Sequence < candidate.Sequence) + return true; + } + return false; } private static uint CanonicalLandblock(uint value) => diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index b1d61c3b..a5f9106d 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -802,6 +802,10 @@ public sealed class LandblockPhysicsPublisherTests Assert.DoesNotContain("_physicsDataCache.RemoveBuildingsForLandblock", publisherSource, StringComparison.Ordinal); Assert.DoesNotContain("_physicsEngine.ShadowObjects.Reflood", publisherSource, StringComparison.Ordinal); Assert.Contains("CommitCollisionGeneration(", publisherSource, StringComparison.Ordinal); + Assert.DoesNotContain( + "RestartCollisionRetainedOwnerCapture(", + publisherSource, + StringComparison.Ordinal); } private static void Publish( diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index 473650b3..aea39519 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -482,7 +482,15 @@ public class PhysicsEngineTests [Fact] public void ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches() { - var engine = MakeFlatEngine(terrainZ: 50f); + var freshCache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = freshCache }; + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(FlatHeightmap(50), LinearHeightTable()), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); // FindObjCollisions early-returns when DataCache is null. An empty // cache is enough for cylinder objects; only BSP objects look up // entries inside. @@ -490,9 +498,7 @@ public class PhysicsEngineTests // succeeds for the outdoor seed cell (0xA9B40039). In production the // streaming-center landblock is always resident before outdoor resolves run; // we replicate that invariant here by registering a flat dummy terrain. - var freshCache = new PhysicsDataCache(); freshCache.CellGraph.RegisterTerrain(0xA9B4FFFFu, new TerrainSurface(FlatHeightmap(50), LinearHeightTable()), Vector3.Zero); - engine.DataCache = freshCache; const uint movingEntityId = 0xDEADBEEFu; var bodyPos = new Vector3(96f, 96f, 50f); diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index e4e60922..0972e99a 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -4,6 +4,7 @@ using AcDream.Core.Physics; using AcDream.Runtime.Entities; using AcDream.Runtime.Physics; using AcDream.Core.Physics.Motion; +using AcDream.Core.World.Cells; using System.Numerics; namespace AcDream.Runtime.Tests.Physics; @@ -421,7 +422,7 @@ public sealed class RuntimePhysicsStateTests } [Fact] - public void WithdrawnOwnerStateChangeRejectsSealUntilRefreshed() + public void ArmedWithdrawnOwnerStateChangeWritesThroughSealedGeneration() { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; @@ -464,15 +465,6 @@ public sealed class RuntimePhysicsStateTests uint owner = Assert.Single(SealPrepared(physics, admission, prepared)); physics.Engine.ShadowObjects.UpdatePhysicsState(owner, 0x14u); - RuntimeCollisionGenerationCommit rejected = - physics.CommitCollisionGeneration(admission, prepared); - Assert.False(rejected.Committed); - Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f)); - - Assert.Equal(owner, Assert.Single(SealPrepared( - physics, - admission, - prepared))); Assert.True(physics.CommitCollisionGeneration( admission, prepared).Committed); @@ -582,6 +574,1853 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(ownerCount, physics.Engine.ShadowObjects.TotalRegistered); } + [Fact] + public void UnrelatedOwnerMutationEveryStepCannotRestartTargetCaptureOrSeal() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(target, terrainHeight: 5f)); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + + physics.Engine.ShadowObjects.Register( + 42u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + physics.Engine.ShadowObjects.Register( + 99u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + 0x0303FFFFu, + seedCellId: 0x03030001u, + isStatic: false); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 15f)); + + int steps = 0; + RuntimeCollisionOwnerCaptureStep capture; + do + { + physics.Engine.ShadowObjects.UpdatePhysicsState( + 99u, + (uint)(steps + 1)); + capture = physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared); + Assert.False(capture.Restarted); + // Admission now materializes its O(1) root snapshot one leaf per + // step before the target-owner scan. Unrelated owner churn must + // not restart either cursor. + Assert.True(++steps < 512); + } + while (!capture.Completed); + Assert.Equal(42u, Assert.Single(prepared.RetainedOwnerIds)); + physics.RefreshCollisionRetainedOwner(admission, prepared, 42u); + + RuntimeCollisionSealStep seal; + do + { + physics.Engine.ShadowObjects.UpdatePhysicsState( + 99u, + (uint)(steps + 1)); + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.False(seal.Restarted); + Assert.InRange(seal.WorkUnits, 0, 1); + Assert.True(++steps < 256); + } + while (!seal.Completed); + + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); + } + + [Fact] + public void TwoUnrelatedOwnersMovingEverySealStepCannotStarveActivation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + foreach (uint ownerId in new[] { 99u, 100u }) + { + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f + ownerId - 99u, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + 0x0303FFFFu, + seedCellId: 0x03030001u, + isStatic: false); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 15f)); + + int steps = 0; + RuntimeCollisionOwnerCaptureStep capture; + do + { + foreach (uint ownerId in new[] { 99u, 100u }) + { + physics.Engine.ShadowObjects.UpdatePhysicsState( + ownerId, + (uint)(steps + ownerId)); + } + capture = physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared); + Assert.False(capture.Restarted); + Assert.True(++steps < 512); + } + while (!capture.Completed); + + while (true) + { + foreach (uint ownerId in new[] { 99u, 100u }) + { + physics.Engine.ShadowObjects.UpdatePosition( + ownerId, + new Vector3(10f + ownerId - 99u + steps, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + 0x0303FFFFu, + seedCellId: 0x03030001u); + physics.Engine.ShadowObjects.UpdatePhysicsState( + ownerId, + (uint)(steps + ownerId)); + } + + RuntimeCollisionSealStep seal = + physics.AdvanceCollisionGenerationSeal(admission, prepared); + Assert.False(seal.Restarted); + Assert.InRange(seal.WorkUnits, 0, 1); + Assert.True(++steps < 512); + if (seal.Completed) + break; + } + + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + ShadowEntry[] entries = physics.Engine.ShadowObjects + .AllEntriesForDebug() + .OrderBy(entry => entry.EntityId) + .ToArray(); + Assert.Equal(new[] { 99u, 100u }, entries.Select(entry => entry.EntityId)); + foreach (ShadowEntry entry in entries) + { + Assert.Equal((uint)(steps - 1) + entry.EntityId, entry.State); + Assert.Equal( + 10f + entry.EntityId - 99u + steps - 1, + entry.Position.X); + } + } + + [Fact] + public void TwoRelevantOwnersMovingEverySealStepConvergeThroughWriteThrough() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(target, terrainHeight: 5f)); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + + foreach (uint ownerId in new[] { 42u, 43u }) + { + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f + ownerId - 42u, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 15f)); + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + + int step = 0; + RuntimeCollisionSealStep seal; + do + { + step++; + physics.Engine.ShadowObjects.UpdatePosition( + 42u, + new Vector3(10f + step * 0.01f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + target, + seedCellId: 0x01010001u); + physics.Engine.ShadowObjects.UpdatePosition( + 43u, + new Vector3(11f + step * 0.01f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + target, + seedCellId: 0x01010001u); + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.False(seal.Restarted); + Assert.InRange(seal.WorkUnits, 0, 1); + Assert.True(step < 256); + } + while (!seal.Completed); + + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + ShadowEntry[] entries = physics.Engine.ShadowObjects + .AllEntriesForDebug() + .OrderBy(entry => entry.EntityId) + .ToArray(); + Assert.Equal(2, entries.Length); + Assert.Equal(10f + step * 0.01f, entries[0].Position.X, 4); + Assert.Equal(11f + step * 0.01f, entries[1].Position.X, 4); + } + + [Fact] + public void FirstLoadWithNewStaticBucketActivatesWithZeroAllocation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 12f)); + prepared.Engine.ShadowObjects.Register( + 500u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + _ = SealPrepared(physics, admission, prepared); + + PhysicsDataCache cacheFacade = physics.DataCache; + CellGraph graphFacade = physics.DataCache.CellGraph; + ShadowObjectRegistry shadowFacade = physics.Engine.ShadowObjects; + int notifications = 0; + physics.CollisionGenerationCommitted += _ => notifications++; + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + long allocated = + GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(commit.Committed); + Assert.Equal(0L, allocated); + Assert.Equal(1, notifications); + Assert.Same(cacheFacade, physics.DataCache); + Assert.Same(graphFacade, physics.DataCache.CellGraph); + Assert.Same(shadowFacade, physics.Engine.ShadowObjects); + Assert.Equal(12f, physics.Engine.SampleTerrainZ(1f, 1f)); + Assert.Equal(500u, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).EntityId); + } + + [Fact] + public void DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const int residentLandblocks = 32; + for (int index = 0; index < residentLandblocks; index++) + { + uint prefix = (uint)(0x10 + index) << 24 | 0x010000u; + uint landblockId = prefix | 0xFFFFu; + RuntimeLandblockCollisionAssets assets = CollisionAssets( + landblockId, + terrainHeight: index); + physics.Engine.AddLandblock( + assets.LandblockId, + assets.Terrain, + assets.CellSurfaces, + assets.PortalPlanes, + assets.WorldOffsetX, + assets.WorldOffsetY); + AddSyntheticCell(physics.DataCache, prefix | 0x0100u); + physics.DataCache.RegisterBuildingForTest( + prefix | 1u, + SyntheticBuilding(Matrix4x4.Identity)); + physics.Engine.ShadowObjects.Register( + (uint)(10_000 + index), + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + landblockId, + seedCellId: prefix | 1u, + isStatic: false); + } + + const uint target = 0x4001FFFFu; + RuntimeCollisionAdmission warmAdmission = + physics.BeginCollisionAdmission(0x3F01FFFFu); + PreparedLandblockCollisionGeneration warm = + physics.PrepareCollisionGeneration(warmAdmission); + physics.CancelCollisionGeneration(warmAdmission, warm); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + long admissionAllocation = + GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.InRange(admissionAllocation, 1L, 128L * 1024L); + Assert.Equal(0, prepared.Engine.LandblockCount); + + int advances = 0; + RuntimeCollisionPreparationStep step; + do + { + step = physics.AdvanceCollisionGenerationPreparation( + admission, + prepared); + Assert.InRange(step.WorkUnits, 0, 1); + Assert.True(++advances < 10_000); + } + while (!step.Completed); + + Assert.True(advances > residentLandblocks); + Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount); + physics.CancelCollisionGeneration(admission, prepared); + } + + [Fact] + public void ChangedEnvCellsBuildingsAndStaticBucketActivateWithZeroAllocation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint oldCell = 0x01010100u; + const uint newCell = 0x01010101u; + const uint oldBuilding = 0x01010001u; + const uint newBuilding = 0x01010002u; + + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(target, terrainHeight: 5f)); + AddSyntheticCell(initial.DataCache, oldCell); + initial.DataCache.RegisterBuildingForTest( + oldBuilding, + SyntheticBuilding(Matrix4x4.Identity)); + initial.Engine.ShadowObjects.Register( + 600u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + prepared.DataCache.RemoveCellsForLandblock(target); + prepared.DataCache.RemoveBuildingsForLandblock(target); + prepared.DataCache.CellGraph.RemoveEnvCellsForLandblock(target); + prepared.Engine.ShadowObjects.DeregisterStaticOwnersForLandblock(target); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 15f)); + AddSyntheticCell(prepared.DataCache, newCell); + prepared.DataCache.RegisterBuildingForTest( + newBuilding, + SyntheticBuilding(Matrix4x4.CreateTranslation(1f, 0f, 0f))); + prepared.Engine.ShadowObjects.Register( + 601u, + 0x01000001u, + new Vector3(11f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010002u, + isStatic: true); + _ = SealPrepared(physics, admission, prepared); + + PhysicsDataCache cacheFacade = physics.DataCache; + CellGraph graphFacade = physics.DataCache.CellGraph; + ShadowObjectRegistry shadowFacade = physics.Engine.ShadowObjects; + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + long allocated = + GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(commit.Committed); + Assert.Equal(0L, allocated); + Assert.Same(cacheFacade, physics.DataCache); + Assert.Same(graphFacade, physics.DataCache.CellGraph); + Assert.Same(shadowFacade, physics.Engine.ShadowObjects); + Assert.Null(physics.DataCache.GetCellStruct(oldCell)); + Assert.NotNull(physics.DataCache.GetCellStruct(newCell)); + Assert.Null(physics.DataCache.GetBuilding(oldBuilding)); + Assert.NotNull(physics.DataCache.GetBuilding(newBuilding)); + Assert.Equal(601u, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).EntityId); + } + + [Fact] + public void ConcurrentPreparedLandblocksRebaseBeforeSecondActivation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint firstLandblock = 0x0101FFFFu; + const uint secondLandblock = 0x0202FFFFu; + RuntimeCollisionAdmission firstAdmission = + physics.BeginCollisionAdmission(firstLandblock); + RuntimeCollisionAdmission secondAdmission = + physics.BeginCollisionAdmission(secondLandblock); + using PreparedLandblockCollisionGeneration first = + physics.PrepareCollisionGeneration(firstAdmission); + using PreparedLandblockCollisionGeneration second = + physics.PrepareCollisionGeneration(secondAdmission); + physics.StageCollisionAssets( + firstAdmission, + first, + CollisionAssets(firstLandblock, terrainHeight: 5f)); + physics.StageCollisionAssets( + secondAdmission, + second, + CollisionAssets(secondLandblock, terrainHeight: 15f)); + first.Engine.ShadowObjects.Register( + 700u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + firstLandblock, + seedCellId: 0x01010001u, + isStatic: true); + _ = SealPrepared(physics, firstAdmission, first); + _ = SealPrepared(physics, secondAdmission, second); + + _ = GC.GetAllocatedBytesForCurrentThread(); + long firstBefore = GC.GetAllocatedBytesForCurrentThread(); + Assert.True(physics.CommitCollisionGeneration( + firstAdmission, + first).Committed); + long firstAllocated = + GC.GetAllocatedBytesForCurrentThread() - firstBefore; + Assert.Equal(0L, firstAllocated); + Assert.True(second.IsSealed); + Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); + Assert.False(physics.Engine.IsLandblockTerrainResident(secondLandblock)); + + _ = SealPrepared(physics, secondAdmission, second); + _ = GC.GetAllocatedBytesForCurrentThread(); + long secondBefore = GC.GetAllocatedBytesForCurrentThread(); + Assert.True(physics.CommitCollisionGeneration( + secondAdmission, + second).Committed); + long secondAllocated = + GC.GetAllocatedBytesForCurrentThread() - secondBefore; + Assert.Equal(0L, secondAllocated); + Assert.Equal(2, physics.Engine.LandblockCount); + Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); + Assert.True(physics.Engine.IsLandblockTerrainResident(secondLandblock)); + Assert.Equal(700u, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).EntityId); + } + + [Fact] + public void PostCommitOwnerMutationWinsOverQueuedPeerRebase() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint firstLandblock = 0x0101FFFFu; + const uint secondLandblock = 0x0202FFFFu; + RuntimeCollisionAdmission firstAdmission = + physics.BeginCollisionAdmission(firstLandblock); + using PreparedLandblockCollisionGeneration first = + physics.PrepareCollisionGeneration(firstAdmission); + physics.StageCollisionAssets( + firstAdmission, + first, + CollisionAssets(firstLandblock)); + first.Engine.ShadowObjects.Register( + 701u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + firstLandblock, + seedCellId: 0x01010001u, + isStatic: false); + + RuntimeCollisionAdmission secondAdmission = + physics.BeginCollisionAdmission(secondLandblock); + using PreparedLandblockCollisionGeneration second = + physics.PrepareCollisionGeneration(secondAdmission); + physics.StageCollisionAssets( + secondAdmission, + second, + CollisionAssets(secondLandblock)); + _ = SealPrepared(physics, firstAdmission, first); + _ = SealPrepared(physics, secondAdmission, second); + + Assert.True(physics.CommitCollisionGeneration( + firstAdmission, + first).Committed); + physics.Engine.ShadowObjects.UpdatePosition( + 701u, + new Vector3(12f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + firstLandblock, + seedCellId: 0x01010001u); + + _ = SealPrepared(physics, secondAdmission, second); + Assert.True(physics.CommitCollisionGeneration( + secondAdmission, + second).Committed); + Assert.Equal(12f, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).Position.X); + } + + [Fact] + public void ConcurrentSeamStaticRefloodsAgainstLaterTopology() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint north = 0xA9B4FFFFu; + const uint south = 0xA9B3FFFFu; + foreach ((uint landblock, float offsetY) in new[] + { + (north, 0f), + (south, -192f), + }) + { + RuntimeCollisionAdmission seed = + physics.BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(seed); + physics.StageCollisionAssets( + seed, + initial, + CollisionAssets(landblock) with + { + WorldOffsetY = offsetY, + }); + Assert.True(CommitPrepared(physics, seed, initial).Committed); + } + + RuntimeCollisionAdmission northAdmission = + physics.BeginCollisionAdmission(north); + using PreparedLandblockCollisionGeneration northPrepared = + physics.PrepareCollisionGeneration(northAdmission); + physics.StageCollisionAssets( + northAdmission, + northPrepared, + CollisionAssets(north)); + northPrepared.Engine.ShadowObjects.Register( + 702u, + 0x01000001u, + new Vector3(150f, 0.2f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + north, + seedCellId: 0xA9B40031u, + isStatic: true); + Assert.True(northPrepared.Engine.ShadowObjects.HasOwnerRowsInLandblock( + 702u, + south)); + + RuntimeCollisionAdmission southAdmission = + physics.BeginCollisionAdmission(south); + using PreparedLandblockCollisionGeneration southPrepared = + physics.PrepareCollisionGeneration(southAdmission); + physics.StageCollisionAssets( + southAdmission, + southPrepared, + CollisionAssets(south) with + { + WorldOffsetY = -192f, + }); + _ = SealPrepared(physics, northAdmission, northPrepared); + _ = SealPrepared(physics, southAdmission, southPrepared); + + Assert.True(physics.CommitCollisionGeneration( + northAdmission, + northPrepared).Committed); + _ = SealPrepared(physics, southAdmission, southPrepared); + Assert.True(physics.CommitCollisionGeneration( + southAdmission, + southPrepared).Committed); + Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( + 702u, + south)); + } + + [Fact] + public void CancelledOlderPreparationNeverLeaksIntoLaterDraft() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint cancelledLandblock = 0x0101FFFFu; + const uint survivingLandblock = 0x0202FFFFu; + RuntimeCollisionAdmission cancelledAdmission = + physics.BeginCollisionAdmission(cancelledLandblock); + PreparedLandblockCollisionGeneration cancelled = + physics.PrepareCollisionGeneration(cancelledAdmission); + physics.StageCollisionAssets( + cancelledAdmission, + cancelled, + CollisionAssets(cancelledLandblock, terrainHeight: 11f)); + RuntimeCollisionAdmission survivingAdmission = + physics.BeginCollisionAdmission(survivingLandblock); + using PreparedLandblockCollisionGeneration surviving = + physics.PrepareCollisionGeneration(survivingAdmission); + physics.StageCollisionAssets( + survivingAdmission, + surviving, + CollisionAssets(survivingLandblock, terrainHeight: 22f)); + _ = SealPrepared(physics, cancelledAdmission, cancelled); + + physics.CancelCollisionGeneration(cancelledAdmission, cancelled); + Assert.True(CommitPrepared( + physics, + survivingAdmission, + surviving).Committed); + + Assert.False(physics.Engine.IsLandblockTerrainResident( + cancelledLandblock)); + Assert.True(physics.Engine.IsLandblockTerrainResident( + survivingLandblock)); + } + + [Fact] + public void OutgoingTargetStaticMutationCannotResurrectOmittedOwner() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(target)); + initial.Engine.ShadowObjects.Register( + 500u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + prepared.Engine.ShadowObjects.DeregisterStaticOwnersForLandblock(target); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 3f)); + physics.Engine.ShadowObjects.UpdatePhysicsState(500u, 0x44u); + + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); + } + + [Fact] + public void SamePrefixCurrentCellMoveAfterSealRebindsToNewRoot() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint firstCell = 0x01010100u; + const uint secondCell = 0x01010101u; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(target)); + AddSyntheticCell(initial.DataCache, firstCell); + AddSyntheticCell(initial.DataCache, secondCell); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + physics.Engine.UpdatePlayerCurrCell(firstCell); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + prepared.DataCache.RemoveCellsForLandblock(target); + prepared.DataCache.CellGraph.RemoveEnvCellsForLandblock(target); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 5f)); + AddSyntheticCell(prepared.DataCache, firstCell); + AddSyntheticCell(prepared.DataCache, secondCell); + _ = SealPrepared(physics, admission, prepared); + + physics.Engine.UpdatePlayerCurrCell(secondCell); + ObjCell oldRootCell = physics.DataCache.CellGraph.CurrCell!; + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + + ObjCell? rebound = physics.DataCache.CellGraph.GetVisible(secondCell); + Assert.NotNull(rebound); + Assert.NotSame(oldRootCell, rebound); + Assert.Same(rebound, physics.DataCache.CellGraph.CurrCell); + } + + [Fact] + public void ArmedOwnerLeavingTargetRemainsLiveInItsNewPrefix() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint destination = 0x0202FFFFu; + foreach (uint landblock in new[] { target, destination }) + { + RuntimeCollisionAdmission seed = + physics.BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(seed); + physics.StageCollisionAssets( + seed, + initial, + CollisionAssets(landblock, terrainHeight: 5f)); + Assert.True(CommitPrepared(physics, seed, initial).Committed); + } + + physics.Engine.ShadowObjects.Register( + 42u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 9f)); + _ = SealPrepared(physics, admission, prepared); + + physics.Engine.ShadowObjects.UpdatePosition( + 42u, + new Vector3(11f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + destination, + seedCellId: 0x02020001u); + + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.False(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( + 42u, + target)); + Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( + 42u, + destination)); + Assert.Equal(11f, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).Position.X); + } + + [Fact] + public void ArmedOwnerIdReuseOutsideTargetKeepsNewIncarnationRows() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint destination = 0x0202FFFFu; + foreach (uint landblock in new[] { target, destination }) + { + RuntimeCollisionAdmission seed = + physics.BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(seed); + physics.StageCollisionAssets( + seed, + initial, + CollisionAssets(landblock)); + Assert.True(CommitPrepared(physics, seed, initial).Committed); + } + physics.Engine.ShadowObjects.Register( + 42u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 8f)); + _ = SealPrepared(physics, admission, prepared); + + physics.Engine.ShadowObjects.Deregister(42u); + physics.Engine.ShadowObjects.Register( + 42u, + 0x01000002u, + new Vector3(12f, 10f, 0f), + Quaternion.Identity, + 0.75f, + 0f, + 0f, + destination, + seedCellId: 0x02020001u, + isStatic: false); + + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + ShadowEntry entry = Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(0x01000002u, entry.GfxObjId); + Assert.Equal(12f, entry.Position.X); + Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( + 42u, + destination)); + } + + [Fact] + public void UnrelatedStateMutationIsJournaledAfterAllRowsChange() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint unrelated = 0x0202FFFFu; + foreach (uint landblock in new[] { target, unrelated }) + { + RuntimeCollisionAdmission seed = + physics.BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(seed); + physics.StageCollisionAssets( + seed, + initial, + CollisionAssets(landblock)); + Assert.True(CommitPrepared(physics, seed, initial).Committed); + } + physics.Engine.ShadowObjects.Register( + 99u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + unrelated, + state: 1u, + seedCellId: 0x02020001u, + isStatic: false); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 6f)); + physics.Engine.ShadowObjects.UpdatePhysicsState(99u, 0x1234u); + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + + Assert.All( + physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => Assert.Equal(0x1234u, entry.State)); + } + + [Fact] + public void PrefixOwnerSlotsReuseTombstonesUnderGuidChurn() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission)) + { + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + Assert.True(CommitPrepared( + physics, + admission, + prepared).Committed); + } + + for (uint ownerId = 1u; ownerId <= 2_000u; ownerId++) + { + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + physics.Engine.ShadowObjects.Deregister(ownerId); + } + + Assert.Equal(0, physics.Engine.ShadowObjects + .PrefixOwnerSlotCapacityForDiagnostics(target)); + Assert.Equal(0, physics.Engine.ShadowObjects + .OwnerVersionCountForDiagnostics); + } + + [Fact] + public void CommittedPreparationRevokesItsStagingCollisionRoot() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(0x0101FFFFu); + PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(0x0101FFFFu)); + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + + Assert.Throws( + () => _ = prepared.Engine.LandblockCount); + prepared.Dispose(); + } + + [Fact] + public void UnrelatedDemotionAndWithdrawalCannotBeResurrectedByDraft() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint demoted = 0x0101FFFFu; + const uint withdrawn = 0x0202FFFFu; + const uint replacement = 0x0303FFFFu; + + RuntimeCollisionAdmission first = + physics.BeginCollisionAdmission(demoted); + using (PreparedLandblockCollisionGeneration preparedFirst = + physics.PrepareCollisionGeneration(first)) + { + physics.StageCollisionAssets( + first, + preparedFirst, + CollisionAssets(demoted)); + AddSyntheticCell(preparedFirst.DataCache, 0x01010100u); + Assert.True(CommitPrepared( + physics, + first, + preparedFirst).Committed); + } + RuntimeCollisionAdmission second = + physics.BeginCollisionAdmission(withdrawn); + using (PreparedLandblockCollisionGeneration preparedSecond = + physics.PrepareCollisionGeneration(second)) + { + physics.StageCollisionAssets( + second, + preparedSecond, + CollisionAssets(withdrawn)); + Assert.True(CommitPrepared( + physics, + second, + preparedSecond).Committed); + } + + RuntimeCollisionAdmission third = + physics.BeginCollisionAdmission(replacement); + using PreparedLandblockCollisionGeneration preparedThird = + physics.PrepareCollisionGeneration(third); + physics.StageCollisionAssets( + third, + preparedThird, + CollisionAssets(replacement)); + + Assert.True(physics.DemoteCollisionToTerrain(demoted).WasResident); + Assert.True(physics.WithdrawCollision(withdrawn).WasResident); + Assert.True(CommitPrepared( + physics, + third, + preparedThird).Committed); + + Assert.True(physics.Engine.IsLandblockTerrainResident(demoted)); + Assert.False(physics.DataCache.CellGraph.Contains(0x01010100u)); + Assert.False(physics.Engine.IsLandblockTerrainResident(withdrawn)); + Assert.True(physics.Engine.IsLandblockTerrainResident(replacement)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void PendingOrActivePeerRebaseCannotResurrectRetiredLandblock( + bool withdraw, + bool beginRebase) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint retired = 0x0101FFFFu; + const uint survivor = 0x0202FFFFu; + const uint retiredCell = 0x01010100u; + + RuntimeCollisionAdmission retiredAdmission = + physics.BeginCollisionAdmission(retired); + using PreparedLandblockCollisionGeneration retiredPrepared = + physics.PrepareCollisionGeneration(retiredAdmission); + physics.StageCollisionAssets( + retiredAdmission, + retiredPrepared, + CollisionAssets(retired, terrainHeight: 7f)); + AddSyntheticCell(retiredPrepared.DataCache, retiredCell); + + RuntimeCollisionAdmission survivorAdmission = + physics.BeginCollisionAdmission(survivor); + using PreparedLandblockCollisionGeneration survivorPrepared = + physics.PrepareCollisionGeneration(survivorAdmission); + physics.StageCollisionAssets( + survivorAdmission, + survivorPrepared, + CollisionAssets(survivor, terrainHeight: 9f)); + while (!physics.AdvanceCollisionGenerationPreparation( + survivorAdmission, + survivorPrepared).Completed) + { + } + + _ = SealPrepared(physics, retiredAdmission, retiredPrepared); + Assert.True(physics.CommitCollisionGeneration( + retiredAdmission, + retiredPrepared).Committed); + if (beginRebase) + { + for (int step = 0; step < 12; step++) + { + RuntimeCollisionSealStep started = + physics.AdvanceCollisionGenerationSeal( + survivorAdmission, + survivorPrepared); + Assert.False(started.Completed); + Assert.InRange(started.WorkUnits, 0, 1); + } + } + + if (withdraw) + Assert.True(physics.WithdrawCollision(retired).WasResident); + else + Assert.True(physics.DemoteCollisionToTerrain(retired).WasResident); + + Assert.True(CommitPrepared( + physics, + survivorAdmission, + survivorPrepared).Committed); + Assert.Equal(!withdraw, physics.Engine.IsLandblockTerrainResident(retired)); + Assert.Null(physics.DataCache.GetCellStruct(retiredCell)); + Assert.False(physics.DataCache.CellGraph.Contains(retiredCell)); + Assert.True(physics.Engine.IsLandblockTerrainResident(survivor)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void OutgoingStaticDeletionCannotEraseSameIdAuthoredReplacement( + int deletionPhase) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint ownerId = 812u; + + RuntimeCollisionAdmission seedAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration seed = + physics.PrepareCollisionGeneration(seedAdmission)) + { + physics.StageCollisionAssets( + seedAdmission, + seed, + CollisionAssets(target)); + seed.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + Assert.True(CommitPrepared( + physics, + seedAdmission, + seed).Committed); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 4f)); + prepared.Engine.ShadowObjects.Register( + ownerId, + 0x01000002u, + new Vector3(13f, 10f, 0f), + Quaternion.Identity, + 0.75f, + 0f, + 0f, + target, + state: 0x55u, + seedCellId: 0x01010002u, + isStatic: true); + + if (deletionPhase == 1) + { + _ = physics.AdvanceCollisionGenerationPreparation( + admission, + prepared); + } + else if (deletionPhase == 2) + { + while (!physics.AdvanceCollisionGenerationPreparation( + admission, + prepared).Completed) + { + } + } + physics.Engine.ShadowObjects.Deregister(ownerId); + + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + ShadowEntry authored = Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == ownerId); + Assert.Equal(0x01000002u, authored.GfxObjId); + Assert.Equal(13f, authored.Position.X); + Assert.Equal(0x55u, authored.State); + } + + [Fact] + public void OwnerJournalCoalescesUnrelatedChurnAcrossManyDrafts() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint ownerId = 900u; + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + 0x0101FFFFu, + seedCellId: 0x01010001u, + isStatic: false); + + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 1u); + _ = GC.GetAllocatedBytesForCurrentThread(); + long baselineBefore = GC.GetAllocatedBytesForCurrentThread(); + for (uint version = 2u; version <= 10_001u; version++) + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, version); + long baselineAllocated = + GC.GetAllocatedBytesForCurrentThread() - baselineBefore; + + var admissions = new List(); + var preparations = new List(); + for (int index = 0; index < 32; index++) + { + uint landblock = ((uint)(0x20 + index) << 24) | 0x0001FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(landblock); + admissions.Add(admission); + preparations.Add(physics.PrepareCollisionGeneration(admission)); + } + + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 10_002u); + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (uint version = 10_003u; version <= 20_002u; version++) + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, version); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(baselineAllocated, allocated); + Assert.Equal(1, physics.CollisionOwnerJournalEntryCountForDiagnostics); + for (int index = 0; index < admissions.Count; index++) + { + physics.CancelCollisionGeneration( + admissions[index], + preparations[index]); + } + Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); + } + + [Fact] + public void ManyUniqueOwnerMutationsReconcileOnePerSealAndCommitWithoutAllocation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint unrelated = 0x0202FFFFu; + const int ownerCount = 512; + + RuntimeCollisionAdmission unrelatedAdmission = + physics.BeginCollisionAdmission(unrelated); + using (PreparedLandblockCollisionGeneration unrelatedPrepared = + physics.PrepareCollisionGeneration(unrelatedAdmission)) + { + physics.StageCollisionAssets( + unrelatedAdmission, + unrelatedPrepared, + CollisionAssets(unrelated)); + Assert.True(CommitPrepared( + physics, + unrelatedAdmission, + unrelatedPrepared).Committed); + } + for (uint index = 0; index < ownerCount; index++) + { + physics.Engine.ShadowObjects.Register( + 30_000u + index, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + unrelated, + seedCellId: 0x02020001u, + isStatic: false); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + _ = SealPrepared(physics, admission, prepared); + + for (uint index = 0; index < ownerCount; index++) + { + physics.Engine.ShadowObjects.UpdatePhysicsState( + 30_000u + index, + index + 1u); + } + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + + int worked = 0; + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.InRange(seal.WorkUnits, 0, 1); + worked += seal.WorkUnits; + } + while (!seal.Completed); + Assert.Equal(ownerCount, worked); + + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(commit.Committed); + Assert.Equal(0L, allocated); + Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); + } + + [Fact] + public void CompactedJournalSupersessionPreservesTheMissedSuffix() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint oldestTarget = 0x0101FFFFu; + const uint survivingTarget = 0x0202FFFFu; + + RuntimeCollisionAdmission oldestAdmission = + physics.BeginCollisionAdmission(oldestTarget); + PreparedLandblockCollisionGeneration oldest = + physics.PrepareCollisionGeneration(oldestAdmission); + physics.Engine.ShadowObjects.Register( + 41_000u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + 0x0303FFFFu, + seedCellId: 0x03030001u, + isStatic: false); + + RuntimeCollisionAdmission survivingAdmission = + physics.BeginCollisionAdmission(survivingTarget); + using PreparedLandblockCollisionGeneration surviving = + physics.PrepareCollisionGeneration(survivingAdmission); + physics.StageCollisionAssets( + survivingAdmission, + surviving, + CollisionAssets(survivingTarget)); + physics.CancelCollisionGeneration(oldestAdmission, oldest); + + _ = SealPrepared(physics, survivingAdmission, surviving); + RuntimeCollisionSealStep compacted = + physics.AdvanceCollisionGenerationSeal( + survivingAdmission, + surviving); + Assert.True(compacted.Completed); + Assert.Equal(1, compacted.WorkUnits); + Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); + + physics.Engine.ShadowObjects.Register( + 42_000u, + 0x01000001u, + new Vector3(12f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + survivingTarget, + seedCellId: 0x02020001u, + isStatic: false); + Assert.True(physics.CommitCollisionGeneration( + survivingAdmission, + surviving).Committed); + Assert.Contains( + physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == 42_000u); + } + + [Fact] + public void UnrelatedOwnerEnteringTargetAfterJournalScanWritesThroughExactly() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint outside = 0x0202FFFFu; + const uint ownerId = 43_000u; + + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + outside, + seedCellId: 0x02020001u, + isStatic: false); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 1u); + _ = SealPrepared(physics, admission, prepared); + + physics.Engine.ShadowObjects.UpdatePosition( + ownerId, + new Vector3(12f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + target, + seedCellId: 0x01010001u); + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( + ownerId, + target)); + } + + [Fact] + public void LateSamePrefixMutationOfScannedUnrelatedOwnerWritesThroughExactly() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint outside = 0x0202FFFFu; + const uint ownerId = 43_500u; + + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + outside, + state: 1u, + seedCellId: 0x02020001u, + isStatic: false); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 2u); + _ = SealPrepared(physics, admission, prepared); + + physics.Engine.ShadowObjects.UpdatePosition( + ownerId, + new Vector3(14f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + outside, + seedCellId: 0x02020001u); + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 0x55u); + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + ShadowEntry entry = Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug(), + item => item.EntityId == ownerId); + Assert.Equal(14f, entry.Position.X); + Assert.Equal(0x55u, entry.State); + } + + [Fact] + public void JournalTailCompactionRetiresItsFreeSlotMetadataIncrementally() + { + var journal = new CollisionOwnerMutationJournal(); + const int ownerCount = 4_096; + for (uint ownerId = 1u; ownerId <= ownerCount; ownerId++) + _ = journal.Record(ownerId, ownerId, journal.NextSequence); + journal.RequestCompactionBefore(journal.NextSequence); + + int steps = 0; + while (journal.AdvanceCompaction()) + Assert.True(++steps <= ownerCount * 2); + Assert.Equal(0, journal.Count); + Assert.Equal(0, journal.ActiveCount); + + CollisionOwnerMutationJournal.MutationRecord next = + journal.Record( + 99_999u, + 1u, + journal.NextSequence); + Assert.Equal(0, next.SlotIndex); + } + + [Fact] + public void RetirementAfterSealBlocksCommitUntilItsCursorCompletes() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint retired = 0x0101FFFFu; + const uint replacement = 0x0202FFFFu; + const uint retiredCell = 0x01010100u; + + RuntimeCollisionAdmission retiredAdmission = + physics.BeginCollisionAdmission(retired); + using (PreparedLandblockCollisionGeneration retiredPrepared = + physics.PrepareCollisionGeneration(retiredAdmission)) + { + physics.StageCollisionAssets( + retiredAdmission, + retiredPrepared, + CollisionAssets(retired)); + AddSyntheticCell(retiredPrepared.DataCache, retiredCell); + Assert.True(CommitPrepared( + physics, + retiredAdmission, + retiredPrepared).Committed); + } + + RuntimeCollisionAdmission replacementAdmission = + physics.BeginCollisionAdmission(replacement); + using PreparedLandblockCollisionGeneration replacementPrepared = + physics.PrepareCollisionGeneration(replacementAdmission); + physics.StageCollisionAssets( + replacementAdmission, + replacementPrepared, + CollisionAssets(replacement)); + _ = SealPrepared( + physics, + replacementAdmission, + replacementPrepared); + + Assert.True(physics.WithdrawCollision(retired).WasResident); + Assert.False(physics.CommitCollisionGeneration( + replacementAdmission, + replacementPrepared).Committed); + + _ = SealPrepared( + physics, + replacementAdmission, + replacementPrepared); + Assert.True(physics.CommitCollisionGeneration( + replacementAdmission, + replacementPrepared).Committed); + Assert.False(physics.Engine.IsLandblockTerrainResident(retired)); + Assert.Null(physics.DataCache.GetCellStruct(retiredCell)); + } + + [Fact] + public void MoreThanFixedRingWorthOfRetirementsRemainMeteredAndLossless() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + _ = SealPrepared(physics, admission, prepared); + + for (uint index = 0; index < 300u; index++) + { + uint ordinal = index + 0x1000u; + uint x = ordinal & 0xFFu; + uint y = (ordinal >> 8) & 0xFFu; + _ = physics.WithdrawCollision( + (x << 24) | (y << 16) | 0xFFFFu); + } + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + + int steps = 0; + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.InRange(seal.WorkUnits, 0, 1); + Assert.True(++steps < 100_000); + } + while (!seal.Completed); + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + } + + [Fact] + public void EmptyOwnerPrefixContainersAreReclaimedAcrossUniquePrefixes() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + for (uint index = 1u; index <= 2_000u; index++) + { + uint x = index & 0xFFu; + uint y = (index >> 8) & 0xFFu; + uint prefix = (x << 24) | (y << 16); + uint ownerId = 20_000u + index; + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + prefix | 0xFFFFu, + seedCellId: prefix | 1u, + isStatic: false); + physics.Engine.ShadowObjects.Deregister(ownerId); + } + + Assert.Equal(0, physics.Engine.ShadowObjects + .PrefixOwnerContainerCountForDiagnostics); + Assert.Equal(0, physics.Engine.ShadowObjects + .OwnerVersionCountForDiagnostics); + } + + [Fact] + public void SealCursorRetainsCapturedSlotListsWhenPrefixContainerIsReclaimed() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint ownerId = 41_000u; + + RuntimeCollisionAdmission seedAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration seed = + physics.PrepareCollisionGeneration(seedAdmission)) + { + physics.StageCollisionAssets( + seedAdmission, + seed, + CollisionAssets(target)); + Assert.True(CommitPrepared( + physics, + seedAdmission, + seed).Committed); + } + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 2f)); + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + RuntimeCollisionSealStep started; + do + { + started = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.False(started.Completed); + } + while (started.WorkUnits == 0); + Assert.Equal(1, started.WorkUnits); + + physics.Engine.ShadowObjects.Deregister(ownerId); + Assert.Equal(0, physics.Engine.ShadowObjects + .PrefixOwnerContainerCountForDiagnostics); + + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.InRange(seal.WorkUnits, 0, 1); + } + while (!seal.Completed); + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); + } + + [Fact] + public void LiveCurrentCellIsNotRolledBackByTopologyActivation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint first = 0x0101FFFFu; + const uint second = 0x0202FFFFu; + foreach (uint landblock in new[] { first, second }) + { + RuntimeCollisionAdmission seed = + physics.BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(seed); + physics.StageCollisionAssets( + seed, + initial, + CollisionAssets(landblock)); + Assert.True(CommitPrepared(physics, seed, initial).Committed); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(first); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(first, terrainHeight: 7f)); + physics.Engine.UpdatePlayerCurrCell(0x02020001u); + Assert.Equal(0x02020001u, physics.DataCache.CellGraph.CurrCell?.Id); + + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + Assert.Equal(0x02020001u, physics.DataCache.CellGraph.CurrCell?.Id); + } + [Fact] public void SpawnAndDeleteDuringStagingAreBothGenerationGated() { @@ -608,26 +2447,54 @@ public sealed class RuntimePhysicsStateTests 0x0101FFFFu, seedCellId: 0x01010001u, isStatic: false); - RuntimeCollisionGenerationCommit spawned = - physics.CommitCollisionGeneration(admission, prepared); - Assert.False(spawned.Committed); - - Assert.Equal(77u, Assert.Single(SealPrepared( - physics, - admission, - prepared))); physics.Engine.ShadowObjects.Deregister(77u); - RuntimeCollisionGenerationCommit deleted = - physics.CommitCollisionGeneration(admission, prepared); - Assert.False(deleted.Committed); - - Assert.Empty(SealPrepared(physics, admission, prepared)); Assert.True(physics.CommitCollisionGeneration( admission, prepared).Committed); Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); } + [Fact] + public void TwoPostSealOwnersWriteThroughBeforeActivation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + Assert.Empty(SealPrepared(physics, admission, prepared)); + + foreach (uint ownerId in new[] { 71u, 72u }) + { + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f + ownerId - 71u, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + } + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.Equal( + new[] { 71u, 72u }, + physics.Engine.ShadowObjects.AllEntriesForDebug() + .Select(entry => entry.EntityId) + .Order() + .ToArray()); + } + [Fact] public void TerminalDisposalClearsLandblocksShadowsAndWorksets() { @@ -1073,6 +2940,39 @@ public sealed class RuntimePhysicsStateTests return [.. prepared.RetainedOwnerIds]; } + private static void AddSyntheticCell( + PhysicsDataCache cache, + uint cellId) + { + cache.RegisterCellStructForTest( + cellId, + new CellPhysics + { + Resolved = new Dictionary(), + }); + cache.CellGraph.Add(new EnvCell( + cellId, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + seenOutside: false, + containmentBsp: null)); + } + + private static BuildingPhysics SyntheticBuilding(Matrix4x4 transform) + { + Matrix4x4.Invert(transform, out Matrix4x4 inverse); + return new BuildingPhysics + { + WorldTransform = transform, + InverseWorldTransform = inverse, + Portals = Array.Empty(), + }; + } + private static RuntimeLandblockCollisionAssets CollisionAssets( uint landblockId, float terrainHeight = 0f) From e84a388e6f17c8cb03836e9aee641454b2009868 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 20:44:03 +0200 Subject: [PATCH 25/73] feat(physics): port canonical retail set-position core --- .../retail-divergence-register.md | 10 +- .../2026-07-31-canonical-set-position.md | 142 +++ src/AcDream.Core/Physics/PhysicsEngine.cs | 393 +++++++ .../Physics/PhysicsSetPosition.cs | 155 +++ src/AcDream.Core/Physics/TransitionTypes.cs | 232 +++- .../Physics/PhysicsSetPositionTests.cs | 1041 +++++++++++++++++ 6 files changed, 1952 insertions(+), 21 deletions(-) create mode 100644 docs/research/2026-07-31-canonical-set-position.md create mode 100644 src/AcDream.Core/Physics/PhysicsSetPosition.cs create mode 100644 tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8281655c..4c37cc4a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -74,6 +74,12 @@ by the DAT-authored portal-space viewport. Recent additions and splits: AD-47/AD-48 (Vulkan sample/present behavior), AD-50..AD-52 (Campaign N), and AD-53..AD-55 (Campaign P response-layer findings). +AD-2 clarification (placement Slice 4A, 2026-07-31): Core never creates cells +during placement, so retail `DoNotCreateCells` has no differential synchronous +loader branch there. Slice 4B must preserve the flag while mapping successful +deferred placement to exact-cell, generation-scoped asynchronous admission; +the presence of the flag in the immutable request is not claimed as exactness. + | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | ~~AD-53~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `Transition.CliffSlide` now consumes only `collision_info.last_known_contact_plane.N`, exactly as retail does. The invented `LastWalkablePlane -> LastKnownContactPlane -> UnitZ` fallback chain is gone; invalid/default or parallel data takes retail's degenerate `OK_TS` return. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07) | @@ -126,7 +132,7 @@ AD-53..AD-55 (Campaign P response-layer findings). --- -## 3. Documented approximation (AP) — 87 active rows (AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 87 active rows (AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -134,7 +140,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position | +| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4A).** Core now exposes the pure retail `SetPosition` → `AdjustPosition` → `CheckPositionInternal` → `find_valid_position` transaction, including exact sphere initialization, two distinct placement validators, step-down schedule, force classes, flags/scatter, signed no-slide predicate, error values, and an explicit deferred-residence result. Production zero-delta routes deliberately still use the legacy nearest-in-Z/terrain-lift resolver because its `ResolveResult` cannot represent a successful lost-cell transition. Slice 4B must cut every authoritative route to the canonical packet and install the Runtime lost-cell owner before this row retires. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs`; `src/AcDream.Core/Physics/TransitionTypes.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `docs/research/2026-07-31-canonical-set-position.md` | Split preserves production behavior while the immutable placement mechanism and its oracle gates land independently | Until 4B, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation and cannot park/wake an exact lost-cell frame | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | | ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 | diff --git a/docs/research/2026-07-31-canonical-set-position.md b/docs/research/2026-07-31-canonical-set-position.md new file mode 100644 index 00000000..f166645e --- /dev/null +++ b/docs/research/2026-07-31-canonical-set-position.md @@ -0,0 +1,142 @@ +# Canonical retail `SetPosition` — placement/streaming Slice 4A + +## Scope + +This note pins the pure physics half of the placement/streaming closeout. +Slice 4A lands the retail placement transaction as a separately testable Core +mechanism. It deliberately does **not** replace the production snap-only +resolver yet: Runtime lost-cell ownership and the complete inbound-route +cutover remain Slice 4B. AP-1 and AD-1 therefore remain active until that +cutover is complete. + +Named-retail oracle, Sept 2013 EoR: + +- `CPhysicsObj::SetPosition` `0x005160C0` +- `CPhysicsObj::SetPositionInternal` `0x00515BD0` +- `CPhysicsObj::AdjustPosition` `0x00511D80` +- `CPhysicsObj::CheckPositionInternal` `0x00511E90` +- `CTransition::find_valid_position` `0x0050C310` +- `CTransition::find_placement_position` `0x0050C170` +- `CTransition::find_placement_pos` `0x0050BA50` +- `CTransition::validate_placement_transition` `0x0050ADC0` +- `CTransition::validate_placement` `0x0050B210` +- `CPhysicsObj::ForceIntoCell` `0x00515660` +- `CPhysicsObj::handle_all_collisions` `0x00514780` + +## Retail transaction + +```text +SetPosition(request): + transition = makeTransition() // GENERAL_FAILURE if none + init_object(transition, object) + + if the PartArray has no spheres: + init_sphere(1, dummy center=(0,0,0.1), radius=0.1, scale=1) + else: + init_sphere(first min(count,2) authored spheres, exact object scale) + + if flags & RANDOM_SCATTER (0x200): + return scatter only + + result = SetPositionInternal(request) + if result != OK and flags & SCATTER (0x100): + return scatter + return result + +SetPositionInternal(request): + AdjustPosition(request frame, first sphere, noCreate=(flags & 0x20)) + if no resident cell: + store the adjusted authoritative frame and enter lost-cell lifetime + return OK + + if the live weenie is Hook, Storage, or Corpse: + return ForceIntoCell(resident cell, frame) + + set do_not_load_cells from flag 0x20 + if !CheckPositionInternal(...): + handled = handle_all_collisions(...) + return handled ? COLLIDED : NO_VALID_POSITION + if transition.curr_cell == null: + return NO_CELL + commit the complete transition + return OK +``` + +`AdjustPosition` branches on the claimed cell shape. A direct outdoor claim +runs pure `LandDefs::adjust_to_outside` normalization before visible-cell +lookup. An indoor claim first resolves the visible cell and child; only a +resident indoor cell marked `seen_outside` falls back to outdoor +normalization. An absent indoor cell (including the `0xFFFF` sentinel) remains +the exact claimed cell/frame. A map-edge outdoor normalization failure stores +cell zero with the otherwise unchanged frame. The old `max(terrainZ, z)` lift +and nearest-in-Z scan do not occur in this canonical mechanism. + +`CheckPositionInternal` calls the complete placement transition. Without the +slide flag, retail accepts the result only when signed +`resolvedX-requestedX <= 0.0500000007`, the same signed Y condition holds, and +the cell is unchanged. Z is not part of that predicate. The resolved origin is +accepted while the requested orientation remains intact. + +## Two validators, not one + +The similarly named retail helpers have different contracts and stay separate +in the port: + +- `validate_placement_transition` is the inner `find_placement_pos` validator. + Any non-OK state from `COLLIDED` through `SLID`, when sliding is permitted, + resets `COLLISIONINFO`; it never retries placement. +- `validate_placement` is the outer initial/final validator. Only `ADJUSTED` + or `SLID`, and only while its retry argument is true, performs one + `placement_insert`; `COLLIDED` neither resets nor retries. + +Step-down is disabled only for missiles. For fewer than two spheres retail +first clamps the requested height to half the radius when the sphere diameter +is less than or equal to that height. It then performs one full probe when the +diameter is greater than the resulting height, otherwise two half probes. +Equality belongs to the two-half-probe branch. + +## Modern seam + +`PhysicsEngine.SetPosition` returns one immutable +`PhysicsSetPositionResult`. `SetPositionError` retains the header values +(`OK=0`, `GENERAL=1`, `NO_VALID=2`, `NO_CELL=3`, `COLLIDED=4`, +`INVALID_ARGS=0x100`) while `PhysicsResidenceDisposition` separately reports +`Committed`, `DeferredCell`, or `Unchanged`. Missing content is therefore +successful-but-deferred, never misreported as a placement failure. + +The result carries the complete commit packet: root/cell-local frame, +contact/walkable/water state, sliding and collision normals, stationary-fall +counter, a complete immutable `COLLISIONINFO` snapshot for the real +`handle_all_collisions` callback (including contact/last-contact, sliding, +collision normal, stationary-fall, environment, adjustment, and object +fields), the callback result, and an explicit shadow action. A PhysicsBSP or changed-cell force commit +requests canonical shadow recalculation; a non-BSP transition replaces its +shadows only when the transition produced a nonempty cell array, otherwise it +preserves the prior list. Unchanged force placement changes only the frame. + +Core never creates cells synchronously, so retail flag `0x20` +(`DoNotCreateCells`) has no differential loader branch inside this pure +mechanism. Both flag states can only observe already-published immutable cell +content and otherwise return `DeferredCell`. Carrying the flag into exact-cell, +generation-scoped async admission is part of the still-open Slice 4B +adaptation tracked with AD-2; this slice does not claim a dead SpherePath field +as exact behavior. + +The existing public `Resolve` compatibility entry remains entirely unchanged +for production movement and zero-delta callers. Its result cannot represent a +successful-but-deferred residence, so hiding `DeferredCell` inside +`ResolveResult.Ok` would corrupt the contract. Slice 4B will route every +authoritative placement family through `SetPosition`, atomically install its +packet, and own exact lost-cell wakeup/commit. + +## Automated oracle + +`PhysicsSetPositionTests` pins error values, absent/invalid outdoor and indoor +claims, cross-landblock frame normalization, map-edge failure and the `0xFFFF` +sentinel, dummy/authored sphere setup, nonpositive scale, Ethereal seeding, +explicit-only PathClipped, missile step-down, exact equality schedules, both validators, +the late compass sample's float bits, signed no-slide behavior, actual +collision-handler mapping, force-class policy, explicit shadow actions, null +current-cell wakeup, ten-record scratch exhaustion, exact scatter ordering, +failed-probe scratch lifetime, and deferred-scatter stop. The legacy public +Resolve fixture remains unchanged until Slice 4B. diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index d4d7610b..597bed30 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -89,6 +89,14 @@ public sealed class PhysicsEngine TransitionState, TransitionState>? TransitionCellCollisionTestHook { get; set; } + /// + /// Deterministic seam for retail Random::RollDice(-1, 1) used by + /// SetPosition scatter. Values are expected in [0,1); production uses the + /// process RNG and tests inject a fixed sequence. + /// + internal Func SetPositionRandomUnit { get; set; } = + Random.Shared.NextDouble; + /// /// True once the landblock covering has had its /// terrain + cells registered via . Accepts a canonical @@ -1530,6 +1538,391 @@ public sealed class PhysicsEngine /// green). /// /// + private readonly record struct AdjustedSetPosition( + uint CellId, + Vector3 CellLocalPosition, + bool Resident); + + /// + /// SetPosition's exact AdjustPosition input/output shape. Unlike the + /// camera helper above, this carries retail's block-local frame and can + /// therefore run outdoor LandDefs::adjust_to_outside without + /// consulting the resident-landblock registry. A valid adjusted id with + /// no visible cell is retained for the lost-cell path. + /// + private AdjustedSetPosition AdjustSetPosition( + uint seedCellId, + Vector3 cellLocalPosition, + Vector3 firstWorldSphereCenter) + { + uint low = seedCellId & 0xFFFFu; + bool lowInRange = low is (>= 1u and <= 0x40u) + or (>= 0x0100u and <= 0xFFFDu) + or 0xFFFFu; + if (!lowInRange) + return new AdjustedSetPosition( + seedCellId, + cellLocalPosition, + Resident: false); + + uint adjustedCell = seedCellId; + Vector3 adjustedLocal = cellLocalPosition; + if (low >= 0x0100u) + { + PhysicsDataCache? cache = DataCache; + if (cache is null || cache.GetCellStruct(seedCellId) is null) + { + return new AdjustedSetPosition( + seedCellId, + cellLocalPosition, + Resident: false); + } + + uint child = CellTransit.FindVisibleChildCell( + cache, + seedCellId, + firstWorldSphereCenter, + useStabList: true); + if (child != 0u) + { + return new AdjustedSetPosition( + child, + adjustedLocal, + Resident: cache.GetCellStruct(child) is not null); + } + + CellPhysics? claimed = cache.GetCellStruct(seedCellId); + if (claimed is null || !claimed.SeenOutside) + { + return new AdjustedSetPosition( + seedCellId, + adjustedLocal, + Resident: false); + } + } + + // Outdoor adjustment is pure cell-relative LandDefs math. Residency + // is observed only after the id/frame have been mutated. + bool adjusted = LandDefs.AdjustToOutside( + ref adjustedCell, + ref adjustedLocal); + bool resident = adjusted + && IsLandblockTerrainResident(adjustedCell); + return new AdjustedSetPosition( + adjustedCell, + adjustedLocal, + resident); + } + + /// Canonical retail placement transaction: + /// CPhysicsObj::SetPosition (0x005160C0) -> + /// SetPositionInternal (0x00515BD0) -> + /// AdjustPosition (0x00511D80) -> + /// CheckPositionInternal (0x00511E90) -> + /// CTransition::find_valid_position (0x0050C310). + /// A destination whose cell is not resident returns DeferredCell; callers + /// must retain the authoritative frame rather than demoting it outdoors. + /// + internal PhysicsSetPositionResult SetPosition( + in PhysicsSetPositionRequest request, + Func? handleCollisions = null) + { + if (_transitionScratch?.ActiveDepth >= TransitionScratchArena.Capacity) + { + return ErrorResult( + request, + PhysicsSetPositionError.GeneralFailure); + } + + Transition transition = RentTransition(); + try + { + InitializeSetPositionTransition(transition, request); + bool randomOnly = request.Flags.HasFlag( + PhysicsSetPositionFlags.RandomScatter); + if (randomOnly) + { + return SetScatterPositionInternal( + transition, + request, + handleCollisions); + } + + PhysicsSetPositionResult result = + SetPositionInternal(transition, request, handleCollisions); + if (result.Error != PhysicsSetPositionError.Ok + && request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter)) + { + return SetScatterPositionInternal( + transition, + request, + handleCollisions); + } + return result; + } + finally + { + ReturnTransition(transition); + } + } + + private void InitializeSetPositionTransition( + Transition transition, + in PhysicsSetPositionRequest request) + { + transition.ObjectInfo.StepUpHeight = request.StepUpHeight; + transition.ObjectInfo.StepDownHeight = request.StepDownHeight; + transition.ObjectInfo.StepDown = + !request.MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile); + transition.ObjectInfo.MoverPhysicsState = request.MoverPhysicsState; + transition.ObjectInfo.SelfEntityId = request.MovingEntityId; + transition.ObjectInfo.State = request.MoverFlags; + transition.ObjectInfo.Ethereal = request.MoverPhysicsState.HasFlag( + PhysicsStateFlags.Ethereal); + transition.SpherePath.PlacementAllowsSliding = + request.Flags.HasFlag(PhysicsSetPositionFlags.Slide); + } + + private PhysicsSetPositionResult SetScatterPositionInternal( + Transition transition, + in PhysicsSetPositionRequest request, + Func? handleCollisions) + { + PhysicsSetPositionResult result = ErrorResult( + request, + PhysicsSetPositionError.GeneralFailure); + for (uint attempt = 0u; attempt < request.ScatterAttempts; attempt++) + { + float dx = ((float)((SetPositionRandomUnit() * 2d) - 1d)) + * request.ScatterRadiusX; + float dy = ((float)((SetPositionRandomUnit() * 2d) - 1d)) + * request.ScatterRadiusY; + var scattered = request with + { + Position = request.Position + new Vector3(dx, dy, 0f), + CellLocalPosition = request.CellLocalPosition + + new Vector3(dx, dy, 0f), + }; + result = SetPositionInternal( + transition, + scattered, + handleCollisions); + if (result.Error == PhysicsSetPositionError.Ok) + break; + } + return result; + } + + private PhysicsSetPositionResult SetPositionInternal( + Transition transition, + in PhysicsSetPositionRequest request, + Func? handleCollisions) + { + transition.SpherePath.CellCandidates.Clear(); + transition.SpherePath.ClearWalkable(); + ImmutableArray spheres = request.Spheres; + float sphereScale = spheres.IsDefaultOrEmpty ? 1f : request.Scale; + Vector3 firstLocalCenter = spheres.IsDefaultOrEmpty + ? new Vector3(0f, 0f, PhysicsGlobals.DummySphereRadius) + : spheres[0].Origin * sphereScale; + Vector3 firstWorldCenter = + Vector3.Transform(firstLocalCenter, request.Orientation) + + request.Position; + AdjustedSetPosition adjusted = AdjustSetPosition( + request.CellId, + request.CellLocalPosition, + firstWorldCenter); + if (!adjusted.Resident) + { + return new PhysicsSetPositionResult( + PhysicsSetPositionError.Ok, + PhysicsResidenceDisposition.DeferredCell, + request.Position, + request.Orientation, + adjusted.CellId, + adjusted.CellLocalPosition, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: ImmutableArray.Empty); + } + + bool forceIntoCell = request.PlacementClass is + PhysicsPlacementClass.Hook + or PhysicsPlacementClass.Storage + or PhysicsPlacementClass.Corpse; + if (forceIntoCell) + { + if (adjusted.CellId == 0u) + { + return ErrorResult( + request, + PhysicsSetPositionError.NoCell); + } + bool changedCell = request.CurrentCellId is null + || request.CurrentCellId.Value != adjusted.CellId; + return new PhysicsSetPositionResult( + PhysicsSetPositionError.Ok, + PhysicsResidenceDisposition.Committed, + request.Position, + request.Orientation, + adjusted.CellId, + adjusted.CellLocalPosition, + CellChanged: changedCell, + ShadowAction: changedCell + ? PhysicsShadowCommitAction.Recalculate + : PhysicsShadowCommitAction.None, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: ImmutableArray.Empty); + } + + transition.SpherePath.InitPath( + request.Position, + request.Position, + adjusted.CellId, + spheres, + sphereScale, + request.Orientation, + request.Orientation); + transition.SpherePath.InsertType = InsertType.Placement; + transition.SpherePath.PlacementAllowsSliding = + request.Flags.HasFlag(PhysicsSetPositionFlags.Slide); + + bool valid = transition.FindValidPosition(this); + SpherePath spherePath = transition.SpherePath; + if (valid + && !request.Flags.HasFlag(PhysicsSetPositionFlags.Slide)) + { + valid = AcceptNoSlidePlacement( + spherePath.CurPos, + request.Position, + spherePath.CurCellId, + adjusted.CellId); + } + + CollisionInfo collision = transition.CollisionInfo; + var collisionReport = new PhysicsSetPositionCollisionReport( + collision.ContactPlaneValid, + collision.ContactPlane, + collision.ContactPlaneCellId, + collision.ContactPlaneIsWater, + collision.LastKnownContactPlaneValid, + collision.LastKnownContactPlane, + collision.LastKnownContactPlaneCellId, + collision.LastKnownContactPlaneIsWater, + collision.SlidingNormalValid, + collision.SlidingNormal, + collision.CollisionNormalValid, + collision.CollisionNormal, + collision.CollidedWithEnvironment, + collision.FramesStationaryFall, + collision.AdjustOffset, + collision.LastCollidedObjectGuid, + collision.CollideObjectGuids.ToImmutableArray()); + bool collisionHandlerResult = !valid + && handleCollisions?.Invoke(collisionReport) == true; + if (!valid) + { + return new PhysicsSetPositionResult( + collisionHandlerResult + ? PhysicsSetPositionError.Collided + : PhysicsSetPositionError.NoValidPosition, + PhysicsResidenceDisposition.Unchanged, + request.Position, + request.Orientation, + request.CellId, + request.CellLocalPosition, + InContact: collision.ContactPlaneValid, + OnWalkable: PhysicsObjUpdate.IsWalkableContact( + collision.ContactPlaneValid, + collision.ContactPlane.Normal), + ContactPlane: collision.ContactPlane, + ContactPlaneCellId: collision.ContactPlaneCellId, + ContactPlaneIsWater: collision.ContactPlaneIsWater, + SlidingNormalValid: collision.SlidingNormalValid, + SlidingNormal: collision.SlidingNormal, + CollisionNormalValid: collision.CollisionNormalValid, + CollisionNormal: collision.CollisionNormal, + FramesStationaryFall: collision.FramesStationaryFall, + CollisionHandlerResult: collisionHandlerResult, + CollidedWithEnvironment: collision.CollidedWithEnvironment, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: collisionReport.CollidedObjectIds); + } + if (spherePath.CurCellId == 0u) + { + return ErrorResult(request, PhysicsSetPositionError.NoCell); + } + + bool inContact = collision.ContactPlaneValid; + bool onWalkable = PhysicsObjUpdate.IsWalkableContact( + inContact, + collision.ContactPlane.Normal); + Vector3 resultLocal = adjusted.CellLocalPosition + + (spherePath.CurPos - request.Position) + - LandDefs.GetBlockOffset(adjusted.CellId, spherePath.CurCellId); + bool hasPhysicsBsp = request.MoverPhysicsState.HasFlag( + PhysicsStateFlags.HasPhysicsBsp); + ImmutableArray transitionCells = + spherePath.CellCandidates.OrderedIds.ToImmutableArray(); + PhysicsShadowCommitAction shadowAction = hasPhysicsBsp + ? PhysicsShadowCommitAction.Recalculate + : transitionCells.Length != 0 + ? PhysicsShadowCommitAction.Replace + : PhysicsShadowCommitAction.Preserve; + return new PhysicsSetPositionResult( + PhysicsSetPositionError.Ok, + PhysicsResidenceDisposition.Committed, + spherePath.CurPos, + // CheckPositionInternal mutates only origin in no-slide mode; + // SetPosition retains the requested frame orientation. + request.Orientation, + spherePath.CurCellId, + resultLocal, + inContact, + onWalkable, + collision.ContactPlane, + collision.ContactPlaneCellId, + collision.ContactPlaneIsWater, + collision.SlidingNormalValid, + collision.SlidingNormal, + collision.CollisionNormalValid, + collision.CollisionNormal, + collision.FramesStationaryFall, + collision.CollidedWithEnvironment, + collisionHandlerResult, + CellChanged: request.CurrentCellId is null + || request.CurrentCellId.Value != spherePath.CurCellId, + ShadowAction: shadowAction, + CrossCellIds: shadowAction == PhysicsShadowCommitAction.Replace + ? transitionCells + : ImmutableArray.Empty, + CollidedObjectIds: + collision.CollideObjectGuids.ToImmutableArray()); + } + + private static PhysicsSetPositionResult ErrorResult( + in PhysicsSetPositionRequest request, + PhysicsSetPositionError error) => new( + error, + PhysicsResidenceDisposition.Unchanged, + request.Position, + request.Orientation, + request.CellId, + request.CellLocalPosition, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: ImmutableArray.Empty); + + internal static bool AcceptNoSlidePlacement( + Vector3 resolvedPosition, + Vector3 requestedPosition, + uint resolvedCellId, + uint adjustedCellId) + { + Vector3 displacement = resolvedPosition - requestedPosition; + return displacement.X <= 0.0500000007f + && displacement.Y <= 0.0500000007f + && resolvedCellId == adjustedCellId; + } + /// /// #111: the walkable floor Z of 's PHYSICS /// polygons under the world XY, nearest to . diff --git a/src/AcDream.Core/Physics/PhysicsSetPosition.cs b/src/AcDream.Core/Physics/PhysicsSetPosition.cs new file mode 100644 index 00000000..1ac3de2c --- /dev/null +++ b/src/AcDream.Core/Physics/PhysicsSetPosition.cs @@ -0,0 +1,155 @@ +using System.Collections.Immutable; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Retail SetPositionError (acclient.h, enum 491). This is +/// deliberately independent of : +/// losing the destination cell is a successful SetPosition operation whose +/// residence is deferred, not a placement error. +/// +internal enum PhysicsSetPositionError +{ + Ok = 0, + GeneralFailure = 1, + NoValidPosition = 2, + NoCell = 3, + Collided = 4, + InvalidArguments = 0x100, +} + +internal enum PhysicsResidenceDisposition +{ + Committed, + DeferredCell, + Unchanged, +} + +/// +/// Shadow-list operation performed by retail's SetPosition commit tail. +/// Recalculate delegates to the canonical live shadow-shape owner (the +/// PhysicsBSP/bounding-box path cannot be reconstructed from placement +/// spheres); Replace consumes ; +/// Preserve intentionally leaves the existing list untouched. +/// +internal enum PhysicsShadowCommitAction +{ + None, + Recalculate, + Replace, + Preserve, +} + +internal readonly record struct PhysicsSetPositionCollisionReport( + bool ContactPlaneValid, + Plane ContactPlane, + uint ContactPlaneCellId, + bool ContactPlaneIsWater, + bool LastKnownContactPlaneValid, + Plane LastKnownContactPlane, + uint LastKnownContactPlaneCellId, + bool LastKnownContactPlaneIsWater, + bool SlidingNormalValid, + Vector3 SlidingNormal, + bool CollisionNormalValid, + Vector3 CollisionNormal, + bool CollidedWithEnvironment, + int FramesStationaryFall, + Vector3 AdjustOffset, + uint? LastCollidedObjectId, + ImmutableArray CollidedObjectIds); + +[Flags] +internal enum PhysicsSetPositionFlags : uint +{ + None = 0, + Placement = 0x001, + Teleport = 0x002, + Restore = 0x004, + Slide = 0x010, + DoNotCreateCells = 0x020, + Scatter = 0x100, + RandomScatter = 0x200, + Line = 0x400, + SendPositionEvent = 0x1000, +} + +/// +/// The three retail weenie classifications which bypass placement collision +/// in CPhysicsObj::SetPositionInternal after AdjustPosition succeeds. +/// Runtime derives this from the canonical object record; callers cannot use a +/// free boolean to force ordinary objects through geometry. +/// +internal enum PhysicsPlacementClass +{ + Ordinary, + Hook, + Storage, + Corpse, +} + +/// +/// Complete immutable input to retail CPhysicsObj::SetPosition. World +/// position feeds acdream's flat collision representation; cell-local position +/// is retail's Position.frame.origin and is the only input to +/// LandDefs::adjust_to_outside. +/// +internal readonly record struct PhysicsSetPositionRequest( + Vector3 Position, + Quaternion Orientation, + uint CellId, + Vector3 CellLocalPosition, + ImmutableArray Spheres, + float Scale, + float StepUpHeight, + float StepDownHeight, + PhysicsStateFlags MoverPhysicsState = PhysicsStateFlags.None, + ObjectInfoState MoverFlags = ObjectInfoState.None, + uint MovingEntityId = 0u, + PhysicsPlacementClass PlacementClass = PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags Flags = PhysicsSetPositionFlags.Placement, + Vector3 Line = default, + float ScatterRadiusX = 0f, + float ScatterRadiusY = 0f, + uint ScatterAttempts = 0u, + // Null models retail's distinct `this->cell == nullptr` state. The + // retained Position cell id may equal the destination while the object is + // still in the lost-cell list; that wake must change_cell and reflood. + uint? CurrentCellId = null); + +/// +/// Immutable commit packet produced by the pure placement transaction. Runtime +/// installs this packet atomically into the canonical body/spatial owner before +/// publishing one presentation delta. +/// +internal readonly record struct PhysicsSetPositionResult( + PhysicsSetPositionError Error, + PhysicsResidenceDisposition Residence, + Vector3 Position, + Quaternion Orientation, + uint CellId, + Vector3 CellLocalPosition, + bool InContact = false, + bool OnWalkable = false, + Plane ContactPlane = default, + uint ContactPlaneCellId = 0u, + bool ContactPlaneIsWater = false, + bool SlidingNormalValid = false, + Vector3 SlidingNormal = default, + bool CollisionNormalValid = false, + Vector3 CollisionNormal = default, + int FramesStationaryFall = 0, + bool CollidedWithEnvironment = false, + bool CollisionHandlerResult = false, + bool CellChanged = false, + PhysicsShadowCommitAction ShadowAction = PhysicsShadowCommitAction.None, + ImmutableArray CrossCellIds = default, + ImmutableArray CollidedObjectIds = default) +{ + internal bool IsSuccessful => Error == PhysicsSetPositionError.Ok; + internal bool IsCommitted => + IsSuccessful && Residence == PhysicsResidenceDisposition.Committed; + internal bool IsDeferred => + IsSuccessful && Residence == PhysicsResidenceDisposition.DeferredCell; +} diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 1c459731..4fcac576 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1575,6 +1575,79 @@ public sealed class Transition return transitionState == TransitionState.OK; } + /// + /// Retail CTransition::find_valid_position (0x0050C310). Placement + /// insertions use the complete outer find_placement_position + /// transaction; ordinary movement uses the transitional sweep. + /// + internal bool FindValidPosition(PhysicsEngine engine) => + SpherePath.InsertType == InsertType.Transition + ? FindTransitionalPosition(engine) + : FindPlacementPosition(engine); + + /// + /// Retail CTransition::find_placement_position (0x0050C170): + /// INITIAL_PLACEMENT insertion, other-cell validation, the inner compass + /// search, optional placement step-down, and final validation. + /// + internal bool FindPlacementPosition(PhysicsEngine engine) + { + SpherePath sp = SpherePath; + sp.SetCheckPos(sp.CurPos, sp.CurCellId); + sp.InsertType = InsertType.InitialPlacement; + + TransitionState initial = ValidatePlacement( + engine, + InitialPlacementInsert(engine), + retryPlacement: true); + if (initial != TransitionState.OK) + return false; + + sp.InsertType = InsertType.Placement; + if (!FindPlacementPos(engine)) + return false; + + if (ObjectInfo.StepDown) + { + const float placementWalkableAllowance = 0.0871556997f; + float stepDownHeight = ObjectInfo.StepDownHeight; + sp.WalkableAllowance = placementWalkableAllowance; + sp.SaveCheckPos(); + InsertType savedInsert = sp.InsertType; + sp.InsertType = InsertType.Transition; + + (float probeHeight, int probeCount) = GetStepDownProbePlan( + sp.NumSphere, + sp.GlobalSphere[0].Radius, + stepDownHeight); + bool stepped = DoStepDown( + probeHeight, + placementWalkableAllowance, + engine); + if (!stepped && probeCount > 1) + { + stepped = DoStepDown( + probeHeight, + placementWalkableAllowance, + engine); + } + if (!stepped) + { + sp.RestoreCheckPos(); + CollisionInfo.ContactPlaneValid = false; + CollisionInfo.ContactPlaneIsWater = false; + } + sp.InsertType = savedInsert; + sp.ClearWalkable(); + } + + return ValidatePlacement( + engine, + TransitionState.OK, + retryPlacement: true) + == TransitionState.OK; + } + /// /// Retail CTransition::find_placement_pos (0x0050BA50). /// Tests the requested position first, then searches concentric rings up @@ -1613,15 +1686,19 @@ public sealed class Transition sphereRadius = 0.48f; } - float stepCountExact = 4f / sphereRadius; + double stepCountExact = 4d / (double)sphereRadius; if (fakeSphere) - stepCountExact *= 0.5f; - if (stepCountExact <= 1f) + stepCountExact *= 0.5d; + if (stepCountExact <= 1d) return false; - int stepCount = (int)MathF.Ceiling(stepCountExact); - float distancePerStep = adjustRadius / stepCount; - float radiansPerStep = MathF.PI * distancePerStep / sphereRadius; + int stepCount = (int)Math.Ceiling(stepCountExact); + float distancePerStep = (float)((double)adjustRadius / stepCount); + // Retail stores the literal 3.14159989f here; do not substitute the + // BCL's more precise PI because it changes late compass samples. + float radiansPerStep = (float)( + ((double)distancePerStep / sphereRadius) + * 3.14159989f); float totalDistance = 0f; float totalRadians = 0f; @@ -1630,23 +1707,25 @@ public sealed class Transition totalDistance += distancePerStep; totalRadians += radiansPerStep; - int sampleCount = (int)MathF.Ceiling(totalRadians) * 2; - float headingStep = 360f / sampleCount; + int sampleCount = (int)Math.Ceiling((double)totalRadians) * 2; + float headingStep = (float)(360d / sampleCount); for (int sample = 0; sample < sampleCount; sample++) { sp.SetCheckPos(sp.CurPos, sp.CurCellId); // Frame::set_heading/get_vector_heading: 0 degrees is +Y, - // 90 degrees is +X in AC's compass convention. - float headingRadians = headingStep * sample * (MathF.PI / 180f); - var offset = new Vector3( - MathF.Sin(headingRadians) * totalDistance, - MathF.Cos(headingRadians) * totalDistance, - 0f); + // 90 degrees is +X in AC's compass convention. Retail promotes + // the float heading to x87 precision, multiplies by this exact + // degree-to-radian constant, then rounds sin/cos back to float. + float heading = headingStep * sample; + Vector3 offset = GetPlacementCompassOffset( + heading, + totalDistance); sp.GlobalOffset = AdjustOffset(offset); - if (sp.GlobalOffset.Length() < PhysicsGlobals.EPSILON) + if (sp.GlobalOffset.LengthSquared() + < PhysicsGlobals.EpsilonSq) continue; sp.AddOffsetToCheckPos(sp.GlobalOffset); @@ -1664,7 +1743,20 @@ public sealed class Transition return false; } - private TransitionState ValidatePlacementTransition(TransitionState transitionState) + internal static Vector3 GetPlacementCompassOffset( + float headingDegrees, + float distance) + { + const double DegreesToRadians = 0.017453292519943295d; + double radians = (double)headingDegrees * DegreesToRadians; + return new Vector3( + (float)Math.Sin(radians) * distance, + (float)Math.Cos(radians) * distance, + 0f); + } + + private TransitionState ValidatePlacementTransition( + TransitionState transitionState) { var sp = SpherePath; if (sp.CheckCellId == 0) @@ -1682,7 +1774,9 @@ public sealed class Transition sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius; } } - else if (sp.PlacementAllowsSliding) + else if (transitionState > TransitionState.OK + && transitionState <= TransitionState.Slid + && sp.PlacementAllowsSliding) { // COLLISIONINFO::init at retail 0x0050B052. Placement probes are // independent; a failed compass sample must not bias the next one. @@ -1692,6 +1786,58 @@ public sealed class Transition return transitionState; } + internal TransitionState ValidatePlacementTransitionForTest( + TransitionState transitionState) => + ValidatePlacementTransition(transitionState); + + /// + /// Retail CTransition::validate_placement (0x0050B210), used only + /// by the outer placement transaction. Adjusted/Slid receives one + /// placement_insert retry when requested; Collided never retries and this + /// validator never clears CollisionInfo. + /// + private TransitionState ValidatePlacement( + PhysicsEngine engine, + TransitionState transitionState, + bool retryPlacement) + { + SpherePath sp = SpherePath; + if (sp.CheckCellId == 0u) + return TransitionState.Collided; + + if (transitionState == TransitionState.OK) + { + sp.CurPos = sp.CheckPos; + sp.CurCellId = sp.CheckCellId; + sp.CurOrientation = sp.CheckOrientation; + for (int i = 0; i < sp.NumSphere; i++) + { + sp.GlobalCurrCenter[i].Origin = + Vector3.Transform( + sp.LocalSphere[i].Origin, + sp.CurOrientation) + + sp.CurPos; + sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius; + } + } + else if ((transitionState is TransitionState.Adjusted + or TransitionState.Slid) + && retryPlacement) + { + return ValidatePlacement( + engine, + PlacementInsert(engine), + retryPlacement: false); + } + return transitionState; + } + + internal TransitionState ValidatePlacementForTest( + PhysicsEngine engine, + TransitionState transitionState, + bool retryPlacement) => + ValidatePlacement(engine, transitionState, retryPlacement); + // ----------------------------------------------------------------------- // Per-step collision check // ----------------------------------------------------------------------- @@ -1749,10 +1895,10 @@ public sealed class Transition float diameter = sphereRadius * 2f; float probeHeight = requestedHeight; - if (numSpheres < 2 && diameter < probeHeight) + if (numSpheres < 2 && diameter <= probeHeight) probeHeight = sphereRadius * 0.5f; - if (diameter >= probeHeight) + if (diameter > probeHeight) return (probeHeight, 1); return (probeHeight * 0.5f, 2); @@ -2176,6 +2322,54 @@ public sealed class Transition return state; } + /// + /// The initial half of retail find_placement_position. This is + /// deliberately not : initial placement + /// performs one primary insert and, only on OK, the other-cell pass. It + /// never enters transitional collide/step/edge response branches. + /// + private TransitionState InitialPlacementInsert(PhysicsEngine engine) + { + SpherePath sp = SpherePath; + if (sp.CheckCellId == 0u) + return TransitionState.Collided; + + TransitionState result = InsertIntoCell( + engine, + sp.CheckCellId, + numAttempts: 3); + if (result == TransitionState.OK) + { + result = RunCheckOtherCellsAndAdvance( + engine, + sp.GlobalSphere[0].Origin, + sp.GlobalSphere[0].Radius); + } + return result; + } + + /// + /// Retail CTransition::placement_insert (0x0050B1D0), used by + /// validate_placement for exactly one Adjusted/Slid retry. + /// + private TransitionState PlacementInsert(PhysicsEngine engine) + { + SpherePath sp = SpherePath; + if (sp.CheckCellId == 0u) + return TransitionState.Collided; + + TransitionState result = InsertIntoCell( + engine, + sp.CheckCellId, + numAttempts: 3); + return result == TransitionState.OK + ? RunCheckOtherCellsAndAdvance( + engine, + sp.GlobalSphere[0].Origin, + sp.GlobalSphere[0].Radius) + : result; + } + /// /// Primary-cell virtual find_collisions composition. A non-OK /// response terminates this pass, so an inner retry always restarts from diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs new file mode 100644 index 00000000..6b88f5ef --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs @@ -0,0 +1,1041 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Named-retail oracle fixtures for CPhysicsObj::SetPosition 0x005160C0, +/// SetPositionInternal 0x00515BD0, AdjustPosition 0x00511D80, and +/// CTransition::find_placement_position 0x0050C170. +/// +public sealed class PhysicsSetPositionTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = Landblock | 0x0001u; + + [Fact] + public void SetPositionErrorValues_MatchRetailHeader() + { + Assert.Equal(0, (int)PhysicsSetPositionError.Ok); + Assert.Equal(1, (int)PhysicsSetPositionError.GeneralFailure); + Assert.Equal(2, (int)PhysicsSetPositionError.NoValidPosition); + Assert.Equal(3, (int)PhysicsSetPositionError.NoCell); + Assert.Equal(4, (int)PhysicsSetPositionError.Collided); + Assert.Equal(0x100, (int)PhysicsSetPositionError.InvalidArguments); + } + + [Fact] + public void MissingOutdoorCell_AdjustsFrameThenDefersWithOk() + { + var engine = new PhysicsEngine(); + var local = new Vector3(193f, 12f, 7f); + uint expectedCell = Cell; + Vector3 expectedLocal = local; + Assert.True(LandDefs.AdjustToOutside( + ref expectedCell, + ref expectedLocal)); + + PhysicsSetPositionResult result = engine.SetPosition( + Request(Cell, local, position: new Vector3(193f, 12f, 7f))); + + Assert.Equal(PhysicsSetPositionError.Ok, result.Error); + Assert.Equal( + PhysicsResidenceDisposition.DeferredCell, + result.Residence); + Assert.Equal(expectedCell, result.CellId); + Assert.Equal(expectedLocal, result.CellLocalPosition); + Assert.Equal(new Vector3(193f, 12f, 7f), result.Position); + } + + [Fact] + public void ResidentCrossLandblockPlacement_RebasesLocalFrameAndPreservesWorldPosition() + { + PhysicsEngine engine = FlatEngine(); + const uint destinationLandblock = 0xAAB40000u; + AddFlatLandblock(engine, destinationLandblock, worldOffsetX: 192f); + var world = new Vector3(193f, 12f, 7f); + + PhysicsSetPositionResult result = engine.SetPosition( + Request(Cell, world, world) with + { + MoverPhysicsState = PhysicsStateFlags.Missile, + }); + + Assert.True(result.IsCommitted); + Assert.Equal(world, result.Position); + Assert.Equal(destinationLandblock | 0x0001u, result.CellId); + Assert.Equal(new Vector3(1f, 12f, 7f), result.CellLocalPosition); + } + + [Fact] + public void OutdoorMapEdgeFailure_ZeroesCellAndDefersExactFrame() + { + var engine = new PhysicsEngine(); + const uint southWestCell = 0x00000001u; + var local = new Vector3(-1f, 12f, 7f); + + PhysicsSetPositionResult result = engine.SetPosition( + Request(southWestCell, local, local)); + + Assert.True(result.IsDeferred); + Assert.Equal(0u, result.CellId); + Assert.Equal(local, result.CellLocalPosition); + Assert.Equal(local, result.Position); + } + + [Fact] + public void OutdoorBlockSentinel_IsAnIndoorShapedLostCellSentinel() + { + var engine = new PhysicsEngine(); + const uint sentinel = Landblock | 0xFFFFu; + var local = new Vector3(25f, 49f, 7f); + PhysicsSetPositionResult result = engine.SetPosition( + Request(sentinel, local, local)); + + Assert.True(result.IsDeferred); + Assert.Equal(sentinel, result.CellId); + Assert.Equal(local, result.CellLocalPosition); + } + + [Fact] + public void InvalidLowCellId_ParksWithoutRewritingAuthoritativeFrame() + { + var engine = new PhysicsEngine(); + const uint invalid = Landblock | 0x0050u; + var local = new Vector3(11f, 13f, 17f); + + PhysicsSetPositionResult result = engine.SetPosition( + Request(invalid, local, local)); + + Assert.True(result.IsDeferred); + Assert.Equal(PhysicsSetPositionError.Ok, result.Error); + Assert.Equal(invalid, result.CellId); + Assert.Equal(local, result.CellLocalPosition); + } + + [Fact] + public void MissingIndoorCell_ParksExactCellAndFrame() + { + var engine = new PhysicsEngine(); + const uint indoor = Landblock | 0x0107u; + var local = new Vector3(31f, -4f, 9f); + + PhysicsSetPositionResult result = engine.SetPosition( + Request(indoor, local, new Vector3(31f, -4f, 9f))); + + Assert.True(result.IsDeferred); + Assert.Equal(indoor, result.CellId); + Assert.Equal(local, result.CellLocalPosition); + } + + [Fact] + public void EmptySphereList_UsesRetailDummyAtScaleOne() + { + PhysicsEngine engine = FlatEngine(); + Vector3 capturedOrigin = default; + float capturedRadius = 0f; + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase == TransitionCellCollisionPhase.Environment) + { + capturedOrigin = transition.SpherePath.LocalSphere[0].Origin; + capturedRadius = transition.SpherePath.LocalSphere[0].Radius; + } + return observed; + }; + + _ = engine.SetPosition(Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f), + scale: -7f)); + + Assert.Equal(new Vector3(0f, 0f, 0.1f), capturedOrigin); + Assert.Equal(0.1f, capturedRadius); + } + + [Fact] + public void AuthoredSphereList_CapsAtTwoAndPreservesNonPositiveScale() + { + PhysicsEngine engine = FlatEngine(); + int count = 0; + Vector3 firstOrigin = default; + float firstRadius = 0f; + Vector3 secondOrigin = default; + float secondRadius = 0f; + engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase == TransitionCellCollisionPhase.Environment) + { + count = transition.SpherePath.NumSphere; + firstOrigin = transition.SpherePath.LocalSphere[0].Origin; + firstRadius = transition.SpherePath.LocalSphere[0].Radius; + secondOrigin = transition.SpherePath.LocalSphere[1].Origin; + secondRadius = transition.SpherePath.LocalSphere[1].Radius; + } + return TransitionState.Collided; + }; + ImmutableArray spheres = ImmutableArray.Create( + new FlatCollisionSphere(new Vector3(1f, 2f, 3f), 4f), + new FlatCollisionSphere(new Vector3(5f, 6f, 7f), 8f), + new FlatCollisionSphere(new Vector3(9f), 10f)); + + _ = engine.SetPosition(Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f), + spheres, + scale: -2f)); + + Assert.Equal(2, count); + Assert.Equal(new Vector3(-2f, -4f, -6f), firstOrigin); + Assert.Equal(-8f, firstRadius); + Assert.Equal(new Vector3(-10f, -12f, -14f), secondOrigin); + Assert.Equal(-16f, secondRadius); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public void StepDown_IsDisabledOnlyForMissiles( + bool missile, + bool expectedStepDown) + { + PhysicsEngine engine = FlatEngine(); + bool? captured = null; + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase == TransitionCellCollisionPhase.Environment) + captured = transition.ObjectInfo.StepDown; + return observed; + }; + PhysicsSetPositionRequest request = Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + MoverPhysicsState = missile + ? PhysicsStateFlags.Missile + : PhysicsStateFlags.Gravity, + }; + + _ = engine.SetPosition(request); + + Assert.Equal(expectedStepDown, captured); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, true)] + public void ObjectInfoEthereal_IsSeededFromPhysicsState( + bool ethereal, + bool expected) + { + PhysicsEngine engine = FlatEngine(); + bool? captured = null; + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase == TransitionCellCollisionPhase.Environment) + captured = transition.ObjectInfo.Ethereal; + return observed; + }; + + _ = engine.SetPosition(Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + MoverPhysicsState = ethereal + ? PhysicsStateFlags.Ethereal + : PhysicsStateFlags.Gravity, + }); + + Assert.Equal(expected, captured); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, true)] + public void PathClipped_ComesOnlyFromExplicitObjectInfoFlags( + bool explicitPathClipped, + bool expected) + { + PhysicsEngine engine = FlatEngine(); + bool? captured = null; + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase == TransitionCellCollisionPhase.Environment) + captured = transition.ObjectInfo.PathClipped; + return observed; + }; + + _ = engine.SetPosition(Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + MoverPhysicsState = PhysicsStateFlags.Missile, + MoverFlags = explicitPathClipped + ? ObjectInfoState.PathClipped + : ObjectInfoState.None, + }); + + Assert.Equal(expected, captured); + } + + [Fact] + public void DoNotCreateCells_MissingDestinationStillDefersWithoutCreation() + { + var engine = new PhysicsEngine(); + + PhysicsSetPositionResult result = engine.SetPosition(Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + Flags = PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide + | PhysicsSetPositionFlags.DoNotCreateCells, + }); + + Assert.True(result.IsDeferred); + Assert.Equal(0, engine.LandblockCount); + } + + [Fact] + public void SuccessfulPlacement_RetainsRequestedOrientation() + { + PhysicsEngine engine = FlatEngine(); + PhysicsSetPositionRequest request = Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)); + + PhysicsSetPositionResult result = engine.SetPosition(request); + + Assert.True(result.IsCommitted); + Assert.Equal(request.Orientation, result.Orientation); + } + + [Fact] + public void OuterPlacementStartsWithInitialPlacementInsert() + { + PhysicsEngine engine = FlatEngine(); + InsertType? captured = null; + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase == TransitionCellCollisionPhase.Environment + && captured is null) + { + captured = transition.SpherePath.InsertType; + } + return observed; + }; + + _ = engine.SetPosition(Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f))); + + Assert.Equal(InsertType.InitialPlacement, captured); + } + + [Theory] + [InlineData(1, 0.5f, 1.0f, 0.25f, 1)] + [InlineData(2, 0.5f, 1.0f, 0.5f, 2)] + [InlineData(1, 0.5f, 0.4f, 0.4f, 1)] + public void StepDownProbePlan_PreservesRetailEqualityBoundaries( + int sphereCount, + float radius, + float requested, + float expectedHeight, + int expectedCount) + { + Assert.Equal( + (expectedHeight, expectedCount), + Transition.GetStepDownProbePlan( + sphereCount, + radius, + requested)); + } + + [Theory] + [InlineData(-100f, 0f, 7f, 7u, true)] + [InlineData(0.0500000007f, 0.0500000007f, 500f, 7u, true)] + [InlineData(0.0501f, 0f, 0f, 7u, false)] + [InlineData(0f, 0.0501f, 0f, 7u, false)] + [InlineData(0f, 0f, 0f, 8u, false)] + public void NoSlideAcceptance_IsSignedXYSameCellAndIgnoresZ( + float dx, + float dy, + float dz, + uint resolvedCell, + bool expected) + { + Assert.Equal( + expected, + PhysicsEngine.AcceptNoSlidePlacement( + new Vector3(dx, dy, dz), + Vector3.Zero, + resolvedCell, + adjustedCellId: 7u)); + } + + [Fact] + public void PlacementCompass_LateSampleMatchesRetailFloatBits() + { + PhysicsEngine engine = FlatEngine(); + var transition = new Transition(); + transition.SpherePath.InitPath( + Vector3.Zero, + Vector3.Zero, + Cell, + sphereRadius: 0.48f); + transition.SpherePath.InsertType = InsertType.Placement; + transition.SpherePath.PlacementAllowsSliding = true; + Vector3 lastCheck = default; + engine.TransitionCellCollisionTestHook = + (observed, phase, _, _) => + { + if (phase == TransitionCellCollisionPhase.Environment) + lastCheck = observed.SpherePath.CheckPos; + return TransitionState.Collided; + }; + + Assert.False(transition.FindPlacementPos(engine)); + + Assert.Equal(unchecked((int)0xBEEDC24F), + BitConverter.SingleToInt32Bits(lastCheck.X)); + Assert.Equal(unchecked((int)0x407E44DE), + BitConverter.SingleToInt32Bits(lastCheck.Y)); + } + + [Theory] + [InlineData(TransitionState.Collided)] + [InlineData(TransitionState.Adjusted)] + [InlineData(TransitionState.Slid)] + public void InnerPlacementValidator_ResetsCollisionInfoForEveryFailure( + TransitionState state) + { + Transition transition = PlacementTransition(); + transition.SpherePath.PlacementAllowsSliding = true; + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + Cell); + transition.CollisionInfo.SetSlidingNormal(Vector3.UnitX); + + TransitionState result = + transition.ValidatePlacementTransitionForTest(state); + + Assert.Equal(state, result); + Assert.False(transition.CollisionInfo.ContactPlaneValid); + Assert.False(transition.CollisionInfo.SlidingNormalValid); + } + + [Fact] + public void InnerPlacementValidator_DoesNotResetWhenSlidingDisabled() + { + Transition transition = PlacementTransition(); + transition.SpherePath.PlacementAllowsSliding = false; + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + Cell); + + _ = transition.ValidatePlacementTransitionForTest( + TransitionState.Collided); + + Assert.True(transition.CollisionInfo.ContactPlaneValid); + } + + [Fact] + public void OuterPlacementValidator_CollidedNeverRetriesOrResets() + { + PhysicsEngine engine = FlatEngine(); + int passes = 0; + engine.TransitionCellCollisionTestHook = + (_, _, _, observed) => + { + passes++; + return observed; + }; + Transition transition = PlacementTransition(); + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + Cell); + + TransitionState result = transition.ValidatePlacementForTest( + engine, + TransitionState.Collided, + retryPlacement: true); + + Assert.Equal(TransitionState.Collided, result); + Assert.Equal(0, passes); + Assert.True(transition.CollisionInfo.ContactPlaneValid); + } + + [Theory] + [InlineData(TransitionState.Adjusted)] + [InlineData(TransitionState.Slid)] + public void OuterPlacementValidator_AdjustedAndSlidRetryExactlyOnce( + TransitionState state) + { + PhysicsEngine engine = FlatEngine(); + int environmentPasses = 0; + engine.TransitionCellCollisionTestHook = + (_, phase, _, _) => + { + if (phase == TransitionCellCollisionPhase.Environment) + environmentPasses++; + return TransitionState.OK; + }; + Transition transition = PlacementTransition(); + + TransitionState result = transition.ValidatePlacementForTest( + engine, + state, + retryPlacement: true); + + Assert.Equal(TransitionState.OK, result); + Assert.Equal(1, environmentPasses); + } + + [Theory] + [InlineData(TransitionState.Adjusted)] + [InlineData(TransitionState.Slid)] + public void OuterPlacementValidator_RetryFalseReturnsOriginalState( + TransitionState state) + { + PhysicsEngine engine = FlatEngine(); + int passes = 0; + engine.TransitionCellCollisionTestHook = + (_, _, _, observed) => + { + passes++; + return observed; + }; + Transition transition = PlacementTransition(); + + TransitionState result = transition.ValidatePlacementForTest( + engine, + state, + retryPlacement: false); + + Assert.Equal(state, result); + Assert.Equal(0, passes); + } + + [Theory] + [InlineData(false, (int)PhysicsSetPositionError.NoValidPosition)] + [InlineData(true, (int)PhysicsSetPositionError.Collided)] + public void FailedCheck_MapsCollisionHandlerResultToRetailError( + bool handled, + int expectedValue) + { + var expected = (PhysicsSetPositionError)expectedValue; + PhysicsEngine engine = FlatEngine(); + engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase == TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollidedWithEnvironment = true; + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, -3f), + Cell, + isWater: true); + transition.CollisionInfo.SetSlidingNormal(Vector3.UnitX); + transition.CollisionInfo.SetCollisionNormal(-Vector3.UnitY); + transition.CollisionInfo.FramesStationaryFall = 2; + transition.CollisionInfo.AdjustOffset = new Vector3(1f, 2f, 3f); + transition.CollisionInfo.CollideObjectGuids.Add(0x12345678u); + transition.CollisionInfo.LastCollidedObjectGuid = 0x12345678u; + } + return TransitionState.Collided; + }; + PhysicsSetPositionCollisionReport observedReport = default; + + PhysicsSetPositionResult result = engine.SetPosition(Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)), report => + { + observedReport = report; + return handled; + }); + + Assert.Equal(expected, result.Error); + Assert.Equal(handled, result.CollisionHandlerResult); + Assert.True(observedReport.CollidedWithEnvironment); + Assert.True(observedReport.ContactPlaneValid); + Assert.Equal(new Plane(Vector3.UnitZ, -3f), observedReport.ContactPlane); + Assert.Equal(Cell, observedReport.ContactPlaneCellId); + Assert.True(observedReport.ContactPlaneIsWater); + Assert.True(observedReport.LastKnownContactPlaneValid); + Assert.Equal( + observedReport.ContactPlane, + observedReport.LastKnownContactPlane); + Assert.Equal(Cell, observedReport.LastKnownContactPlaneCellId); + Assert.True(observedReport.LastKnownContactPlaneIsWater); + Assert.True(observedReport.SlidingNormalValid); + Assert.Equal(Vector3.UnitX, observedReport.SlidingNormal); + Assert.True(observedReport.CollisionNormalValid); + Assert.Equal(-Vector3.UnitY, observedReport.CollisionNormal); + Assert.Equal(2, observedReport.FramesStationaryFall); + Assert.Equal(new Vector3(1f, 2f, 3f), observedReport.AdjustOffset); + Assert.Equal(0x12345678u, observedReport.LastCollidedObjectId); + Assert.Equal( + new[] { 0x12345678u }, + observedReport.CollidedObjectIds.ToArray()); + Assert.True(result.CollidedWithEnvironment); + Assert.True(result.InContact); + Assert.True(result.OnWalkable); + Assert.True(result.ContactPlaneIsWater); + Assert.True(result.SlidingNormalValid); + Assert.True(result.CollisionNormalValid); + Assert.Equal(2, result.FramesStationaryFall); + Assert.Equal( + new[] { 0x12345678u }, + result.CollidedObjectIds.ToArray()); + Assert.Equal(PhysicsResidenceDisposition.Unchanged, result.Residence); + } + + [Theory] + [InlineData((int)PhysicsPlacementClass.Hook)] + [InlineData((int)PhysicsPlacementClass.Storage)] + [InlineData((int)PhysicsPlacementClass.Corpse)] + public void RetailForceClasses_BypassPlacementCollision( + int placementClassValue) + { + var placementClass = (PhysicsPlacementClass)placementClassValue; + PhysicsEngine engine = FlatEngine(); + int collisionPasses = 0; + engine.TransitionCellCollisionTestHook = + (_, _, _, observed) => + { + collisionPasses++; + return observed; + }; + + PhysicsSetPositionResult result = engine.SetPosition( + Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + PlacementClass = placementClass, + }); + + Assert.True(result.IsCommitted); + Assert.Equal(0, collisionPasses); + Assert.True(result.CellChanged); + Assert.Equal( + PhysicsShadowCommitAction.Recalculate, + result.ShadowAction); + Assert.Empty(result.CrossCellIds); + } + + [Fact] + public void OrdinaryObject_CannotRequestForceByFlags() + { + PhysicsEngine engine = FlatEngine(); + int collisionPasses = 0; + engine.TransitionCellCollisionTestHook = + (_, _, _, observed) => + { + collisionPasses++; + return observed; + }; + + PhysicsSetPositionResult result = engine.SetPosition( + Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + PlacementClass = PhysicsPlacementClass.Ordinary, + Flags = (PhysicsSetPositionFlags)uint.MaxValue + & ~PhysicsSetPositionFlags.RandomScatter + & ~PhysicsSetPositionFlags.Scatter, + }); + + Assert.True(result.IsCommitted); + Assert.True(collisionPasses > 0); + } + + [Fact] + public void ForceIntoSameCell_SetsFrameWithoutRecalculatingCrossCells() + { + PhysicsEngine engine = FlatEngine(); + PhysicsSetPositionResult result = engine.SetPosition( + Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + PlacementClass = PhysicsPlacementClass.Corpse, + CurrentCellId = Cell, + }); + + Assert.True(result.IsCommitted); + Assert.False(result.CellChanged); + Assert.Equal(PhysicsShadowCommitAction.None, result.ShadowAction); + Assert.Empty(result.CrossCellIds); + } + + [Fact] + public void ForceIntoChangedCell_RequestsCanonicalShadowRecalculation() + { + PhysicsEngine engine = FlatEngine(); + var position = new Vector3(23.8f, 12f, 2.5f); + ImmutableArray spheres = ImmutableArray.Create( + new FlatCollisionSphere(Vector3.Zero, 0.5f)); + + PhysicsSetPositionResult result = engine.SetPosition( + Request(Cell, position, position, spheres) with + { + PlacementClass = PhysicsPlacementClass.Hook, + CurrentCellId = Landblock | 0x0040u, + }); + + Assert.True(result.IsCommitted); + Assert.True(result.CellChanged); + Assert.Equal( + PhysicsShadowCommitAction.Recalculate, + result.ShadowAction); + Assert.Empty(result.CrossCellIds); + } + + [Fact] + public void ForceIntoRetainedCellWithNullCurrentPointer_RefloodsShadows() + { + PhysicsEngine engine = FlatEngine(); + + PhysicsSetPositionResult result = engine.SetPosition( + Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + PlacementClass = PhysicsPlacementClass.Corpse, + CurrentCellId = null, + }); + + Assert.True(result.IsCommitted); + Assert.True(result.CellChanged); + Assert.Equal( + PhysicsShadowCommitAction.Recalculate, + result.ShadowAction); + } + + [Fact] + public void OrdinaryPhysicsBspPlacement_RequestsCanonicalShadowRecalculation() + { + PhysicsEngine engine = FlatEngine(); + + PhysicsSetPositionResult result = engine.SetPosition( + Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + MoverPhysicsState = PhysicsStateFlags.HasPhysicsBsp + | PhysicsStateFlags.Missile, + }); + + Assert.True(result.IsCommitted); + Assert.Equal( + PhysicsShadowCommitAction.Recalculate, + result.ShadowAction); + Assert.Empty(result.CrossCellIds); + } + + [Fact] + public void OrdinarySpherePlacement_ReplacesShadowsWithTransitionCells() + { + PhysicsEngine engine = FlatEngine(); + + PhysicsSetPositionResult result = engine.SetPosition( + Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + MoverPhysicsState = PhysicsStateFlags.Missile, + }); + + Assert.True(result.IsCommitted); + Assert.Equal(PhysicsShadowCommitAction.Replace, result.ShadowAction); + Assert.Contains(Cell, result.CrossCellIds); + } + + [Fact] + public void OrdinarySpherePlacementWithNoTransitionCells_PreservesShadows() + { + // The Core-only/no-cell-graph fixture is the real empty-array shape: + // terrain is resident, but async cell topology has not been published. + PhysicsEngine engine = FlatEngine(withDataCache: false); + + PhysicsSetPositionResult result = engine.SetPosition( + Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + MoverPhysicsState = PhysicsStateFlags.Missile, + }); + + Assert.True(result.IsCommitted); + Assert.Equal(PhysicsShadowCommitAction.Preserve, result.ShadowAction); + Assert.Empty(result.CrossCellIds); + } + + [Fact] + public void RandomScatter_IsDirectAndStopsOnDeferredOk() + { + var engine = new PhysicsEngine(); + var random = new Queue(new[] { 1d, 0d, 0.25d, 0.75d }); + int draws = 0; + engine.SetPositionRandomUnit = () => + { + draws++; + return random.Dequeue(); + }; + PhysicsSetPositionRequest request = Request( + Cell, + new Vector3(10f, 10f, 3f), + new Vector3(10f, 10f, 3f)) with + { + Flags = PhysicsSetPositionFlags.RandomScatter, + ScatterRadiusX = 4f, + ScatterRadiusY = 2f, + ScatterAttempts = 2u, + }; + + PhysicsSetPositionResult result = engine.SetPosition(request); + + Assert.True(result.IsDeferred); + Assert.Equal(2, draws); + Assert.Equal(new Vector3(14f, 8f, 3f), result.Position); + } + + [Fact] + public void ScatterFallback_RunsOnlyAfterNormalError_AndUsesExactAttempts() + { + PhysicsEngine engine = FlatEngine(); + int collisionPasses = 0; + engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase == TransitionCellCollisionPhase.Environment) + { + collisionPasses++; + transition.CollisionInfo.CollidedWithEnvironment = true; + } + return TransitionState.Collided; + }; + var random = new Queue(new[] { 0d, 0d, 1d, 1d }); + int draws = 0; + engine.SetPositionRandomUnit = () => + { + draws++; + return random.Dequeue(); + }; + PhysicsSetPositionRequest request = Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + Flags = PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Scatter, + ScatterRadiusX = 1f, + ScatterRadiusY = 1f, + ScatterAttempts = 2u, + }; + + PhysicsSetPositionResult result = engine.SetPosition(request); + + Assert.Equal(PhysicsSetPositionError.NoValidPosition, result.Error); + Assert.Equal(3, collisionPasses); + Assert.Equal(4, draws); + Assert.Equal(new Vector3(11f, 11f, 10f), result.Position); + } + + [Fact] + public void Scatter_ReusesOneTransitionAndFailedInnerProbeCannotLeakIntoSuccess() + { + PhysicsEngine engine = FlatEngine(); + int placementPasses = 0; + bool injectedFailure = false; + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase != TransitionCellCollisionPhase.Environment) + return observed; + + if (transition.SpherePath.InsertType == InsertType.Placement) + { + placementPasses++; + // Attempt 1 reaches the inner placement probe and fails. + // The exact inner validator clears this collision scratch. + if (!injectedFailure) + { + injectedFailure = true; + transition.CollisionInfo.CollidedWithEnvironment = true; + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + Cell); + transition.CollisionInfo.SetSlidingNormal(Vector3.UnitX); + return TransitionState.Collided; + } + } + return TransitionState.OK; + }; + engine.SetPositionRandomUnit = () => 0.5d; + + PhysicsSetPositionResult result = engine.SetPosition( + Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)) with + { + Flags = PhysicsSetPositionFlags.RandomScatter + | PhysicsSetPositionFlags.Slide, + ScatterAttempts = 2u, + MoverPhysicsState = PhysicsStateFlags.Missile, + }); + + Assert.True(result.IsCommitted); + Assert.True(injectedFailure); + Assert.True(placementPasses >= 2); + Assert.False(result.InContact); + Assert.False(result.SlidingNormalValid); + Assert.False(result.CollidedWithEnvironment); + Assert.Empty(result.CollidedObjectIds); + } + + [Fact] + public void EleventhNestedSetPosition_ReturnsGeneralFailureWithoutThrowing() + { + PhysicsEngine engine = FlatEngine(); + PhysicsSetPositionRequest request = Request( + Cell, + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f)); + PhysicsSetPositionResult capacityResult = default; + int callbackDepth = 0; + engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => + { + if (phase != TransitionCellCollisionPhase.Environment) + return observed; + + callbackDepth++; + PhysicsSetPositionResult nested = engine.SetPosition(request); + if (nested.Error == PhysicsSetPositionError.GeneralFailure) + capacityResult = nested; + return TransitionState.Collided; + }; + + PhysicsSetPositionResult outer = engine.SetPosition(request); + + Assert.Equal(PhysicsSetPositionError.NoValidPosition, outer.Error); + Assert.Equal(10, callbackDepth); + Assert.Equal( + PhysicsSetPositionError.GeneralFailure, + capacityResult.Error); + Assert.Equal( + PhysicsResidenceDisposition.Unchanged, + capacityResult.Residence); + } + + [Fact] + public void ZeroScatterAttempts_ReturnsGeneralFailureWithoutRandomDraw() + { + var engine = new PhysicsEngine(); + int draws = 0; + engine.SetPositionRandomUnit = () => + { + draws++; + return 0.5d; + }; + + PhysicsSetPositionResult result = engine.SetPosition( + Request(Cell, Vector3.One, Vector3.One) with + { + Flags = PhysicsSetPositionFlags.RandomScatter, + ScatterAttempts = 0u, + }); + + Assert.Equal( + PhysicsSetPositionError.GeneralFailure, + result.Error); + Assert.Equal(PhysicsResidenceDisposition.Unchanged, result.Residence); + Assert.Equal(0, draws); + } + + private static PhysicsSetPositionRequest Request( + uint cellId, + Vector3 cellLocal, + Vector3 position, + ImmutableArray spheres = default, + float scale = 1f) => new( + position, + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f), + cellId, + cellLocal, + spheres, + scale, + StepUpHeight: 0.4f, + StepDownHeight: 0.4f, + Flags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide); + + private static Transition PlacementTransition() + { + var transition = new Transition(); + transition.SpherePath.InitPath( + new Vector3(10f, 10f, 10f), + new Vector3(10f, 10f, 10f), + Cell, + sphereRadius: 0.48f); + transition.SpherePath.InsertType = InsertType.Placement; + return transition; + } + + private static PhysicsEngine FlatEngine(bool withDataCache = true) + { + var engine = new PhysicsEngine(); + if (withDataCache) + engine.DataCache = new PhysicsDataCache(); + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return engine; + } + + private static void AddFlatLandblock( + PhysicsEngine engine, + uint landblock, + float worldOffsetX = 0f, + float worldOffsetY = 0f) + { + engine.AddLandblock( + landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX, + worldOffsetY); + } +} From 4c02ac425918f0613e0e7af8a399c4bf79273f22 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 22:32:49 +0200 Subject: [PATCH 26/73] feat(runtime): own deferred set-position residence --- docs/architecture/acdream-architecture.md | 3 + .../retail-divergence-register.md | 16 +- .../2026-07-31-canonical-set-position.md | 155 ++ src/AcDream.Core/AcDream.Core.csproj | 3 + .../Physics/CollisionWorldState.cs | 1 + .../Physics/ShadowObjectRegistry.cs | 228 +- .../Entities/ParentAttachmentState.cs | 99 +- .../Entities/RuntimeEntityDirectory.cs | 6 + .../RuntimeEntityObjectEventStream.cs | 109 +- .../Entities/RuntimeEntityObjectLifetime.cs | 65 +- .../Entities/RuntimeEntityRecord.cs | 3 + src/AcDream.Runtime/GameRuntime.cs | 3 +- src/AcDream.Runtime/GameRuntimeEvents.cs | 5 + .../Physics/RuntimePhysicsState.cs | 170 +- .../Physics/RuntimeSetPositionState.cs | 2014 ++++++++++++++ .../Physics/ShadowSetPositionCommitTests.cs | 167 ++ .../Entities/ParentAttachmentStateTests.cs | 95 + .../Physics/RuntimeSetPositionStateTests.cs | 2449 +++++++++++++++++ 18 files changed, 5557 insertions(+), 34 deletions(-) create mode 100644 src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs create mode 100644 tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index b46152fa..76a38787 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -241,6 +241,9 @@ src/ Physics/ RuntimePhysicsState.cs -> per-session engine/cache/scratch/shadows, collision receipts, bodies/hosts/worksets + RuntimeSetPositionState.cs -> exact placement/lost-cell operations, + authored mover retention, ordered host + receipts, and collision-generation wake RuntimeRemotePhysicsUpdater.cs -> presentation-free remote simulation RuntimeOrdinaryPhysicsUpdater.cs -> presentation-free object simulation RuntimeProjectile.cs -> canonical projectile component/prediction owner diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4c37cc4a..bf6ef268 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -80,6 +80,18 @@ loader branch there. Slice 4B must preserve the flag while mapping successful deferred placement to exact-cell, generation-scoped asynchronous admission; the presence of the flag in the immutable request is not claimed as exactness. +AP-1/AD-1 checkpoint (placement Slice 4B1, 2026-07-31): Runtime now owns the +exact accepted placement/lost-cell transaction, atomic body/contact/cell/ +shadow/workset commit, adjusted retained frame, authored mover preparation, +exact-cell/generation wake, append/swap lost buckets, a bounded indexed +deadline heap, independent root/direct-child deadlines, and revisioned ordered +host receipts. Both rows remain open until 4B2 cuts graphical and no-window +production routes over, quiesces active placement before invoking the dormant +collision-retirement entry, and binds portal +authority to `RuntimeWorldTransitState`. AD-2 remains the deliberate async +readiness/requeue adaptation. See +`docs/research/2026-07-31-canonical-set-position.md`. + | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | ~~AD-53~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `Transition.CliffSlide` now consumes only `collision_info.last_known_contact_plane.N`, exactly as retail does. The invented `LastWalkablePlane -> LastKnownContactPlane -> UnitZ` fallback chain is gone; invalid/default or parallel data takes retail's degenerate `OK_TS` return. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07) | @@ -92,7 +104,7 @@ the presence of the flag in the immutable request is not claimed as exactness. | AD-51 | **Filed at Campaign N slice N4 (2026-07-29).** The inbound sequence tracker keeps a reclaimed-word pool (per-parked-word draw ordinals + `PriorityQueue` consumed lowest-draw-order-first) that retail has no counterpart for: on a VALIDATED cleartext `RejectRetransmit`, the word the gap walk parked for the reject packet's OWN sequence is removed, every later-drawn parked word is shifted down one position, and the excess word feeds the next fresh draws. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`OnCleartextRejectSequence`, `NextWord`, `ParkedWord`); trigger at `src/AcDream.Core.Net/WorldSession.cs` (RejectRetransmit consumption) | Retail's inbound invariant is "every missing id was an encrypted packet whose keystream word the server drew" — true against retail servers, whose cleartext packets always borrow live sequences (acks/NAKs reuse `highestIDSent_`; `FlowQueue::TransmitNewPackets @ 0x00547A60` sequences only reliable packets). ACE breaks it in exactly one place: `RejectRetransmit` takes a FRESH sequence through FlushPackets, cleartext, drawing NO S2C keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748). Without the reclaim, our gap walk pre-draws a word for that id, the inbound stream runs permanently one word ahead, and every later encrypted packet fails checksum — the N2 desync class reintroduced through the reject path. The pool is provably empty against a retail server, so retail behavior is untouched. Reject BODY ids keep the N2 discard (their words were drawn on both sides — consumed-in-place). Known unreachable corner: a reject whose own id later appears inside another reject's body (first reject pruned after 120 s of sustained loss with the session alive) would discard a never-drawn word; probabilistically impossible against ACE's 60 s silence timeout and the 0.6 s NAK cadence. | Against a hypothetical non-ACE server that assigns fresh cleartext sequences to packets OTHER than RejectRetransmit, those ids would still mis-park with no reclaim trigger — inbound desync. Only ACE-family servers exist for this client today, and ACE has exactly the one path. | `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the gap walk whose invariant ACE breaks); `SharedNet::HandleEmptyAck @ 0x005448F0` (retail's reject consumption — body ids only, no own-sequence machinery because retail never needs it) | | AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | -| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | +| AD-1 | **NARROWED 2026-07-31 (placement Slice 4B1).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, and host Withdraw/Place receipts. Production graphical/headless authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until 4B2 cuts those routes over. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owner is dormant and separately gated so landing it cannot change the accepted production world before the complete route/host-ack cutover. | Until 4B2, a production gap can still commit an outdoor approximation inside/under a building or lift a legitimate below-heightmap restore instead of entering the now-available Runtime lost-cell owner. | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | @@ -140,7 +152,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4A).** Core now exposes the pure retail `SetPosition` → `AdjustPosition` → `CheckPositionInternal` → `find_valid_position` transaction, including exact sphere initialization, two distinct placement validators, step-down schedule, force classes, flags/scatter, signed no-slide predicate, error values, and an explicit deferred-residence result. Production zero-delta routes deliberately still use the legacy nearest-in-Z/terrain-lift resolver because its `ResolveResult` cannot represent a successful lost-cell transition. Slice 4B must cut every authoritative route to the canonical packet and install the Runtime lost-cell owner before this row retires. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs`; `src/AcDream.Core/Physics/TransitionTypes.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `docs/research/2026-07-31-canonical-set-position.md` | Split preserves production behavior while the immutable placement mechanism and its oracle gates land independently | Until 4B, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation and cannot park/wake an exact lost-cell frame | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | +| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B1).** Core exposes the pure retail `SetPosition` transaction and Runtime now owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, and revisioned host receipts. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies exact authored mover preparation and cuts graphical/headless inbound families to this dormant owner. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md` | The mechanism, ownership, and failure/reentrancy gates land independently without partially changing production behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owner now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | | ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 | diff --git a/docs/research/2026-07-31-canonical-set-position.md b/docs/research/2026-07-31-canonical-set-position.md index f166645e..6c1ae257 100644 --- a/docs/research/2026-07-31-canonical-set-position.md +++ b/docs/research/2026-07-31-canonical-set-position.md @@ -140,3 +140,158 @@ collision-handler mapping, force-class policy, explicit shadow actions, null current-cell wakeup, ten-record scratch exhaustion, exact scatter ordering, failed-probe scratch lifetime, and deferred-scatter stop. The legacy public Resolve fixture remains unchanged until Slice 4B. + +## Slice 4B1 — Runtime residence owner + +Slice 4B1 adds the presentation-independent half of the cutover without +changing a production graphical route yet. `RuntimeSetPositionState` accepts +an exact entity/position token before graphical DAT preparation, consumes the +immutable Core result, and commits body, contact, full cell, shadows, object +clock, and Runtime spatial worksets before publishing one ordered placement +delta. The delta carries the exact `RuntimeEntityKey`, session lifetime, +position/spatial/placement versions, adjusted cell, collision generation, and +optional portal-authority shape. A throwing or unavailable host does not roll +simulation back: Runtime republishes the same projection token until the +exact FIFO head is acknowledged. A newer operation changes an already- +published token to `Discard` and increments its projection revision, so an +acknowledgement of the previously observed Place/Withdraw cannot consume an +unseen Discard. It is never silently forgotten. An unacknowledged lost-cell +Withdraw transfers intact to a replacing accepted operation and remains the +FIFO head before that replacement may publish Place. + +The successful missing-cell path owns retail's residence shape: + +```text +SetPosition -> OK + DeferredCell + retain adjusted Position and the same PhysicsBody/components + clear only Active and suspend the object clock + withdraw Runtime spatial worksets and shadow rows + retain shadow registration and exact authored mover request + append parentless root to (exact cell, collision generation) + arm independent exact-key 25 s deadlines for root + direct children + publish Withdraw + +exact cell generation resident + Withdraw acknowledged + re-run SetPosition with retained authored spheres and CurrentCellId=null + atomically install complete result + publish Place +``` + +Lost-cell membership buckets use retail-shaped append plus swap-remove. +Destruction deadlines use an exact-key hash plus a bounded indexed min-heap, +the allocation-bounded modern equivalent of retail's hash + +`PQueueArray` priority owner. Rearm/removal updates the exact heap node; +there are no stale tombstones. Only committed, current direct children from +the parent-incarnation ordered CHILDLIST participate; unresolved or future +relations cannot inherit a deadline. Parent, pickup, delete, GUID +replacement, newer Position, reset, and disposal cancel the exact incarnation +and use leave-world semantics rather than a wakeable lost entry. The dormant +collision-retirement entry parks non-static parentless indoor roots and, for +complete withdrawal, affected outdoor roots. It performs a complete preflight +and rejects overlap with any active accepted/host-ack-pending placement before +mutating one resident. It then installs every affected canonical lost +residence and operation before publishing the first synchronous Withdraw, so +an observer re-entering for a later root inherits that root's exact pending +Withdraw instead of having its newly accepted placement cancelled by the +retirement loop. 4B2 must quiesce that placement prefix before invoking the +entry in the same transaction that installs host acknowledgements. + +Runtime retains the last accepted prepared mover request, including exact +off-center/two-sphere payloads, scale, flags, and step values. A cold resident +with no prepared request still withdraws atomically but remains explicitly in +`AwaitingPreparation`; it cannot wake through an invented empty-sphere shape. +Preparation is cached only after Core accepts it as Committed or DeferredCell. +A rejected/malformed preparation keeps the same accepted token retryable and +cannot replace the last validated mover used by later collision retirement. +Runtime's host boundary rejects only non-finite consumed frame/shape values; +retail-valid oddities such as nonpositive authored scale remain untouched. +Likewise, a non-deferred wake failure retains the withdrawn body, independent +25-second lifetime, and exact operation: invalid arguments return to +AwaitingPreparation and re-index for the next exact generation, while other +world-placement failures also re-index. The last successful DeferredCell +result and adjusted frame remain canonical across the failed attempt. No +failed wake can leave a live entity withdrawn without a Runtime owner. The +graphical/no-window cutover in 4B2 supplies that exact preparation token. + +Retail `CPhysicsObj::SetPositionInternal` (`0x00515BD0`) calls +`prepare_to_enter_world` (`0x00511FA0`) only when `this->cell == 0`. +Consequently the physics `update_time` (`PhysicsBody.LastUpdateTime`) and +active bit are reset only on the cellless-to-world edge. Ordinary same-cell or +cross-cell in-world SetPosition preserves the already-consumed physics clock; +entering the lost-cell residence also preserves it until the eventual +cellless wake commit. Runtime pins both sides and does not inherit the older +graphical teleport helper's unconditional timer reset. + +The wake timestamp is in the Runtime simulation-time domain, never Unix/UTC: +`GameRuntime` binds its instance `GameRuntimeClock` through the entity/physics +owner, and RetryDeferred samples `SimulationTimeSeconds`. Standalone Runtime +fixtures without a bound game clock retain the accepted command time. This is +a Runtime dependency only; no App delegate enters the owner. + +The canonical commit also installs every SetPosition-derived body invariant +before host publication: Contact/OnWalkable/WaterContact, the current contact +plane and slope `GroundNormal`, Sliding plus its normal, and the complete +StationaryFall/Stop/Stuck encoding. Named retail +`CPhysicsObj::SetPositionInternal(CTransition const*)` (`0x00515330`) copies +only the transition's current contact plane/water flag, walkability, sliding +normal/valid flag, and collision state (`0x005153E5–0x005154FE`). It does not +publish `last_known_contact_plane` or the SpherePath walkable polygon on this +path, so those ordinary-update-only fields are intentionally absent from the +immutable SetPosition result and remain unchanged. A zero expected velocity +version in host preparation preserves the nonzero version captured when the +operation was accepted; an intervening Vector/Movement therefore suppresses +only the stale collision-velocity response. A bodyless cancellation terminates +without fabricating a PhysicsBody or a host Withdraw projection. The two time +domains remain explicit: `PhysicsBody.LastUpdateTime` consumes the instance +simulation clock, while `IRuntimeRemotePlacement.LastServerPositionTime` +remains Unix-UTC receipt time because the remote stale-velocity owner ages it +against `RuntimePhysicsState.UtcNowSeconds`. A deferred wake therefore cannot +make fresh authoritative remote velocity appear years old. Runtime preparation +also applies the existing retail `PositionFrameValidation` before Core or +prepared-mover caching, and caps synchronous Scatter/RandomScatter work at 64 +attempts; this keeps valid authored retail request shapes while rejecting a +hostile `uint.MaxValue` loop at the authority boundary. + +Two boundaries intentionally remain open for 4B2: + +- `RuntimePortalPlacementAuthority` validates immutable token shape only; + 4B2 must bind it to active `RuntimeWorldTransitState` generation, teleport + sequence, destination, and host acknowledgement before reveal. +- Runtime applies the canonical collision-report state but fails the handler + return closed. Retail returns report/track success, not collision presence; + the per-object report/tracking owner required for that boolean is not yet in + Runtime, and the former environment/object-presence guess is forbidden. + +AD-2 remains the explicit async adaptation: collision readiness can publish in +a different frame from retail's blocking load. A failed wake is safely re- +indexed to the next exact generation instead of inheriting retail's +synchronous assumption. When older unbound survivors meet newer entities +already indexed into that future generation, Runtime merges them into one +bucket with the older survivor order first and retains one bucket-order entry. +AP-1 and AD-1 remain open until 4B2 removes the legacy graphical/headless +placement paths. + +`RuntimeSetPositionStateTests` pins accepted-before-preparation ownership, +portal-shape rejection, canonical-before-projection ordering, retry and +reentrant discard, cross-landblock adjusted-cell park/wake, same-body +identity, retained contact/water/sliding/velocity, exact generation gating, +authored two-sphere retention, cold preparation, bounded priority-deadline +rearm/cancel, zero-allocation empty ticks, independent ordered direct-child +deadlines, actual collision-admission supersession/invalidation, missing exact +indoor-cell generation rebind/wake, collision demotion/withdrawal preflight, +failed-wake retry, malformed-preparation retry without cache poisoning, +simulation-clock-domain wake, velocity-version preservation, derived body-bit +writeback, immediate remote-velocity survival across the simulation/UTC clock +boundary, invalid cell/frame/quaternion and extreme-scatter rejection before +Core/caching, bodyless cancellation, newer Position/pickup/parent/delete, GUID +reuse, reset, and complete index/node terminal convergence. Existing zero- +allocation collision-generation gates remain unchanged on the no-deferred +fast path. + +The warmed immediate commit/ack route currently measures exactly **1,880 +managed bytes per operation** in the Release Runtime test host (1,000 +iterations after 64 warmups); the regression gate caps it at 2,048 bytes. +This dormant-path result is an explicit 4B2 activation blocker rather than a +claim of allocation-free production readiness: 4B2 must either pool/remove +the operation and projection envelopes or record an approved measured budget +before routing frame-frequency placement through this owner. diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index cf3567d5..8300307e 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -23,6 +23,9 @@ <_Parameter1>AcDream.Runtime + + <_Parameter1>AcDream.Runtime.Tests + diff --git a/src/AcDream.Core/Physics/CollisionWorldState.cs b/src/AcDream.Core/Physics/CollisionWorldState.cs index a2a182c2..bc92d882 100644 --- a/src/AcDream.Core/Physics/CollisionWorldState.cs +++ b/src/AcDream.Core/Physics/CollisionWorldState.cs @@ -25,6 +25,7 @@ internal sealed class CollisionWorldState internal Dictionary> ShadowCells { get; } = new(); internal Dictionary> ShadowEntityCells { get; } = new(); internal HashSet SuspendedShadowEntities { get; } = new(); + internal Dictionary> SuspendedShadowEntityCells { get; } = new(); internal Dictionary> WithdrawnPrefixesByOwner { get; } = new(); internal Dictionary> ShadowEntityShapes { get; } = new(); internal Dictionary diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index d79e14c5..874addd3 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -33,6 +33,8 @@ public sealed class ShadowObjectRegistry _collisionWorld.Current.ShadowEntityCells; // for deregistration private HashSet _suspendedEntities => _collisionWorld.Current.SuspendedShadowEntities; + private Dictionary> _suspendedEntityCells => + _collisionWorld.Current.SuspendedShadowEntityCells; // Rows withdrawn because a touched landblock streamed out. The owner may // be seeded in an adjacent still-resident landblock, so its remaining rows // cannot by themselves tell RefloodLandblock that this prefix needs repair. @@ -691,6 +693,218 @@ public sealed class ShadowObjectRegistry reg.State, reg.Flags, seedCellId, reg.IsStatic); } + /// + /// Installs the shadow-list suffix of one canonical retail + /// CPhysicsObj::SetPositionInternal commit. The transition has + /// already selected whether retail recalculates, replaces, or preserves + /// the owner's cross-cell list; this method consumes that decision + /// without running a second placement/flood oracle. + /// + internal void CommitSetPosition( + uint entityId, + Vector3 worldPosition, + Quaternion worldRotation, + uint seedCellId, + float worldOffsetX, + float worldOffsetY, + PhysicsShadowCommitAction action, + System.Collections.Immutable.ImmutableArray crossCellIds) + { + if (!_entityReg.TryGetValue( + entityId, + out RegistrationRecord? registration)) + { + return; + } + + switch (action) + { + case PhysicsShadowCommitAction.None: + RefreshPositionRows( + entityId, + registration, + worldPosition, + worldRotation, + seedCellId); + return; + case PhysicsShadowCommitAction.Recalculate: + UpdatePosition( + entityId, + worldPosition, + worldRotation, + worldOffsetX, + worldOffsetY, + landblockId: seedCellId & 0xFFFF0000u, + seedCellId); + return; + case PhysicsShadowCommitAction.Replace: + if (crossCellIds.IsDefaultOrEmpty) + { + RefreshPositionRows( + entityId, + registration, + worldPosition, + worldRotation, + seedCellId); + return; + } + ReplacePositionRows( + entityId, + registration, + worldPosition, + worldRotation, + seedCellId, + crossCellIds); + return; + case PhysicsShadowCommitAction.Preserve: + RefreshPositionRows( + entityId, + registration, + worldPosition, + worldRotation, + seedCellId); + return; + default: + throw new ArgumentOutOfRangeException(nameof(action)); + } + } + + private void RefreshPositionRows( + uint entityId, + RegistrationRecord registration, + Vector3 worldPosition, + Quaternion worldRotation, + uint seedCellId) + { + if ((_entityToCells.TryGetValue( + entityId, + out List? retainedCells) + || _suspendedEntityCells.TryGetValue( + entityId, + out retainedCells)) + && retainedCells.Count != 0) + { + ReplacePositionRows( + entityId, + registration, + worldPosition, + worldRotation, + seedCellId, + retainedCells); + return; + } + + _entityReg[entityId] = registration with + { + SeedCellId = seedCellId, + EntityWorldPos = worldPosition, + EntityWorldRot = worldRotation, + }; + BumpOwnerVersion(entityId); + } + + private void ReplacePositionRows( + uint entityId, + RegistrationRecord registration, + Vector3 worldPosition, + Quaternion worldRotation, + uint seedCellId, + IReadOnlyList cellIds) + { + if (_entityToCells.TryGetValue( + entityId, + out List? previousCells)) + { + for (int index = 0; index < previousCells.Count; index++) + { + if (_cells.TryGetValue( + previousCells[index], + out List? entries)) + { + RemoveOwnerRows(entries, entityId); + } + } + } + + _suspendedEntities.Remove(entityId); + _suspendedEntityCells.Remove(entityId); + _entityReg[entityId] = registration with + { + SeedCellId = seedCellId, + EntityWorldPos = worldPosition, + EntityWorldRot = worldRotation, + }; + + var exactCells = new List(cellIds.Count); + for (int index = 0; index < cellIds.Count; index++) + { + uint cellId = cellIds[index]; + if (cellId == 0u || exactCells.Contains(cellId)) + continue; + exactCells.Add(cellId); + } + + if (registration.IsMultiPart + && _entityShapes.TryGetValue( + entityId, + out IReadOnlyList? shapes)) + { + foreach (ShadowShape shape in shapes) + { + Vector3 partWorldPosition = worldPosition + + Vector3.Transform(shape.LocalPosition, worldRotation); + Quaternion partWorldRotation = worldRotation + * shape.LocalRotation; + var entry = new ShadowEntry( + entityId, + shape.GfxObjId, + partWorldPosition, + partWorldRotation, + shape.Radius, + shape.CollisionType, + shape.CylHeight, + shape.Scale, + registration.State, + registration.Flags, + shape.LocalPosition, + shape.LocalRotation); + for (int index = 0; index < exactCells.Count; index++) + AddEntryToCell(entry, exactCells[index]); + } + } + else + { + var entry = new ShadowEntry( + entityId, + registration.GfxObjId, + worldPosition, + worldRotation, + registration.Radius, + registration.CollisionType, + registration.CylHeight, + registration.Scale, + registration.State, + registration.Flags); + for (int index = 0; index < exactCells.Count; index++) + AddEntryToCell(entry, exactCells[index]); + } + + if (exactCells.Count == 0) + _entityToCells.Remove(entityId); + else + _entityToCells[entityId] = exactCells; + if (_withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? withdrawn)) + { + for (int index = 0; index < exactCells.Count; index++) + withdrawn.Remove(exactCells[index] & 0xFFFF0000u); + if (withdrawn.Count == 0) + _withdrawnPrefixesByOwner.Remove(entityId); + } + BumpOwnerVersion(entityId); + } + /// /// Removes an entity from every cell collision list while retaining the /// exact registration and shape payload needed to restore it later. @@ -706,6 +920,7 @@ public sealed class ShadowObjectRegistry if (_entityToCells.TryGetValue(entityId, out var cellIds)) { + _suspendedEntityCells[entityId] = new List(cellIds); foreach (uint cellId in cellIds) { if (_cells.TryGetValue(cellId, out var list)) @@ -924,7 +1139,8 @@ public sealed class ShadowObjectRegistry bool existed = _entityReg.ContainsKey(entityId) || _entityToCells.ContainsKey(entityId) || _entityShapes.ContainsKey(entityId) - || _suspendedEntities.Contains(entityId); + || _suspendedEntities.Contains(entityId) + || _suspendedEntityCells.ContainsKey(entityId); if (_entityToCells.TryGetValue(entityId, out var cellIds)) { foreach (var cellId in cellIds) @@ -937,6 +1153,7 @@ public sealed class ShadowObjectRegistry _entityShapes.Remove(entityId); _entityReg.Remove(entityId); _suspendedEntities.Remove(entityId); + _suspendedEntityCells.Remove(entityId); _withdrawnPrefixesByOwner.Remove(entityId); if (existed && publishMutation) { @@ -1065,6 +1282,7 @@ public sealed class ShadowObjectRegistry _entityShapes.Remove(eid); _entityReg.Remove(eid); _suspendedEntities.Remove(eid); + _suspendedEntityCells.Remove(eid); _withdrawnPrefixesByOwner.Remove(eid); } } @@ -1367,6 +1585,9 @@ public sealed class ShadowObjectRegistry return false; } _entityToCells.TryGetValue(entityId, out List? cells); + _suspendedEntityCells.TryGetValue( + entityId, + out List? suspendedCells); _entityShapes.TryGetValue( entityId, out IReadOnlyList? shapes); @@ -1394,6 +1615,7 @@ public sealed class ShadowObjectRegistry cells is null ? null : new List(cells), rows, _suspendedEntities.Contains(entityId), + suspendedCells is null ? null : new List(suspendedCells), withdrawn is null ? null : new HashSet(withdrawn)); return true; } @@ -1405,6 +1627,8 @@ public sealed class ShadowObjectRegistry _entityShapes[state.EntityId] = state.Shapes; if (state.Suspended) _suspendedEntities.Add(state.EntityId); + if (state.SuspendedCellIds is not null) + _suspendedEntityCells[state.EntityId] = state.SuspendedCellIds; if (state.WithdrawnPrefixes is not null) { _withdrawnPrefixesByOwner[state.EntityId] = state.WithdrawnPrefixes; @@ -1614,6 +1838,7 @@ public sealed class ShadowObjectRegistry List? CellIds, IReadOnlyList Rows, bool Suspended, + List? SuspendedCellIds, HashSet? WithdrawnPrefixes); internal sealed record PreparedShadowCellRows( @@ -1629,6 +1854,7 @@ public sealed class ShadowObjectRegistry _cells.Clear(); _entityToCells.Clear(); _suspendedEntities.Clear(); + _suspendedEntityCells.Clear(); _withdrawnPrefixesByOwner.Clear(); _entityShapes.Clear(); _entityReg.Clear(); diff --git a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs index 2d64f63d..355d139e 100644 --- a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs +++ b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs @@ -16,6 +16,7 @@ public sealed class ParentAttachmentState private readonly Dictionary _stagedByChild = new(); private readonly Dictionary _recoveryByChild = new(); private readonly Dictionary _lastAcceptedByChild = new(); + private readonly Dictionary> _committedChildrenByParent = new(); public int UnresolvedRelationCount => _unresolvedByChild.Values.Sum(queue => queue.Count); @@ -148,6 +149,9 @@ public sealed class ParentAttachmentState out ParentAttachmentRelation committed) && committed == relation; + public bool HasCommittedParent(uint childGuid) => + _lastAcceptedByChild.ContainsKey(childGuid); + public bool IsPending( ParentAttachmentRelation relation, ParentProjectionCandidateKind kind) => @@ -184,7 +188,20 @@ public sealed class ParentAttachmentState { return false; } + + RemoveCommittedChild(relation.ChildGuid); _lastAcceptedByChild[relation.ChildGuid] = relation; + var parent = new ParentIncarnation( + relation.ParentGuid, + relation.ParentInstanceSequence); + if (!_committedChildrenByParent.TryGetValue( + parent, + out List? children)) + { + children = []; + _committedChildrenByParent.Add(parent, children); + } + children.Add(relation.ChildGuid); _stagedByChild.Remove(relation.ChildGuid); _recoveryByChild[relation.ChildGuid] = relation; return true; @@ -233,16 +250,34 @@ public sealed class ParentAttachmentState return result.ToArray(); } + /// + /// Returns only the exact direct children currently committed to one + /// parent incarnation. Lost-cell destruction follows retail's live + /// CHILDLIST and must not capture staged, unresolved, or future-generation + /// relations that merely reuse the same parent GUID. + /// + public IReadOnlyList ChildrenAttachedToParent( + uint parentGuid, + ushort parentInstanceSequence) + { + var parent = new ParentIncarnation(parentGuid, parentInstanceSequence); + return _committedChildrenByParent.TryGetValue( + parent, + out List? children) + ? children + : Array.Empty(); + } + public void RemoveObject(uint guid) { _stagedByChild.Remove(guid); _recoveryByChild.Remove(guid); - _lastAcceptedByChild.Remove(guid); + RemoveCommittedChild(guid); _unresolvedByChild.Remove(guid); RemoveParentReferences(_stagedByChild, guid); RemoveParentReferences(_recoveryByChild, guid); - RemoveParentReferences(_lastAcceptedByChild, guid); + RemoveCommittedParentReferences(guid); uint[] children = _unresolvedByChild.Keys.ToArray(); for (int i = 0; i < children.Length; i++) @@ -268,10 +303,10 @@ public sealed class ParentAttachmentState relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); _stagedByChild.Remove(guid); _recoveryByChild.Remove(guid); - _lastAcceptedByChild.Remove(guid); + RemoveCommittedChild(guid); RemoveParentReferences(_stagedByChild, guid); RemoveParentReferences(_recoveryByChild, guid); - RemoveParentReferences(_lastAcceptedByChild, guid); + RemoveCommittedParentReferences(guid); FilterParentCandidates( guid, relation => relation.ParentInstanceSequence == replacementGeneration @@ -292,10 +327,10 @@ public sealed class ParentAttachmentState relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); _stagedByChild.Remove(guid); _recoveryByChild.Remove(guid); - _lastAcceptedByChild.Remove(guid); + RemoveCommittedChild(guid); RemoveParentReferences(_stagedByChild, guid); RemoveParentReferences(_recoveryByChild, guid); - RemoveParentReferences(_lastAcceptedByChild, guid); + RemoveCommittedParentReferences(guid); FilterParentCandidates( guid, relation => PhysicsTimestampGate.IsNewer( @@ -311,14 +346,14 @@ public sealed class ParentAttachmentState { _stagedByChild.Remove(childGuid); _recoveryByChild.Remove(childGuid); - _lastAcceptedByChild.Remove(childGuid); + RemoveCommittedChild(childGuid); } public void RemoveChild(uint childGuid) { _stagedByChild.Remove(childGuid); _recoveryByChild.Remove(childGuid); - _lastAcceptedByChild.Remove(childGuid); + RemoveCommittedChild(childGuid); _unresolvedByChild.Remove(childGuid); } @@ -328,6 +363,50 @@ public sealed class ParentAttachmentState _stagedByChild.Clear(); _recoveryByChild.Clear(); _lastAcceptedByChild.Clear(); + foreach (List children in _committedChildrenByParent.Values) + children.Clear(); + _committedChildrenByParent.Clear(); + } + + private void RemoveCommittedChild(uint childGuid) + { + if (!_lastAcceptedByChild.Remove( + childGuid, + out ParentAttachmentRelation relation)) + { + return; + } + + var parent = new ParentIncarnation( + relation.ParentGuid, + relation.ParentInstanceSequence); + if (!_committedChildrenByParent.TryGetValue( + parent, + out List? children)) + { + return; + } + + int childIndex = children.IndexOf(childGuid); + if (childIndex >= 0) + { + int lastIndex = children.Count - 1; + children[childIndex] = children[lastIndex]; + children.RemoveAt(lastIndex); + } + + if (children.Count == 0) + _committedChildrenByParent.Remove(parent); + } + + private void RemoveCommittedParentReferences(uint parentGuid) + { + uint[] children = _lastAcceptedByChild + .Where(pair => pair.Value.ParentGuid == parentGuid) + .Select(pair => pair.Key) + .ToArray(); + for (int i = 0; i < children.Length; i++) + RemoveCommittedChild(children[i]); } private static void RemoveParentReferences( @@ -377,6 +456,10 @@ public sealed class ParentAttachmentState else _unresolvedByChild[childGuid] = retained; } + + private readonly record struct ParentIncarnation( + uint ServerGuid, + ushort InstanceSequence); } public readonly record struct ParentAttachmentRelation( diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 02d2722b..73308b39 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -254,6 +254,12 @@ public sealed class RuntimeEntityDirectory record.AdvanceMovementCommit(); } + public void AdvancePlacementCommit(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.AdvancePlacementCommit(); + } + public void AdvanceParentCommit(RuntimeEntityRecord record) { EnsureKnown(record); diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs index 5cc7e5c3..9935b453 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs @@ -1,4 +1,5 @@ using AcDream.Core.Items; +using AcDream.Runtime.Physics; namespace AcDream.Runtime.Entities; @@ -14,6 +15,11 @@ public interface IRuntimeEntityObjectEventSource IDisposable Subscribe(IRuntimeEntityObjectObserver observer); } +public interface IRuntimePlacementObserver +{ + void OnPlacement(in RuntimePlacementDelta delta); +} + /// /// One synchronous, generation-stamped commit stream for the canonical entity /// directory and retained object table. It owns no cross-frame queue and @@ -31,6 +37,7 @@ public sealed class RuntimeEntityObjectEventStream private readonly object _observerGate = new(); private readonly List _pendingDispatch = []; private IRuntimeEntityObjectObserver[] _observers = []; + private IRuntimePlacementObserver[] _placementObservers = []; private Func _generation = static () => default; private Func _frameNumber = static () => 0UL; private bool _contextBound; @@ -53,6 +60,8 @@ public sealed class RuntimeEntityObjectEventStream public ulong LastSequence => _sequencer.LastSequence; public int SubscriberCount => Volatile.Read(ref _observers).Length; + public int PlacementSubscriberCount => + Volatile.Read(ref _placementObservers).Length; public int PendingDispatchCount => _pendingDispatch.Count; public bool IsDispatching => _dispatching; public long DispatchFailureCount { get; private set; } @@ -107,6 +116,26 @@ public sealed class RuntimeEntityObjectEventStream return new ObserverSubscription(this, observer); } + public IDisposable SubscribePlacement(IRuntimePlacementObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_observerGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + IRuntimePlacementObserver[] current = _placementObservers; + if (Array.IndexOf(current, observer) >= 0) + { + throw new InvalidOperationException( + "The Runtime placement observer is already subscribed."); + } + var replacement = new IRuntimePlacementObserver[current.Length + 1]; + Array.Copy(current, replacement, current.Length); + replacement[^1] = observer; + Volatile.Write(ref _placementObservers, replacement); + } + return new PlacementObserverSubscription(this, observer); + } + /// /// Allocates the next stamp for another domain on the same Runtime event /// surface. J4–J6 will move those remaining domain publishers into @@ -132,6 +161,15 @@ public sealed class RuntimeEntityObjectEventStream EnqueueAndDrain(PendingDispatch.ForEntity(delta)); } + internal void PublishPlacement( + in RuntimePlacementProjectionSnapshot placement) + { + var delta = new RuntimePlacementDelta( + NextStamp(), + placement); + EnqueueAndDrain(PendingDispatch.ForPlacement(delta)); + } + public void Dispose() { lock (_observerGate) @@ -140,6 +178,7 @@ public sealed class RuntimeEntityObjectEventStream return; _disposed = true; Volatile.Write(ref _observers, []); + Volatile.Write(ref _placementObservers, []); _objects.Cleared -= OnObjectsCleared; _objects.ObjectRemovalClassified -= OnObjectRemoved; _objects.ObjectMoved -= OnObjectMoved; @@ -155,6 +194,7 @@ public sealed class RuntimeEntityObjectEventStream if (_disposed) return; Volatile.Write(ref _observers, []); + Volatile.Write(ref _placementObservers, []); } } @@ -245,7 +285,7 @@ public sealed class RuntimeEntityObjectEventStream RuntimeEntityDelta delta = pending.Entity; observer.OnEntity(in delta); } - else + else if (pending.Kind is PendingDispatchKind.Inventory) { RuntimeInventoryDelta delta = pending.Inventory; observer.OnInventory(in delta); @@ -256,6 +296,24 @@ public sealed class RuntimeEntityObjectEventStream RecordDispatchFailure(error); } } + + if (pending.Kind is PendingDispatchKind.Placement) + { + IRuntimePlacementObserver[] placementObservers = + Volatile.Read(ref _placementObservers); + foreach (IRuntimePlacementObserver observer in placementObservers) + { + try + { + RuntimePlacementDelta delta = pending.Placement; + observer.OnPlacement(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } } private void RecordDispatchFailure(Exception error) @@ -295,24 +353,54 @@ public sealed class RuntimeEntityObjectEventStream } } + private void UnsubscribePlacement(IRuntimePlacementObserver observer) + { + lock (_observerGate) + { + IRuntimePlacementObserver[] current = _placementObservers; + int index = Array.IndexOf(current, observer); + if (index < 0) + return; + var replacement = new IRuntimePlacementObserver[current.Length - 1]; + if (index != 0) + Array.Copy(current, 0, replacement, 0, index); + if (index != current.Length - 1) + { + Array.Copy( + current, + index + 1, + replacement, + index, + current.Length - index - 1); + } + Volatile.Write(ref _placementObservers, replacement); + } + } + private enum PendingDispatchKind : byte { Entity, Inventory, + Placement, } private readonly record struct PendingDispatch( PendingDispatchKind Kind, RuntimeEntityDelta Entity, - RuntimeInventoryDelta Inventory) + RuntimeInventoryDelta Inventory, + RuntimePlacementDelta Placement) { public static PendingDispatch ForEntity( RuntimeEntityDelta entity) => - new(PendingDispatchKind.Entity, entity, default); + new(PendingDispatchKind.Entity, entity, default, default); public static PendingDispatch ForInventory( RuntimeInventoryDelta inventory) => - new(PendingDispatchKind.Inventory, default, inventory); + new(PendingDispatchKind.Inventory, default, inventory, default); + + public static PendingDispatch ForPlacement( + RuntimePlacementDelta placement) => + new(PendingDispatchKind.Placement, default, default, placement); } private sealed class ObserverSubscription( @@ -325,4 +413,17 @@ public sealed class RuntimeEntityObjectEventStream public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer); } + + + private sealed class PlacementObserverSubscription( + RuntimeEntityObjectEventStream owner, + IRuntimePlacementObserver observer) + : IDisposable + { + private RuntimeEntityObjectEventStream? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)? + .UnsubscribePlacement(observer); + } } diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 1b2b8a95..4d7be7c5 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -28,6 +28,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( int EquipmentOwnerCount, int PendingMoveCount, int StreamSubscriberCount, + int PlacementStreamSubscriberCount, long StreamDispatchFailureCount, bool HasLastStreamDispatchFailure, int PendingDispatchCount, @@ -51,6 +52,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && EquipmentOwnerCount == 0 && PendingMoveCount == 0 && StreamSubscriberCount == 0 + && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 && !IsDispatching && !IsSessionClearInProgress; @@ -95,10 +97,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable public RuntimeEntityObjectLifetime( uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGameRuntimeClock? gameClock = null) { Entities = new RuntimeEntityDirectory(firstLocalEntityId); - Physics = new RuntimePhysicsState(Entities, timeProvider: timeProvider); + Physics = new RuntimePhysicsState( + Entities, + timeProvider: timeProvider, + gameClock: gameClock); Objects = new ClientObjectTable(); // AP-129 (Campaign P Slice P4 review fix, 2026-07-30): the physics // entry-restriction gate (ObjectInfo.CheckEntryRestrictions) resolves @@ -111,44 +117,51 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable EntityView = views.Entities; InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); + Physics.SetPosition.BindEventStream(Events); } internal RuntimeEntityObjectLifetime( PhysicsDataCache physicsDataCache, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGameRuntimeClock? gameClock = null) { ArgumentNullException.ThrowIfNull(physicsDataCache); Entities = new RuntimeEntityDirectory(firstLocalEntityId); Physics = new RuntimePhysicsState( Entities, physicsDataCache, - timeProvider); + timeProvider, + gameClock); Objects = new ClientObjectTable(); Physics.Engine.Objects = Objects; var views = new RuntimeEntityObjectViews(Entities, Objects); EntityView = views.Entities; InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); + Physics.SetPosition.BindEventStream(Events); } internal RuntimeEntityObjectLifetime( PhysicsEngine physicsEngine, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGameRuntimeClock? gameClock = null) { ArgumentNullException.ThrowIfNull(physicsEngine); Entities = new RuntimeEntityDirectory(firstLocalEntityId); Physics = new RuntimePhysicsState( Entities, physicsEngine, - timeProvider); + timeProvider, + gameClock); Objects = new ClientObjectTable(); Physics.Engine.Objects = Objects; var views = new RuntimeEntityObjectViews(Entities, Objects); EntityView = views.Entities; InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); + Physics.SetPosition.BindEventStream(Events); } public RuntimeEntityDirectory Entities { get; } @@ -176,6 +189,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Objects.EquipmentOwnerCount, Objects.PendingMoveCount, Events.SubscriberCount, + Events.PlacementSubscriberCount, Events.DispatchFailureCount, Events.LastDispatchFailure is not null, Events.PendingDispatchCount, @@ -421,6 +435,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(canonical); + RuntimePlacementCancellationReceipt cancellation = + Physics.SetPosition.Forget( + canonical, + releasePreparedMover: true); Physics.RemoveSpatialProjection(canonical); Entities.SetRemoteMotion(canonical, null); Entities.SetRemoteMotionBindingInProgress(canonical, false); @@ -432,6 +450,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.SetPhysicsBodyAcquisitionInProgress(canonical, false); Entities.SetHasPartArray(canonical, false); Entities.ReleaseLocalId(canonical); + Physics.SetPosition.PublishCancellation(cancellation); } /// @@ -510,6 +529,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvancePositionAuthority(canonical); + RuntimePlacementCancellationReceipt cancellation = + Physics.SetPosition.Forget(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); Entities.ParentAttachments.EndChildProjection(update.Guid); @@ -520,7 +541,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable () => acknowledgeProjection?.Invoke(canonical), RuntimeEntityChange.Withdrawn, () => canonical.PositionAuthorityVersion == positionVersion - && canonical.SpatialAuthorityVersion == spatialVersion); + && canonical.SpatialAuthorityVersion == spatialVersion, + cancellation); } public bool TryApplyCreateParent( @@ -596,6 +618,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return false; } + RuntimePlacementCancellationReceipt cancellation = + Physics.SetPosition.Forget(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); ulong spatialVersion = canonical.SpatialAuthorityVersion; @@ -605,7 +629,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimeEntityChange.Withdrawn, () => canonical.PositionAuthorityVersion == positionAuthorityVersion - && canonical.SpatialAuthorityVersion == spatialVersion); + && canonical.SpatialAuthorityVersion == spatialVersion, + cancellation); } public bool TryApplyMotion( @@ -764,6 +789,9 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable }; } + RuntimePlacementCancellationReceipt cancellation = acceptedPosition + ? Physics.SetPosition.Forget(canonical) + : default; Entities.RefreshSnapshot( canonical, snapshot, @@ -793,7 +821,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable ? RuntimeEntityChange.Rebucketed : RuntimeEntityChange.Updated, () => canonical.PositionAuthorityVersion == positionVersion - && canonical.SpatialAuthorityVersion == spatialVersion); + && canonical.SpatialAuthorityVersion == spatialVersion, + cancellation); } public bool CommitRebucket( @@ -915,8 +944,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable && active.Incarnation == delete.InstanceSequence && Entities.RemoveActive(active)) { + RuntimePlacementCancellationReceipt cancellation = + Physics.SetPosition.Forget( + active, + releasePreparedMover: true); retiredCanonical = active; Entities.RetainTeardown(active); + Physics.SetPosition.PublishCancellation(cancellation); PublishEntity( removeRetainedObject ? RuntimeEntityChange.Deleted @@ -985,10 +1019,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return Array.Empty(); _sessionClearInProgress = true; + Physics.SetPosition.ResetSession(); Entities.BeginSessionClear(); RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); foreach (RuntimeEntityRecord canonical in active) { + Physics.SetPosition.Forget(canonical); if (!Entities.RemoveActive(canonical)) continue; Entities.RetainTeardown(canonical); @@ -1119,6 +1155,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvancePositionAuthority(canonical); + RuntimePlacementCancellationReceipt cancellation = + Physics.SetPosition.Forget(canonical); ulong positionVersion = canonical.PositionAuthorityVersion; ulong spatialVersion = canonical.SpatialAuthorityVersion; return AcknowledgeProjectionAndPublish( @@ -1126,7 +1164,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable () => acknowledgeProjection?.Invoke(canonical), RuntimeEntityChange.Updated, () => canonical.PositionAuthorityVersion == positionVersion - && canonical.SpatialAuthorityVersion == spatialVersion); + && canonical.SpatialAuthorityVersion == spatialVersion, + cancellation); } private void PublishEntity( @@ -1138,8 +1177,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimeEntityRecord canonical, Action acknowledgeProjection, RuntimeEntityChange change, - Func matchesCommittedMutation) + Func matchesCommittedMutation, + RuntimePlacementCancellationReceipt cancellation = default) { + Physics.SetPosition.PublishCancellation(cancellation); + if (!IsExpectedCanonical(canonical, matchesCommittedMutation)) + return false; try { acknowledgeProjection(); diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs index 57ffb766..f180b915 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -52,6 +52,7 @@ public sealed class RuntimeEntityRecord public uint RawPhysicsState { get; internal set; } public PhysicsStateFlags FinalPhysicsState { get; internal set; } public ulong SpatialAuthorityVersion { get; private set; } + public ulong PlacementCommitVersion { get; private set; } public ulong PhysicsStateMutationVersion { get; private set; } /// @@ -136,6 +137,8 @@ public sealed class RuntimeEntityRecord internal void AdvanceMovementCommit() => MovementCommitVersion++; + internal void AdvancePlacementCommit() => PlacementCommitVersion++; + internal void AdvanceParentCommit() => ParentCommitVersion++; internal void AdvanceObjDescAuthority() => ObjDescAuthorityVersion++; diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index fcad9f14..1625d47b 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -178,7 +178,8 @@ public sealed class GameRuntime context.EntityObjects = new RuntimeEntityObjectLifetime( dependencies.FirstLocalEntityId, - dependencies.TimeProvider); + dependencies.TimeProvider, + clock); construction.Own(context.EntityObjects); Fault( GameRuntimeConstructionPoint.EntityObjectsCreated, diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs index 376a3426..6183ea23 100644 --- a/src/AcDream.Runtime/GameRuntimeEvents.cs +++ b/src/AcDream.Runtime/GameRuntimeEvents.cs @@ -1,4 +1,5 @@ using AcDream.Core.Combat; +using AcDream.Runtime.Physics; namespace AcDream.Runtime; @@ -50,6 +51,10 @@ public readonly record struct RuntimeEntityDelta( RuntimeEntityChange Change, RuntimeEntitySnapshot Entity); +public readonly record struct RuntimePlacementDelta( + RuntimeEventStamp Stamp, + RuntimePlacementProjectionSnapshot Placement); + public enum RuntimeInventoryChange { Added, diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 95ccf9dc..3df6294c 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -9,6 +9,20 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( int SpatialRootCount, int SpatialRemoteCount, int SpatialProjectileCount, + int SetPositionOperationCount, + int AwaitingSetPositionPreparationCount, + int DeferredSetPositionCount, + int PendingSetPositionHostAcknowledgementCount, + int LostCellDeadlineCount, + int LostCellDeadlineNodeCount, + int LostCellDeadlineIndexCount, + int ExpiredLostCellCount, + int ExpiredLostCellIndexCount, + int DeferredSetPositionBucketCount, + int DeferredSetPositionBucketOrderCount, + int UnboundDeferredSetPositionCellCount, + int UnboundDeferredSetPositionCellOrderCount, + int PreparedSetPositionMoverCount, int CollisionAdmissionCount, int CollisionGenerationCount, bool OwnsProductionDataCache, @@ -21,6 +35,20 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( && SpatialRootCount == 0 && SpatialRemoteCount == 0 && SpatialProjectileCount == 0 + && SetPositionOperationCount == 0 + && AwaitingSetPositionPreparationCount == 0 + && DeferredSetPositionCount == 0 + && PendingSetPositionHostAcknowledgementCount == 0 + && LostCellDeadlineCount == 0 + && LostCellDeadlineNodeCount == 0 + && LostCellDeadlineIndexCount == 0 + && ExpiredLostCellCount == 0 + && ExpiredLostCellIndexCount == 0 + && DeferredSetPositionBucketCount == 0 + && DeferredSetPositionBucketOrderCount == 0 + && UnboundDeferredSetPositionCellCount == 0 + && UnboundDeferredSetPositionCellOrderCount == 0 + && PreparedSetPositionMoverCount == 0 && CollisionAdmissionCount == 0 && CollisionGenerationCount == 0 && OwnsProductionDataCache; @@ -979,6 +1007,7 @@ internal readonly record struct RuntimeCollisionSealStep( public sealed class RuntimePhysicsState : IDisposable { private readonly TimeProvider _timeProvider; + private readonly IGameRuntimeClock? _gameClock; private readonly Dictionary _spatialRoots = new(); private readonly Dictionary @@ -1020,10 +1049,12 @@ public sealed class RuntimePhysicsState : IDisposable internal RuntimePhysicsState( RuntimeEntityDirectory entities, PhysicsDataCache? dataCache = null, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGameRuntimeClock? gameClock = null) { Entities = entities ?? throw new ArgumentNullException(nameof(entities)); _timeProvider = timeProvider ?? TimeProvider.System; + _gameClock = gameClock; DataCache = dataCache ?? PhysicsDataCache.CreateProduction(); Engine = new PhysicsEngine { @@ -1032,15 +1063,18 @@ public sealed class RuntimePhysicsState : IDisposable Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; Engine.ShadowObjects.OwnerPrefixMembershipChanged += OnCollisionOwnerPrefixMembershipChanged; + SetPosition = new RuntimeSetPositionState(this, Entities); } internal RuntimePhysicsState( RuntimeEntityDirectory entities, PhysicsEngine engine, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGameRuntimeClock? gameClock = null) { Entities = entities ?? throw new ArgumentNullException(nameof(entities)); _timeProvider = timeProvider ?? TimeProvider.System; + _gameClock = gameClock; Engine = engine ?? throw new ArgumentNullException(nameof(engine)); DataCache = engine.DataCache ?? PhysicsDataCache.CreateProduction(engine.CollisionWorld); @@ -1048,11 +1082,13 @@ public sealed class RuntimePhysicsState : IDisposable Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; Engine.ShadowObjects.OwnerPrefixMembershipChanged += OnCollisionOwnerPrefixMembershipChanged; + SetPosition = new RuntimeSetPositionState(this, Entities); } internal RuntimeEntityDirectory Entities { get; } public PhysicsEngine Engine { get; } public PhysicsDataCache DataCache { get; } + internal RuntimeSetPositionState SetPosition { get; } public int SpatialRootCount => _spatialRoots.Count; public int SpatialRemoteCount => _spatialRemotes.Count; public int SpatialProjectileCount => _spatialProjectiles.Count; @@ -1061,18 +1097,41 @@ public sealed class RuntimePhysicsState : IDisposable internal double UtcNowSeconds => (_timeProvider.GetUtcNow() - DateTimeOffset.UnixEpoch) .TotalSeconds; + internal double MonotonicNowSeconds => + _timeProvider.GetTimestamp() + / (double)_timeProvider.TimestampFrequency; + internal double PlacementSimulationTime(double fallback) => + _gameClock?.SimulationTimeSeconds ?? fallback; - public RuntimePhysicsOwnershipSnapshot CaptureOwnership() => - new( + public RuntimePhysicsOwnershipSnapshot CaptureOwnership() + { + RuntimeSetPositionOwnershipSnapshot setPosition = + SetPosition.CaptureOwnership(); + return new( Engine.LandblockCount, Engine.ShadowObjects.RetainedRegistrationCount, _spatialRoots.Count, _spatialRemotes.Count, _spatialProjectiles.Count, + setPosition.ActiveOperationCount, + setPosition.AwaitingPreparationCount, + setPosition.DeferredCellCount, + setPosition.PendingProjectionAcknowledgementCount, + setPosition.LostDeadlineCount, + setPosition.LostDeadlineNodeCount, + setPosition.LostDeadlineIndexCount, + setPosition.ExpiredLostCellCount, + setPosition.ExpiredLostCellIndexCount, + setPosition.DeferredBucketCount, + setPosition.DeferredBucketOrderCount, + setPosition.UnboundDeferredCellCount, + setPosition.UnboundDeferredCellOrderCount, + setPosition.PreparedMoverCount, _collisionAdmissions.Count, _collisionGenerations.Count, ReferenceEquals(Engine.DataCache, DataCache), _disposed); + } public void AcknowledgeSpatialProjection( RuntimeEntityRecord record, @@ -1806,6 +1865,20 @@ public sealed class RuntimePhysicsState : IDisposable EnsureNotDisposed(); EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); + if (_collisionAdmissions.Remove( + canonical, + out RuntimeCollisionAdmission? superseded)) + { + SetPosition.CancelCollisionGeneration( + canonical, + superseded.Generation); + if (_preparedCollisionGenerations.Remove( + canonical, + out PreparedLandblockCollisionGeneration? prepared)) + { + prepared.Dispose(); + } + } ulong generation = _collisionGenerations.TryGetValue( canonical, out ulong current) @@ -1817,6 +1890,7 @@ public sealed class RuntimePhysicsState : IDisposable canonical, generation); _collisionAdmissions[canonical] = admission; + SetPosition.BeginCollisionGeneration(canonical, generation); return admission; } @@ -1901,6 +1975,9 @@ public sealed class RuntimePhysicsState : IDisposable && ReferenceEquals(current, admission)) { _collisionAdmissions.Remove(admission.LandblockId); + SetPosition.CancelCollisionGeneration( + admission.LandblockId, + admission.Generation); _collisionGenerations[admission.LandblockId] = checked( admission.Generation + 1UL); } @@ -2137,6 +2214,10 @@ public sealed class RuntimePhysicsState : IDisposable admission.Generation, Engine.IsLandblockTerrainResident(admission.LandblockId), Ready: Engine.IsLandblockTerrainResident(admission.LandblockId)); + SetPosition.CommitCollisionGeneration( + acknowledgement.LandblockId, + acknowledgement.Generation, + acknowledgement.Ready); PublishCollisionGenerationCommitted( new RuntimeCollisionGenerationCommitted( acknowledgement.LandblockId, @@ -2221,6 +2302,7 @@ public sealed class RuntimePhysicsState : IDisposable prepared.Dispose(); } _preparedCollisionGenerations.Clear(); + SetPosition.Dispose(); _collisionOwnerJournal.Clear(); _collisionOwnerSubscribers.Clear(); Engine.Clear(); @@ -2380,12 +2462,22 @@ public sealed class RuntimePhysicsState : IDisposable private void InvalidateCollisionAdmission(uint landblockId) { - ulong generation = _collisionGenerations.TryGetValue( + ulong currentGeneration = _collisionGenerations.TryGetValue( landblockId, out ulong current) - ? checked(current + 1UL) - : 1UL; + ? current + : 0UL; + ulong invalidatedGeneration = _collisionAdmissions.TryGetValue( + landblockId, + out RuntimeCollisionAdmission? admission) + ? admission.Generation + : checked(currentGeneration + 1UL); + ulong generation = checked( + Math.Max(currentGeneration, invalidatedGeneration) + 1UL); _collisionGenerations[landblockId] = generation; + SetPosition.CancelCollisionGeneration( + landblockId, + invalidatedGeneration); _collisionAdmissions.Remove(landblockId); if (_preparedCollisionGenerations.Remove( landblockId, @@ -2396,6 +2488,70 @@ public sealed class RuntimePhysicsState : IDisposable TrimCollisionOwnerJournal(); } + internal ulong ExpectedCollisionGeneration(uint exactCellId) + { + uint landblockId = CanonicalLandblock(exactCellId); + if (landblockId == 0u) + return 0UL; + if (_collisionAdmissions.TryGetValue( + landblockId, + out RuntimeCollisionAdmission? admission)) + { + return admission.Generation; + } + return _collisionGenerations.TryGetValue( + landblockId, + out ulong generation) + ? checked(generation + 1UL) + : 1UL; + } + + internal bool HandleSetPositionCollisions( + RuntimeEntityRecord record, + ulong positionAuthorityVersion, + ulong spatialAuthorityVersion, + ulong velocityAuthorityVersion, + bool previousContact, + bool previousOnWalkable, + in PhysicsSetPositionCollisionReport report) + { + if (!Entities.IsCurrent(record) + || record.PositionAuthorityVersion != positionAuthorityVersion + || record.SpatialAuthorityVersion != spatialAuthorityVersion + || (velocityAuthorityVersion != 0UL + && record.VelocityAuthorityVersion + != velocityAuthorityVersion) + || record.PhysicsBody is not { } body) + { + return false; + } + + body.FramesStationaryFall = report.FramesStationaryFall; + PhysicsObjUpdate.HandleAllCollisions( + body, + report.CollisionNormalValid, + report.CollisionNormal, + previousContact, + previousOnWalkable, + body.OnWalkable); + body.TransientState &= ~(TransientStateFlags.StationaryFall + | TransientStateFlags.StationaryStop + | TransientStateFlags.StationaryStuck); + body.TransientState |= report.FramesStationaryFall switch + { + 1 => TransientStateFlags.StationaryFall, + 2 => TransientStateFlags.StationaryStop, + 3 => TransientStateFlags.StationaryStuck, + _ => TransientStateFlags.None, + }; + // Retail returns the result of collision reporting, not a collision- + // presence guess. Runtime does not yet own the per-object report/ + // tracking table required to reproduce that return value, so fail + // closed. This preserves ordinary placement rejection and leaves the + // already-registered reporting seam explicit for the 4B2 cutover. + return false; + } + private void OnCollisionOwnerMutated(uint ownerId, ulong version) { _ = version; diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs new file mode 100644 index 00000000..a4381184 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -0,0 +1,2014 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Physics; + +internal enum RuntimeSetPositionOperationKind +{ + InitialLogin, + LocalAuthoritative, + RemoteAuthoritative, + ProjectileAuthoritative, +} + +internal enum RuntimeSetPositionStatus +{ + Rejected, + DeferredCell, + CommittedHostAcknowledgementPending, + Committed, + Cancelled, +} + +internal enum RuntimeEntityPlacementStage +{ + AwaitingPreparation, + AwaitingWithdrawalAcknowledgement, + AwaitingCell, + AwaitingCommitAcknowledgement, + CancelledAwaitingAcknowledgement, +} + +internal readonly record struct RuntimeEntityPlacementToken( + ulong SessionLifetimeVersion, + RuntimeEntityKey Entity, + ulong PositionAuthorityVersion, + ulong OperationId) +{ + internal bool IsValid => OperationId != 0UL + && Entity.LocalEntityId != 0u + && PositionAuthorityVersion != 0UL; +} + +public enum RuntimePlacementProjectionKind +{ + Withdraw, + Place, + Discard, +} + +public readonly record struct RuntimePortalPlacementAuthority( + bool Present, + long RevealGeneration, + ushort TeleportSequence, + RuntimeWorldHostProjectionToken Projection) +{ + internal bool IsEmpty => !Present + && RevealGeneration == 0 + && TeleportSequence == 0 + && Projection == default; + internal bool IsValid => Present + && RevealGeneration != 0 + && Projection.IsValid + && Projection.Generation == RevealGeneration; +} + +internal readonly record struct RuntimeSetPositionCommand( + PhysicsSetPositionRequest Physics, + RuntimeSetPositionOperationKind Kind, + double GameTime, + ulong ExpectedVelocityAuthorityVersion, + float ShadowWorldOffsetX = 0f, + float ShadowWorldOffsetY = 0f, + RuntimePortalPlacementAuthority Portal = default); + +public readonly record struct RuntimePlacementProjectionToken( + ulong Sequence, + ulong Revision, + RuntimeEntityKey Entity, + ulong PositionAuthorityVersion, + ulong SpatialAuthorityVersion, + ulong PlacementCommitVersion, + ulong SessionLifetimeVersion, + uint ExactCellId, + ulong CollisionGeneration, + RuntimePortalPlacementAuthority Portal) +{ + internal bool IsValid => Sequence != 0 + && Entity.LocalEntityId != 0u + && PositionAuthorityVersion != 0UL; +} + +public readonly record struct RuntimePlacementProjectionSnapshot( + RuntimePlacementProjectionToken Token, + RuntimePlacementProjectionKind Kind, + Vector3 WorldPosition, + Quaternion Orientation, + Vector3 CellLocalPosition, + bool InContact, + bool OnWalkable); + +internal readonly record struct RuntimeSetPositionOutcome( + RuntimeSetPositionStatus Status, + PhysicsSetPositionError Error, + PhysicsResidenceDisposition Residence, + uint ExactCellId, + RuntimePlacementProjectionToken Projection) +{ + internal bool Accepted => Error == PhysicsSetPositionError.Ok; +} + +internal readonly record struct RuntimePlacementCancellationReceipt( + RuntimePlacementProjectionSnapshot Projection) +{ + internal bool IsValid => Projection.Kind + is RuntimePlacementProjectionKind.Discard + && Projection.Token.IsValid; +} + +internal readonly record struct RuntimeSetPositionOwnershipSnapshot( + int ActiveOperationCount, + int AwaitingPreparationCount, + int DeferredCellCount, + int PendingProjectionAcknowledgementCount, + int LostDeadlineCount, + int LostDeadlineNodeCount, + int LostDeadlineIndexCount, + int ExpiredLostCellCount, + int ExpiredLostCellIndexCount, + int DeferredBucketCount, + int DeferredBucketOrderCount, + int UnboundDeferredCellCount, + int UnboundDeferredCellOrderCount, + int PreparedMoverCount) +{ + internal bool IndexesConsistent => + LostDeadlineCount == LostDeadlineNodeCount + && LostDeadlineCount == LostDeadlineIndexCount + && ExpiredLostCellCount == ExpiredLostCellIndexCount + && DeferredBucketCount == DeferredBucketOrderCount + && UnboundDeferredCellCount == UnboundDeferredCellOrderCount; + + internal bool IsConverged => ActiveOperationCount == 0 + && AwaitingPreparationCount == 0 + && DeferredCellCount == 0 + && PendingProjectionAcknowledgementCount == 0 + && LostDeadlineCount == 0 + && LostDeadlineNodeCount == 0 + && LostDeadlineIndexCount == 0 + && ExpiredLostCellCount == 0 + && ExpiredLostCellIndexCount == 0 + && DeferredBucketCount == 0 + && DeferredBucketOrderCount == 0 + && UnboundDeferredCellCount == 0 + && UnboundDeferredCellOrderCount == 0 + && PreparedMoverCount == 0; +} + +/// +/// Presentation-independent owner of retail SetPosition residence. Runtime +/// commits the complete Core packet before publishing an immutable projection +/// token. A host can retry the exact token until acknowledged; no App delegate +/// is retained and no presentation callback can duplicate simulation state. +/// +internal sealed class RuntimeSetPositionState : IDisposable +{ + // Retail accepts an unsigned num_tries and performs the attempts in one + // synchronous call. Runtime preparation is an authority boundary, so an + // untrusted uint.MaxValue must not turn that verbatim loop into a frame- + // thread denial of service. Authored retail callers use small counts; + // 64 preserves that shape while making the synchronous work finite. + private const uint MaxSynchronousScatterAttempts = 64u; + + private readonly record struct CellGenerationKey( + uint CellId, + ulong CollisionGeneration); + + private sealed class Operation + { + internal required RuntimeEntityRecord Record { get; init; } + internal PhysicsBody? Body { get; set; } + internal required RuntimeEntityPlacementToken Token { get; init; } + internal required RuntimeEntityKey Key { get; init; } + internal required ulong PositionAuthorityVersion { get; init; } + internal required ulong SessionLifetimeVersion { get; init; } + internal required ulong SourceSpatialAuthorityVersion { get; init; } + internal required ulong SourceVelocityAuthorityVersion { get; set; } + internal required bool PreviousContact { get; set; } + internal required bool PreviousOnWalkable { get; set; } + internal required RuntimeSetPositionCommand Command { get; set; } + internal PhysicsSetPositionResult Result { get; set; } + internal ulong SpatialAuthorityVersion { get; set; } + internal ulong PlacementCommitVersion { get; set; } + internal uint ExactCellId { get; set; } + internal ulong CollisionGeneration { get; set; } + internal bool WithdrawalAcknowledged { get; set; } + internal bool CollisionGenerationReady { get; set; } + internal ulong ProjectionSequence { get; set; } + internal bool WakeableLostCell { get; set; } + internal RuntimeEntityPlacementStage Stage { get; set; } + internal RuntimeSetPositionOperationKind Kind { get; init; } + internal RuntimePortalPlacementAuthority Portal { get; init; } + internal bool RequiresPreparation { get; set; } + internal bool Expired { get; set; } + internal List? LostFamilyKeys { get; set; } + internal bool InheritedLostDeadline { get; set; } + internal bool EnteringWorldFromCelllessResidence { get; set; } + internal RuntimeSetPositionCommand? PreparedCommandAwaitingWithdrawalAck + { + get; + set; + } + } + + private readonly RuntimePhysicsState _physics; + private readonly RuntimeEntityDirectory _entities; + private readonly Dictionary _operations = []; + private readonly Dictionary> + _deferredByCellGeneration = []; + private readonly SortedDictionary + _pendingProjection = []; + private readonly List _deferredBucketOrder = []; + private readonly Dictionary> + _unboundDeferredByCell = []; + private readonly List _unboundDeferredCellOrder = []; + private readonly Dictionary _lostDeadlines = []; + private readonly List _lostDeadlineNodes = []; + private readonly Dictionary + _lostDeadlineNodeIndex = []; + private readonly Dictionary + _preparedMovers = []; + private readonly LinkedList _expiredLostCells = []; + private readonly Dictionary> + _expiredLostCellNodes = []; + private ulong _nextProjectionSequence; + private ulong _nextOperationId; + private ulong _nextLostDeadlineSequence; + private RuntimeEntityObjectEventStream? _events; + private bool _disposed; + + private readonly record struct LostDeadlineEntry( + RuntimeEntityKey Key, + double Deadline, + ulong Sequence); + + internal RuntimeSetPositionState( + RuntimePhysicsState physics, + RuntimeEntityDirectory entities) + { + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + } + + internal RuntimeSetPositionOwnershipSnapshot CaptureOwnership() + { + int deferred = 0; + int awaitingPreparation = 0; + foreach (Operation operation in _operations.Values) + { + if (operation.Stage + is RuntimeEntityPlacementStage.AwaitingPreparation) + { + awaitingPreparation++; + } + if (operation.WakeableLostCell) + deferred++; + } + return new RuntimeSetPositionOwnershipSnapshot( + _operations.Count, + awaitingPreparation, + deferred, + _pendingProjection.Count, + _lostDeadlines.Count, + _lostDeadlineNodes.Count, + _lostDeadlineNodeIndex.Count, + _expiredLostCells.Count, + _expiredLostCellNodes.Count, + _deferredByCellGeneration.Count, + _deferredBucketOrder.Count, + _unboundDeferredByCell.Count, + _unboundDeferredCellOrder.Count, + _preparedMovers.Count); + } + + internal void BindEventStream(RuntimeEntityObjectEventStream events) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(events); + if (_events is not null) + throw new InvalidOperationException( + "The Runtime placement event stream is already bound."); + _events = events; + } + + internal void RetryPendingProjections() + { + EnsureNotDisposed(); + RuntimePlacementProjectionSnapshot[] snapshot = + _pendingProjection.Values.ToArray(); + for (int index = 0; index < snapshot.Length; index++) + { + RuntimePlacementProjectionSnapshot projection = snapshot[index]; + if (_pendingProjection.TryGetValue( + projection.Token.Sequence, + out RuntimePlacementProjectionSnapshot current) + && current == projection) + { + PublishPlacement(projection); + } + } + } + + internal void ResetSession() + { + EnsureNotDisposed(); + ClearOwnedState(); + } + + internal RuntimeSetPositionOutcome Apply( + RuntimeEntityRecord record, + ulong expectedPositionAuthorityVersion, + in RuntimeSetPositionCommand command) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + RuntimeEntityPlacementToken token = BeginAcceptedPlacement( + record, + expectedPositionAuthorityVersion, + command.Kind, + command.Portal); + if (!token.IsValid) + { + return Rejected(command.Physics); + } + + return SubmitPreparedPlacement(token, command); + } + + internal RuntimeEntityPlacementToken BeginAcceptedPlacement( + RuntimeEntityRecord record, + ulong expectedPositionAuthorityVersion, + RuntimeSetPositionOperationKind kind, + RuntimePortalPlacementAuthority portal = default) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key + || !_entities.IsCurrent(record) + || record.PositionAuthorityVersion + != expectedPositionAuthorityVersion + || !(portal.IsEmpty + || (portal.IsValid + && kind is RuntimeSetPositionOperationKind + .LocalAuthoritative))) + { + return default; + } + + var token = new RuntimeEntityPlacementToken( + _entities.SessionLifetimeVersion, + key, + expectedPositionAuthorityVersion, + checked(++_nextOperationId)); + var replacement = new Operation + { + Record = record, + Token = token, + Key = key, + PositionAuthorityVersion = expectedPositionAuthorityVersion, + SessionLifetimeVersion = _entities.SessionLifetimeVersion, + SourceSpatialAuthorityVersion = record.SpatialAuthorityVersion, + SourceVelocityAuthorityVersion = record.VelocityAuthorityVersion, + PreviousContact = record.PhysicsBody?.InContact ?? false, + PreviousOnWalkable = record.PhysicsBody?.OnWalkable ?? false, + Command = default, + Result = default, + SpatialAuthorityVersion = record.SpatialAuthorityVersion, + PlacementCommitVersion = record.PlacementCommitVersion, + Stage = RuntimeEntityPlacementStage.AwaitingPreparation, + Kind = kind, + Portal = portal, + }; + List? inheritedLostFamily = null; + RuntimePlacementProjectionSnapshot? inheritedWithdrawal = null; + bool inheritedWithdrawalAcknowledged = false; + if (_operations.TryGetValue(key, out Operation? displaced) + && (displaced.WakeableLostCell + || displaced.InheritedLostDeadline)) + { + inheritedLostFamily = displaced.LostFamilyKeys; + displaced.LostFamilyKeys = null; + inheritedWithdrawalAcknowledged = + displaced.WithdrawalAcknowledged; + if (displaced.ProjectionSequence != 0UL + && _pendingProjection.TryGetValue( + displaced.ProjectionSequence, + out RuntimePlacementProjectionSnapshot pendingWithdrawal) + && pendingWithdrawal.Kind + is RuntimePlacementProjectionKind.Withdraw) + { + inheritedWithdrawal = pendingWithdrawal; + displaced.ProjectionSequence = 0UL; + } + } + _ = CancelCoreDeferred( + key, + cancelLostFamily: false, + preserveLostFamily: inheritedLostFamily is not null, + out RuntimePlacementProjectionSnapshot? discard); + replacement.LostFamilyKeys = inheritedLostFamily; + replacement.InheritedLostDeadline = inheritedLostFamily is not null; + replacement.WithdrawalAcknowledged = inheritedWithdrawalAcknowledged; + if (inheritedWithdrawal is { } retainedWithdrawal) + { + replacement.ProjectionSequence = + retainedWithdrawal.Token.Sequence; + replacement.Result = displaced!.Result; + replacement.ExactCellId = displaced.ExactCellId; + replacement.CollisionGeneration = + displaced.CollisionGeneration; + } + _operations[key] = replacement; + if (discard is { } cancelled) + PublishPlacement(cancelled); + return IsCurrent(replacement) ? token : default; + } + + internal RuntimeSetPositionOutcome SubmitPreparedPlacement( + in RuntimeEntityPlacementToken token, + in RuntimeSetPositionCommand command) + { + EnsureNotDisposed(); + Operation? operation = null; + bool ownsToken = token.IsValid + && _operations.TryGetValue(token.Entity, out operation) + && operation.Token == token; + if (!ownsToken + || operation is null + || operation.Stage + is not RuntimeEntityPlacementStage.AwaitingPreparation + || !IsCurrent(operation) + || operation.Record.PhysicsBody is not { } body + || !double.IsFinite(command.GameTime) + || command.Kind != operation.Kind + || command.Portal != operation.Portal + || (command.ExpectedVelocityAuthorityVersion != 0UL + && operation.Record.VelocityAuthorityVersion + != command.ExpectedVelocityAuthorityVersion)) + { + // Preparation is an accepted-operation input, not the operation + // itself. A malformed or temporarily incomplete preparation must + // remain retryable under the exact token and cannot discard a + // withdrawn resident or its last validated mover shape. + return Rejected(command.Physics); + } + + operation.Body = body; + operation.EnteringWorldFromCelllessResidence |= + !body.InWorld || operation.Record.FullCellId == 0u; + if (command.ExpectedVelocityAuthorityVersion != 0UL) + { + operation.SourceVelocityAuthorityVersion = + command.ExpectedVelocityAuthorityVersion; + } + operation.PreviousContact = body.InContact; + operation.PreviousOnWalkable = body.OnWalkable; + var canonicalRequest = command.Physics with + { + Position = operation.WakeableLostCell + ? operation.Result.Position + : command.Physics.Position, + Orientation = operation.WakeableLostCell + ? operation.Result.Orientation + : command.Physics.Orientation, + CellId = operation.WakeableLostCell + ? operation.ExactCellId + : command.Physics.CellId, + CellLocalPosition = operation.WakeableLostCell + ? operation.Result.CellLocalPosition + : command.Physics.CellLocalPosition, + MoverPhysicsState = operation.Record.FinalPhysicsState, + MovingEntityId = operation.Key.LocalEntityId, + CurrentCellId = operation.WakeableLostCell + ? null + : body.InWorld + && operation.Record.FullCellId != 0u + ? operation.Record.FullCellId + : null, + }; + var canonicalCommand = command with { Physics = canonicalRequest }; + operation.Command = canonicalCommand; + + if (operation.InheritedLostDeadline + && !operation.WithdrawalAcknowledged + && operation.ProjectionSequence != 0UL) + { + operation.PreparedCommandAwaitingWithdrawalAck = + canonicalCommand; + operation.Stage = RuntimeEntityPlacementStage + .AwaitingWithdrawalAcknowledgement; + return Outcome( + RuntimeSetPositionStatus.DeferredCell, + operation.Result, + _pendingProjection.TryGetValue( + operation.ProjectionSequence, + out RuntimePlacementProjectionSnapshot pending) + ? pending.Token + : default); + } + + if (operation.WakeableLostCell) + { + operation.RequiresPreparation = false; + operation.Stage = RuntimeEntityPlacementStage.AwaitingCell; + if (operation.WithdrawalAcknowledged + && operation.CollisionGenerationReady + && operation.ProjectionSequence == 0UL) + { + RetryDeferred(operation); + } + return Outcome( + RuntimeSetPositionStatus.DeferredCell, + operation.Result, + operation.ProjectionSequence != 0UL + && _pendingProjection.TryGetValue( + operation.ProjectionSequence, + out RuntimePlacementProjectionSnapshot pending) + ? pending.Token + : default); + } + + if (!IsStructurallyValid(canonicalRequest)) + { + PhysicsSetPositionResult invalid = InvalidResult(canonicalRequest); + operation.Result = invalid; + operation.RequiresPreparation = true; + operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation; + return Outcome(RuntimeSetPositionStatus.Rejected, invalid, default); + } + + PhysicsSetPositionResult result = + _physics.Engine.SetPosition( + canonicalRequest, + report => _physics.HandleSetPositionCollisions( + operation.Record, + operation.PositionAuthorityVersion, + operation.SourceSpatialAuthorityVersion, + operation.SourceVelocityAuthorityVersion, + operation.PreviousContact, + operation.PreviousOnWalkable, + report)); + operation.Result = result; + if (!result.IsSuccessful) + { + operation.RequiresPreparation = true; + operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation; + return Outcome(RuntimeSetPositionStatus.Rejected, result, default); + } + + if (!IsCurrent(operation)) + return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); + operation.RequiresPreparation = false; + operation.ExactCellId = result.CellId; + _preparedMovers[operation.Key] = canonicalRequest; + + if (result.IsDeferred) + return ParkDeferred(operation, result); + + if (!CommitCanonical(operation, result)) + { + PublishCancellation(CancelCore(operation)); + return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); + } + + operation.Stage = RuntimeEntityPlacementStage + .AwaitingCommitAcknowledgement; + RuntimePlacementProjectionToken projection = PublishProjection( + operation, + RuntimePlacementProjectionKind.Place, + result); + return Outcome( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + result, + projection); + } + + internal bool TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection) + { + EnsureNotDisposed(); + if (_pendingProjection.Count == 0) + { + projection = default; + return false; + } + projection = _pendingProjection.First().Value; + return true; + } + + internal bool AcknowledgeProjection( + in RuntimePlacementProjectionToken token) + { + EnsureNotDisposed(); + if (!token.IsValid + || _pendingProjection.Count == 0 + || _pendingProjection.First().Key != token.Sequence + || !_pendingProjection.TryGetValue( + token.Sequence, + out RuntimePlacementProjectionSnapshot pending) + || pending.Token != token) + { + return false; + } + if (pending.Kind is RuntimePlacementProjectionKind.Discard) + { + _pendingProjection.Remove(token.Sequence); + return true; + } + if (!_operations.TryGetValue( + token.Entity, + out Operation? operation) + || operation.ProjectionSequence != token.Sequence + || !IsCurrent(operation) + || operation.SpatialAuthorityVersion + != token.SpatialAuthorityVersion) + { + return false; + } + + _pendingProjection.Remove(token.Sequence); + operation.ProjectionSequence = 0UL; + if (pending.Kind is RuntimePlacementProjectionKind.Place) + { + operation.Stage = RuntimeEntityPlacementStage.AwaitingCommitAcknowledgement; + return _operations.Remove(operation.Key); + } + if (!operation.WakeableLostCell + && !operation.InheritedLostDeadline) + { + return _operations.Remove(operation.Key); + } + + operation.WithdrawalAcknowledged = true; + operation.Stage = operation.RequiresPreparation + || operation.InheritedLostDeadline + ? RuntimeEntityPlacementStage.AwaitingPreparation + : RuntimeEntityPlacementStage.AwaitingCell; + if (operation.PreparedCommandAwaitingWithdrawalAck is { } prepared) + { + operation.PreparedCommandAwaitingWithdrawalAck = null; + operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation; + _ = SubmitPreparedPlacement(operation.Token, prepared); + return true; + } + if (operation.CollisionGenerationReady) + RetryDeferred(operation); + return true; + } + + /// + /// Runs retail's 25-second lost-cell destruction timer. Expired exact + /// incarnation keys are consumed by the entity-lifetime owner; no host + /// callback or GUID-only deletion can cross a replacement generation. + /// + internal void TickLostCellDeadlines() + { + EnsureNotDisposed(); + if (_lostDeadlineNodes.Count == 0) + return; + double now = _physics.MonotonicNowSeconds; + while (_lostDeadlineNodes.Count != 0) + { + LostDeadlineEntry entry = _lostDeadlineNodes[0]; + if (entry.Deadline > now) + break; + RemoveLostDeadlineNodeAt(0); + if (!_lostDeadlines.Remove(entry.Key)) + throw new InvalidOperationException( + "The lost-cell deadline index diverged from its exact key owner."); + if (_operations.TryGetValue( + entry.Key, + out Operation? operation)) + operation.Expired = true; + if (_entities.TryGetByLocalId( + entry.Key.LocalEntityId, + out RuntimeEntityRecord record) + && record.Key == entry.Key) + { + if (!_expiredLostCellNodes.ContainsKey(entry.Key)) + { + LinkedListNode node = + _expiredLostCells.AddLast(entry.Key); + _expiredLostCellNodes.Add(entry.Key, node); + } + } + } + } + + internal bool TryDequeueExpiredLostCell(out RuntimeEntityKey key) + { + EnsureNotDisposed(); + if (_expiredLostCells.First is not { } first) + { + key = default; + return false; + } + key = first.Value; + _expiredLostCells.RemoveFirst(); + _expiredLostCellNodes.Remove(key); + return true; + } + + internal bool Cancel(RuntimeEntityRecord record, bool publishWithdrawal) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key) + return false; + + bool removed = CancelCoreDeferred( + key, + cancelLostFamily: false, + preserveLostFamily: false, + out RuntimePlacementProjectionSnapshot? discard); + if (!publishWithdrawal || !_entities.IsCurrent(record)) + { + if (discard is { } cancelled) + PublishPlacement(cancelled); + return removed; + } + + LeaveWorldCanonical(record); + if (record.PhysicsBody is null) + { + if (discard is { } cancelledBodyless) + PublishPlacement(cancelledBodyless); + return removed; + } + var operation = CreateWithdrawalOperation(record, key); + _operations[key] = operation; + if (discard is { } cancelledOld) + PublishPlacement(cancelledOld); + if (!IsCurrent(operation)) + return true; + _ = PublishProjection( + operation, + RuntimePlacementProjectionKind.Withdraw, + operation.Result); + return true; + } + + internal RuntimePlacementCancellationReceipt Forget( + RuntimeEntityRecord record, + bool releasePreparedMover = false) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + RuntimePlacementCancellationReceipt receipt = default; + if (record.Key is { } key) + { + receipt = CancelCore(key); + if (releasePreparedMover) + _preparedMovers.Remove(key); + } + return receipt; + } + + internal void LeaveWorld(RuntimeEntityRecord record) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + RuntimePlacementCancellationReceipt receipt = record.Key is { } key + ? CancelCore(key) + : default; + if (_entities.IsCurrent(record)) + LeaveWorldCanonical(record); + PublishCancellation(receipt); + } + + internal bool IsDeferred(RuntimeEntityRecord record) => + record.Key is { } key + && _operations.TryGetValue(key, out Operation? operation) + && ReferenceEquals(operation.Record, record) + && operation.WakeableLostCell; + + internal bool TryGetPreparedMoverSphereCount( + RuntimeEntityRecord record, + out int sphereCount) + { + EnsureNotDisposed(); + if (record.Key is { } key + && _preparedMovers.TryGetValue( + key, + out PhysicsSetPositionRequest request)) + { + sphereCount = request.Spheres.Length; + return true; + } + sphereCount = 0; + return false; + } + + internal bool TryGetAwaitingPreparationToken( + RuntimeEntityRecord record, + out RuntimeEntityPlacementToken token) + { + EnsureNotDisposed(); + if (record.Key is { } key + && _operations.TryGetValue(key, out Operation? operation) + && ReferenceEquals(operation.Record, record) + && operation.RequiresPreparation) + { + token = operation.Token; + return true; + } + token = default; + return false; + } + + internal void ParkCollisionResidents( + uint landblockId, + bool includeOutdoorCells) + { + EnsureNotDisposed(); + uint prefix = landblockId & 0xFFFF0000u; + var roots = new List(); + _physics.CopySpatialRootsTo(roots); + for (int index = 0; index < roots.Count; index++) + { + RuntimeEntityRecord record = roots[index]; + if (IsAffectedCollisionResident( + record, + prefix, + includeOutdoorCells) + && record.Key is { } key + && _operations.ContainsKey(key)) + { + throw new InvalidOperationException( + $"Collision retirement for 0x{prefix:X8} cannot overlap active placement for 0x{record.ServerGuid:X8}/{record.Incarnation}."); + } + } + var stagedWithdrawals = new List( + roots.Count); + for (int index = 0; index < roots.Count; index++) + { + RuntimeEntityRecord record = roots[index]; + uint cellId = record.FullCellId; + if (!IsAffectedCollisionResident( + record, + prefix, + includeOutdoorCells) + || record.Key is not { } key + || record.PhysicsBody is not { } body) + { + continue; + } + + PublishCancellation(CancelCore(key)); + bool hasPrepared = _preparedMovers.TryGetValue( + key, + out PhysicsSetPositionRequest prepared); + var request = (hasPrepared + ? prepared + : new PhysicsSetPositionRequest( + body.Position, + body.Orientation, + cellId, + body.CellPosition.Frame.Origin, + ImmutableArray.Empty, + 1f, + 0f, + 0f)) with + { + Position = body.Position, + Orientation = body.Orientation, + CellId = cellId, + CellLocalPosition = body.CellPosition.Frame.Origin, + MoverPhysicsState = record.FinalPhysicsState, + MovingEntityId = key.LocalEntityId, + CurrentCellId = null, + }; + var result = new PhysicsSetPositionResult( + PhysicsSetPositionError.Ok, + PhysicsResidenceDisposition.DeferredCell, + body.Position, + body.Orientation, + cellId, + body.CellPosition.Frame.Origin, + InContact: body.InContact, + OnWalkable: body.OnWalkable, + ContactPlane: body.ContactPlane, + ContactPlaneCellId: body.ContactPlaneCellId, + ContactPlaneIsWater: body.ContactPlaneIsWater, + SlidingNormalValid: body.SlidingNormal != Vector3.Zero, + SlidingNormal: body.SlidingNormal, + FramesStationaryFall: body.FramesStationaryFall, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: ImmutableArray.Empty); + var command = new RuntimeSetPositionCommand( + request, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + body.LastUpdateTime, + record.VelocityAuthorityVersion); + var operation = new Operation + { + Record = record, + Body = body, + Token = new RuntimeEntityPlacementToken( + _entities.SessionLifetimeVersion, + key, + record.PositionAuthorityVersion, + checked(++_nextOperationId)), + Key = key, + PositionAuthorityVersion = record.PositionAuthorityVersion, + SessionLifetimeVersion = _entities.SessionLifetimeVersion, + SourceSpatialAuthorityVersion = record.SpatialAuthorityVersion, + SourceVelocityAuthorityVersion = record.VelocityAuthorityVersion, + PreviousContact = body.InContact, + PreviousOnWalkable = body.OnWalkable, + Command = command, + Result = result, + SpatialAuthorityVersion = record.SpatialAuthorityVersion, + PlacementCommitVersion = record.PlacementCommitVersion, + ExactCellId = cellId, + Stage = RuntimeEntityPlacementStage.AwaitingPreparation, + Kind = RuntimeSetPositionOperationKind.RemoteAuthoritative, + Portal = default, + RequiresPreparation = !hasPrepared, + }; + _operations.Add(key, operation); + RuntimeSetPositionOutcome parked = ParkDeferred( + operation, + result, + publishImmediately: false); + if (_pendingProjection.TryGetValue( + parked.Projection.Sequence, + out RuntimePlacementProjectionSnapshot staged)) + { + stagedWithdrawals.Add(staged); + } + if (operation.RequiresPreparation) + { + operation.Stage = RuntimeEntityPlacementStage + .AwaitingPreparation; + } + } + + // Canonical residence and operation ownership for every affected root + // must be installed before a synchronous host observer can re-enter. + // A callback may legitimately replace or cancel a later root; publish + // only the exact snapshot that is still pending when its turn arrives. + for (int index = 0; index < stagedWithdrawals.Count; index++) + { + RuntimePlacementProjectionSnapshot staged = + stagedWithdrawals[index]; + if (_pendingProjection.TryGetValue( + staged.Token.Sequence, + out RuntimePlacementProjectionSnapshot current) + && current == staged) + { + PublishPlacement(staged); + } + } + } + + private bool IsAffectedCollisionResident( + RuntimeEntityRecord record, + uint prefix, + bool includeOutdoorCells) + { + uint cellId = record.FullCellId; + return (cellId & 0xFFFF0000u) == prefix + && (includeOutdoorCells || (cellId & 0xFFFFu) >= 0x0100u) + && (record.FinalPhysicsState & PhysicsStateFlags.Static) == 0 + && !_entities.ParentAttachments.HasCommittedParent( + record.ServerGuid); + } + + internal void BeginCollisionGeneration(uint landblockId, ulong generation) + { + EnsureNotDisposed(); + if (generation == 0UL) + throw new ArgumentOutOfRangeException(nameof(generation)); + uint prefix = landblockId & 0xFFFF0000u; + for (int index = 0; index < _unboundDeferredCellOrder.Count;) + { + uint cellId = _unboundDeferredCellOrder[index]; + if ((cellId & 0xFFFF0000u) != prefix + || !_unboundDeferredByCell.Remove( + cellId, + out List? retained)) + { + index++; + continue; + } + _unboundDeferredCellOrder.RemoveAt(index); + var bucket = new CellGenerationKey(cellId, generation); + var rebound = new List(retained.Count); + for (int entityIndex = 0; + entityIndex < retained.Count; + entityIndex++) + { + RuntimeEntityKey entity = retained[entityIndex]; + if (_operations.TryGetValue(entity, out Operation? operation) + && operation.WakeableLostCell + && operation.ExactCellId == cellId + && operation.CollisionGeneration == 0UL) + { + operation.CollisionGeneration = generation; + rebound.Add(entity); + } + } + if (rebound.Count != 0) + { + if (_deferredByCellGeneration.TryGetValue( + bucket, + out List? futureBound)) + { + // The unbound survivors entered lost residence before + // entities indexed directly into this future generation. + // Preserve both append orders while restoring that age. + var merged = new List( + rebound.Count + futureBound.Count); + merged.AddRange(rebound); + merged.AddRange(futureBound); + _deferredByCellGeneration[bucket] = merged; + } + else + { + _deferredByCellGeneration.Add(bucket, rebound); + _deferredBucketOrder.Add(bucket); + } + } + } + foreach (Operation operation in _operations.Values) + { + if (!operation.WakeableLostCell + || operation.CollisionGeneration != 0UL + || (operation.ExactCellId & 0xFFFF0000u) != prefix) + { + continue; + } + operation.CollisionGeneration = generation; + IndexDeferred(operation); + } + } + + internal void CancelCollisionGeneration(uint landblockId, ulong generation) + { + EnsureNotDisposed(); + if (_deferredBucketOrder.Count == 0) + return; + uint prefix = landblockId & 0xFFFF0000u; + CellGenerationKey[] matching = _deferredBucketOrder + .Where(key => key.CollisionGeneration == generation + && (key.CellId & 0xFFFF0000u) == prefix) + .ToArray(); + for (int index = 0; index < matching.Length; index++) + { + UnbindDeferredBucket(matching[index]); + } + } + + internal void CommitCollisionGeneration( + uint landblockId, + ulong generation, + bool ready) + { + EnsureNotDisposed(); + if (!ready || _deferredBucketOrder.Count == 0) + return; + + uint prefix = landblockId & 0xFFFF0000u; + var cells = new List(); + for (int index = 0; index < _deferredBucketOrder.Count; index++) + { + CellGenerationKey key = _deferredBucketOrder[index]; + if (key.CollisionGeneration == generation + && (key.CellId & 0xFFFF0000u) == prefix) + { + cells.Add(key); + } + } + foreach (CellGenerationKey cell in cells) + { + if (!_deferredByCellGeneration.TryGetValue( + cell, + out List? indexed)) + { + continue; + } + RuntimeEntityKey[] exact = indexed.ToArray(); + if (!_physics.Engine.IsSpawnCellReady(cell.CellId)) + { + UnbindDeferredBucket(cell); + continue; + } + RemoveDeferredBucket(cell); + foreach (RuntimeEntityKey entity in exact) + { + if (!_operations.TryGetValue( + entity, + out Operation? operation) + || !operation.WakeableLostCell + || operation.ExactCellId != cell.CellId + || operation.CollisionGeneration != generation) + { + continue; + } + operation.CollisionGenerationReady = true; + if (operation.WithdrawalAcknowledged) + RetryDeferred(operation); + } + } + } + + public void Dispose() + { + if (_disposed) + return; + ClearOwnedState(); + _events = null; + _disposed = true; + } + + private void ClearOwnedState() + { + _operations.Clear(); + _deferredByCellGeneration.Clear(); + _deferredBucketOrder.Clear(); + _unboundDeferredByCell.Clear(); + _unboundDeferredCellOrder.Clear(); + _lostDeadlines.Clear(); + _lostDeadlineNodes.Clear(); + _lostDeadlineNodeIndex.Clear(); + _preparedMovers.Clear(); + _pendingProjection.Clear(); + _expiredLostCells.Clear(); + _expiredLostCellNodes.Clear(); + } + + private RuntimeSetPositionOutcome ParkDeferred( + Operation operation, + in PhysicsSetPositionResult result, + bool publishImmediately = true) + { + PhysicsBody body = operation.Body!; + body.Orientation = result.Orientation; + body.SnapToCell( + result.CellId, + result.Position, + result.CellLocalPosition); + body.InWorld = false; + body.TransientState &= ~TransientStateFlags.Active; + if (operation.Record.RemoteMotion is IRuntimeRemotePlacement remote) + { + remote.LastServerPosition = result.Position; + // Remote-motion stale-age bookkeeping is in Unix-time seconds + // (RuntimeRemotePhysicsUpdater and the retained App network + // updater both consume this field in that clock domain). The + // placement command's GameTime is the instance simulation clock + // used only by PhysicsBody.LastUpdateTime. Mixing the two after + // a deferred wake makes a fresh server velocity look decades old. + remote.LastServerPositionTime = _physics.UtcNowSeconds; + remote.LastShadowSyncPosition = Vector3.Zero; + remote.LastShadowSyncOrientation = Quaternion.Zero; + } + + WithdrawCanonical(operation.Record); + _entities.SuspendObjectClock(operation.Record); + operation.SpatialAuthorityVersion = + operation.Record.SpatialAuthorityVersion; + _entities.AdvancePlacementCommit(operation.Record); + operation.PlacementCommitVersion = + operation.Record.PlacementCommitVersion; + operation.ExactCellId = result.CellId; + operation.WakeableLostCell = true; + operation.EnteringWorldFromCelllessResidence = true; + ArmLostFamilyDeadlines(operation); + operation.CollisionGeneration = _physics + .ExpectedCollisionGeneration(result.CellId); + operation.Command = operation.Command with + { + Physics = operation.Command.Physics with + { + Position = result.Position, + Orientation = result.Orientation, + CellId = result.CellId, + CellLocalPosition = result.CellLocalPosition, + CurrentCellId = null, + }, + }; + IndexDeferred(operation); + operation.Stage = operation.RequiresPreparation + ? RuntimeEntityPlacementStage.AwaitingPreparation + : RuntimeEntityPlacementStage.AwaitingWithdrawalAcknowledgement; + RuntimePlacementProjectionToken projection = PublishProjection( + operation, + RuntimePlacementProjectionKind.Withdraw, + result, + publishImmediately); + return Outcome( + RuntimeSetPositionStatus.DeferredCell, + result, + projection); + } + + private void RetryDeferred(Operation operation) + { + if (!IsCurrent(operation) + || !operation.WakeableLostCell + || operation.RequiresPreparation + || operation.Expired + || !operation.WithdrawalAcknowledged + || !operation.CollisionGenerationReady + || operation.ProjectionSequence != 0UL) + { + return; + } + + UnindexDeferred(operation); + operation.Command = operation.Command with + { + GameTime = _physics.PlacementSimulationTime( + operation.Command.GameTime), + }; + PhysicsSetPositionResult result = IsStructurallyValid( + operation.Command.Physics) + ? _physics.Engine.SetPosition( + operation.Command.Physics, + report => _physics.HandleSetPositionCollisions( + operation.Record, + operation.PositionAuthorityVersion, + operation.SpatialAuthorityVersion, + operation.SourceVelocityAuthorityVersion, + operation.PreviousContact, + operation.PreviousOnWalkable, + report)) + : InvalidResult(operation.Command.Physics); + operation.CollisionGenerationReady = false; + if (result.IsDeferred) + { + operation.Result = result; + operation.ExactCellId = result.CellId; + _preparedMovers[operation.Key] = operation.Command.Physics; + operation.CollisionGeneration = _physics + .ExpectedCollisionGeneration(result.CellId); + IndexDeferred(operation); + return; + } + if (!result.IsSuccessful) + { + if (result.Error is PhysicsSetPositionError.InvalidArguments) + { + operation.RequiresPreparation = true; + operation.Stage = RuntimeEntityPlacementStage + .AwaitingPreparation; + operation.CollisionGeneration = _physics + .ExpectedCollisionGeneration(operation.ExactCellId); + IndexDeferred(operation); + } + else + { + operation.Stage = RuntimeEntityPlacementStage.AwaitingCell; + operation.CollisionGeneration = _physics + .ExpectedCollisionGeneration(operation.ExactCellId); + IndexDeferred(operation); + } + return; + } + if (!IsCurrent(operation)) + return; + _preparedMovers[operation.Key] = operation.Command.Physics; + if (!CommitCanonical(operation, result)) + { + PublishCancellation(CancelCore(operation)); + return; + } + + operation.Stage = RuntimeEntityPlacementStage + .AwaitingCommitAcknowledgement; + _ = PublishProjection( + operation, + RuntimePlacementProjectionKind.Place, + result); + } + + private bool CommitCanonical( + Operation operation, + in PhysicsSetPositionResult result) + { + if (!result.IsCommitted || !IsCurrent(operation)) + return false; + RuntimeEntityRecord record = operation.Record; + PhysicsBody body = operation.Body!; + body.Orientation = result.Orientation; + body.SnapToCell( + result.CellId, + result.Position, + result.CellLocalPosition); + bool isStatic = (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0; + if (operation.EnteringWorldFromCelllessResidence) + { + body.LastUpdateTime = operation.Command.GameTime; + _entities.ResetObjectClockForEnterWorld(record, isStatic); + } + if (operation.EnteringWorldFromCelllessResidence && !isStatic) + body.TransientState |= TransientStateFlags.Active; + body.ContactPlaneValid = result.InContact; + body.ContactPlane = result.ContactPlane; + body.ContactPlaneCellId = result.ContactPlaneCellId; + body.ContactPlaneIsWater = result.ContactPlaneIsWater; + if (result.InContact) + body.GroundNormal = result.ContactPlane.Normal; + body.SlidingNormal = result.SlidingNormal; + if (result.SlidingNormalValid) + body.TransientState |= TransientStateFlags.Sliding; + else + body.TransientState &= ~TransientStateFlags.Sliding; + body.FramesStationaryFall = result.FramesStationaryFall; + body.TransientState &= ~(TransientStateFlags.StationaryFall + | TransientStateFlags.StationaryStop + | TransientStateFlags.StationaryStuck); + body.TransientState |= result.FramesStationaryFall switch + { + 1 => TransientStateFlags.StationaryFall, + 2 => TransientStateFlags.StationaryStop, + 3 => TransientStateFlags.StationaryStuck, + _ => TransientStateFlags.None, + }; + + IRuntimeRemotePlacement? remote = + record.RemoteMotion as IRuntimeRemotePlacement; + if (record.FullCellId != result.CellId) + { + _entities.SetFullCell( + record, + result.CellId, + (result.CellId & 0xFFFF0000u) | 0xFFFFu); + } + operation.SpatialAuthorityVersion = record.SpatialAuthorityVersion; + _entities.AdvancePlacementCommit(record); + operation.PlacementCommitVersion = record.PlacementCommitVersion; + ulong canonicalCommitVersion = record.PlacementCommitVersion; + if (remote is not null) + { + remote.CellId = result.CellId; + remote.LastServerPosition = result.Position; + remote.LastServerPositionTime = _physics.UtcNowSeconds; + remote.LastShadowSyncPosition = result.Position; + remote.LastShadowSyncOrientation = result.Orientation; + } + + _physics.Engine.ShadowObjects.CommitSetPosition( + operation.Key.LocalEntityId, + result.Position, + result.Orientation, + result.CellId, + operation.Command.ShadowWorldOffsetX, + operation.Command.ShadowWorldOffsetY, + result.ShadowAction, + result.CrossCellIds); + _physics.AcknowledgeSpatialProjection(record, spatial: true); + operation.ExactCellId = result.CellId; + operation.Result = result; + operation.WakeableLostCell = false; + operation.EnteringWorldFromCelllessResidence = false; + CancelLostFamilyDeadlines(operation); + + uint committedCellId = result.CellId; + bool IsCanonicalCommitCurrent() => + _entities.IsCurrent(record) + && ReferenceEquals(record.PhysicsBody, body) + && record.PlacementCommitVersion == canonicalCommitVersion + && record.FullCellId == committedCellId + && _physics.IsSpatialRoot(record); + Action? hitGround = remote is null ? null : remote.HitGround; + Action? leaveGround = remote is null ? null : remote.LeaveGround; + if (!PhysicsObjUpdate.CommitSetPositionTransition( + body, + result.InContact, + result.OnWalkable, + result.CollisionNormalValid, + result.CollisionNormal, + operation.PreviousContact, + operation.PreviousOnWalkable, + hitGround, + leaveGround, + IsCanonicalCommitCurrent, + () => IsCanonicalCommitCurrent() + && IsVelocityCurrent(operation))) + { + return false; + } + if (remote is not null) + remote.Airborne = !body.OnWalkable; + return IsCurrent(operation) + && IsCanonicalCommitCurrent(); + } + + private void WithdrawCanonical(RuntimeEntityRecord record) + { + _physics.RemoveSpatialProjection(record); + if (record.Key is { } key) + _physics.Engine.ShadowObjects.Suspend(key.LocalEntityId); + if (record.FullCellId != 0u) + _entities.SetFullCell(record, 0u, 0u); + } + + private void LeaveWorldCanonical(RuntimeEntityRecord record) + { + WithdrawCanonical(record); + if (record.PhysicsBody is not { } body) + { + _entities.SuspendObjectClock(record); + _entities.AdvancePlacementCommit(record); + return; + } + body.SnapToCell( + 0u, + body.Position, + body.CellPosition.Frame.Origin); + body.InWorld = false; + body.TransientState = TransientStateFlags.None; + body.ContactPlaneValid = false; + body.ContactPlaneCellId = 0u; + body.ContactPlaneIsWater = false; + body.SlidingNormal = Vector3.Zero; + body.FramesStationaryFall = 0; + body.calc_acceleration(); + if (record.RemoteMotion is IRuntimeRemotePlacement remote) + remote.CellId = 0u; + _entities.SuspendObjectClock(record); + _entities.AdvancePlacementCommit(record); + } + + private RuntimePlacementProjectionToken PublishProjection( + Operation operation, + RuntimePlacementProjectionKind kind, + in PhysicsSetPositionResult result, + bool publishImmediately = true) + { + if (operation.ProjectionSequence != 0UL) + _pendingProjection.Remove(operation.ProjectionSequence); + ulong sequence = checked(++_nextProjectionSequence); + var token = new RuntimePlacementProjectionToken( + sequence, + Revision: 1UL, + operation.Key, + operation.PositionAuthorityVersion, + operation.SpatialAuthorityVersion, + operation.PlacementCommitVersion, + operation.SessionLifetimeVersion, + operation.ExactCellId, + operation.CollisionGeneration, + operation.Command.Portal); + var snapshot = new RuntimePlacementProjectionSnapshot( + token, + kind, + result.Position, + result.Orientation, + result.CellLocalPosition, + result.InContact, + result.OnWalkable); + operation.ProjectionSequence = sequence; + _pendingProjection.Add(sequence, snapshot); + if (publishImmediately) + PublishPlacement(snapshot); + return token; + } + + private Operation CreateWithdrawalOperation( + RuntimeEntityRecord record, + RuntimeEntityKey key) + { + PhysicsBody body = record.PhysicsBody + ?? throw new InvalidOperationException( + "A withdrawal projection requires the canonical PhysicsBody."); + var physics = new PhysicsSetPositionRequest( + body.Position, + body.Orientation, + body.CellPosition.ObjCellId, + body.CellPosition.Frame.Origin, + ImmutableArray.Empty, + 1f, + 0f, + 0f); + var result = new PhysicsSetPositionResult( + PhysicsSetPositionError.Ok, + PhysicsResidenceDisposition.DeferredCell, + body.Position, + body.Orientation, + body.CellPosition.ObjCellId, + body.CellPosition.Frame.Origin, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: ImmutableArray.Empty); + return new Operation + { + Record = record, + Body = body, + Token = new RuntimeEntityPlacementToken( + _entities.SessionLifetimeVersion, + key, + record.PositionAuthorityVersion, + checked(++_nextOperationId)), + Key = key, + PositionAuthorityVersion = record.PositionAuthorityVersion, + SessionLifetimeVersion = _entities.SessionLifetimeVersion, + SourceSpatialAuthorityVersion = record.SpatialAuthorityVersion, + SourceVelocityAuthorityVersion = record.VelocityAuthorityVersion, + PreviousContact = body.InContact, + PreviousOnWalkable = body.OnWalkable, + Command = new RuntimeSetPositionCommand( + physics, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + body.LastUpdateTime, + record.VelocityAuthorityVersion, + ShadowWorldOffsetX: 0f, + ShadowWorldOffsetY: 0f), + Result = result, + SpatialAuthorityVersion = record.SpatialAuthorityVersion, + PlacementCommitVersion = record.PlacementCommitVersion, + ExactCellId = result.CellId, + WakeableLostCell = false, + Stage = RuntimeEntityPlacementStage + .AwaitingWithdrawalAcknowledgement, + Kind = RuntimeSetPositionOperationKind.RemoteAuthoritative, + Portal = default, + }; + } + + private bool IsCurrent(Operation operation) => + _operations.TryGetValue(operation.Key, out Operation? current) + && ReferenceEquals(current, operation) + && _entities.SessionLifetimeVersion == operation.SessionLifetimeVersion + && _entities.IsCurrent(operation.Record) + && operation.Record.Key == operation.Key + && (operation.Body is null + || ReferenceEquals(operation.Record.PhysicsBody, operation.Body)) + && operation.Record.PositionAuthorityVersion + == operation.PositionAuthorityVersion + && operation.Record.SpatialAuthorityVersion + == operation.SpatialAuthorityVersion + && operation.Record.PlacementCommitVersion + == operation.PlacementCommitVersion; + + private bool IsVelocityCurrent(Operation operation) => + operation.SourceVelocityAuthorityVersion == 0UL + || operation.Record.VelocityAuthorityVersion + == operation.SourceVelocityAuthorityVersion; + + internal void PublishCancellation( + in RuntimePlacementCancellationReceipt receipt) + { + if (!receipt.IsValid) + return; + RuntimePlacementProjectionSnapshot projection = receipt.Projection; + if (_pendingProjection.TryGetValue( + projection.Token.Sequence, + out RuntimePlacementProjectionSnapshot current) + && current == projection) + { + PublishPlacement(projection); + } + } + + private RuntimePlacementCancellationReceipt CancelCore( + RuntimeEntityKey key) + { + _ = CancelCoreDeferred( + key, + cancelLostFamily: false, + preserveLostFamily: false, + out RuntimePlacementProjectionSnapshot? discard); + return discard is { } projection + ? new RuntimePlacementCancellationReceipt(projection) + : default; + } + + private bool CancelCoreDeferred( + RuntimeEntityKey key, + bool cancelLostFamily, + bool preserveLostFamily, + out RuntimePlacementProjectionSnapshot? discard) + { + discard = null; + if (!preserveLostFamily) + CancelExactLostKey(key); + if (!_operations.Remove(key, out Operation? operation)) + return false; + UnindexDeferred(operation); + if (!preserveLostFamily && cancelLostFamily) + CancelLostFamilyDeadlines(operation); + if (operation.ProjectionSequence != 0UL + && _pendingProjection.TryGetValue( + operation.ProjectionSequence, + out RuntimePlacementProjectionSnapshot pending)) + { + RuntimePlacementProjectionSnapshot cancelled = pending with + { + Token = pending.Token with + { + Revision = checked(pending.Token.Revision + 1UL), + }, + Kind = RuntimePlacementProjectionKind.Discard, + }; + _pendingProjection[operation.ProjectionSequence] = cancelled; + operation.Stage = RuntimeEntityPlacementStage + .CancelledAwaitingAcknowledgement; + discard = cancelled; + } + return true; + } + + private RuntimePlacementCancellationReceipt CancelCore( + Operation expected, + bool preserveLostFamily = false) + { + if (!_operations.TryGetValue(expected.Key, out Operation? current) + || !ReferenceEquals(current, expected)) + { + return default; + } + _ = CancelCoreDeferred( + expected.Key, + cancelLostFamily: false, + preserveLostFamily, + out RuntimePlacementProjectionSnapshot? discard); + return discard is { } projection + ? new RuntimePlacementCancellationReceipt(projection) + : default; + } + + private void IndexDeferred(Operation operation) + { + if (operation.ExactCellId == 0u + || operation.CollisionGeneration == 0UL) + { + return; + } + var bucket = new CellGenerationKey( + operation.ExactCellId, + operation.CollisionGeneration); + if (!_deferredByCellGeneration.TryGetValue( + bucket, + out List? entities)) + { + entities = []; + _deferredByCellGeneration.Add(bucket, entities); + _deferredBucketOrder.Add(bucket); + } + if (!entities.Contains(operation.Key)) + entities.Add(operation.Key); + } + + private void UnindexDeferred(Operation operation) + { + if (operation.ExactCellId == 0u) + { + return; + } + if (operation.CollisionGeneration == 0UL) + { + if (_unboundDeferredByCell.TryGetValue( + operation.ExactCellId, + out List? unbound)) + { + int unboundIndex = unbound.IndexOf(operation.Key); + if (unboundIndex >= 0) + { + int last = unbound.Count - 1; + unbound[unboundIndex] = unbound[last]; + unbound.RemoveAt(last); + } + if (unbound.Count == 0) + { + _unboundDeferredByCell.Remove(operation.ExactCellId); + _unboundDeferredCellOrder.Remove(operation.ExactCellId); + } + } + return; + } + var bucket = new CellGenerationKey( + operation.ExactCellId, + operation.CollisionGeneration); + if (_deferredByCellGeneration.TryGetValue( + bucket, + out List? entities)) + { + int index = entities.IndexOf(operation.Key); + if (index >= 0) + { + int last = entities.Count - 1; + entities[index] = entities[last]; + entities.RemoveAt(last); + } + if (entities.Count == 0) + RemoveDeferredBucket(bucket); + } + } + + private void UnbindDeferredBucket(CellGenerationKey bucket) + { + if (!_deferredByCellGeneration.TryGetValue( + bucket, + out List? entities)) + { + return; + } + RemoveDeferredBucket(bucket); + if (!_unboundDeferredByCell.TryGetValue( + bucket.CellId, + out List? unbound)) + { + unbound = []; + _unboundDeferredByCell.Add(bucket.CellId, unbound); + _unboundDeferredCellOrder.Add(bucket.CellId); + } + for (int index = 0; index < entities.Count; index++) + { + RuntimeEntityKey key = entities[index]; + if (!_operations.TryGetValue(key, out Operation? operation) + || !operation.WakeableLostCell + || operation.ExactCellId != bucket.CellId + || operation.CollisionGeneration != bucket.CollisionGeneration) + { + continue; + } + operation.CollisionGeneration = 0UL; + operation.CollisionGenerationReady = false; + if (!unbound.Contains(key)) + unbound.Add(key); + } + if (unbound.Count == 0) + { + _unboundDeferredByCell.Remove(bucket.CellId); + _unboundDeferredCellOrder.Remove(bucket.CellId); + } + } + + private void RemoveDeferredBucket(CellGenerationKey bucket) + { + _deferredByCellGeneration.Remove(bucket); + int index = _deferredBucketOrder.IndexOf(bucket); + if (index < 0) + return; + int last = _deferredBucketOrder.Count - 1; + _deferredBucketOrder[index] = _deferredBucketOrder[last]; + _deferredBucketOrder.RemoveAt(last); + } + + private static RuntimeSetPositionOutcome Outcome( + RuntimeSetPositionStatus status, + in PhysicsSetPositionResult result, + in RuntimePlacementProjectionToken projection) => new( + status, + result.Error, + result.Residence, + result.CellId, + projection); + + private static RuntimeSetPositionOutcome Rejected( + in PhysicsSetPositionRequest request) => new( + RuntimeSetPositionStatus.Rejected, + PhysicsSetPositionError.InvalidArguments, + PhysicsResidenceDisposition.Unchanged, + request.CellId, + default); + + private static PhysicsSetPositionResult InvalidResult( + in PhysicsSetPositionRequest request) => new( + PhysicsSetPositionError.InvalidArguments, + PhysicsResidenceDisposition.Unchanged, + request.Position, + request.Orientation, + request.CellId, + request.CellLocalPosition, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: ImmutableArray.Empty); + + private static bool IsStructurallyValid( + in PhysicsSetPositionRequest request) + { + static bool Finite(Vector3 value) => float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Z); + + // The wire/runtime frame is retail Position::IsValid composed with + // Frame::IsValid: valid inbound cell plus the exact unit-quaternion + // tolerance. This must run before Core or prepared-mover caching. + if (!PositionFrameValidation.IsValid( + request.CellId, + request.CellLocalPosition, + request.Orientation) + || !Finite(request.Position) + || !Finite(request.CellLocalPosition) + || !float.IsFinite(request.StepUpHeight) + || !float.IsFinite(request.StepDownHeight)) + { + return false; + } + if (!request.Spheres.IsDefaultOrEmpty) + { + if (!float.IsFinite(request.Scale)) + return false; + int count = Math.Min(request.Spheres.Length, 2); + for (int index = 0; index < count; index++) + { + FlatCollisionSphere sphere = request.Spheres[index]; + if (!Finite(sphere.Origin) || !float.IsFinite(sphere.Radius)) + return false; + } + } + if (request.Flags.HasFlag(PhysicsSetPositionFlags.Line) + && !Finite(request.Line)) + { + return false; + } + bool scatter = request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter) + || request.Flags.HasFlag(PhysicsSetPositionFlags.RandomScatter); + if (scatter + && (!float.IsFinite(request.ScatterRadiusX) + || !float.IsFinite(request.ScatterRadiusY) + || request.ScatterAttempts > MaxSynchronousScatterAttempts)) + { + return false; + } + return true; + } + + private void PublishPlacement( + in RuntimePlacementProjectionSnapshot projection) => + _events?.PublishPlacement(projection); + + private void ArmLostFamilyDeadlines(Operation operation) + { + RuntimeEntityRecord root = operation.Record; + CancelLostFamilyDeadlines(operation); + double deadline = _physics.MonotonicNowSeconds + 25d; + operation.LostFamilyKeys ??= []; + if (root.Key is { } rootKey) + { + operation.LostFamilyKeys.Add(rootKey); + ArmLostDeadline(rootKey, deadline); + } + IReadOnlyList children = _entities.ParentAttachments + .ChildrenAttachedToParent(root.ServerGuid, root.Incarnation); + for (int index = 0; index < children.Count; index++) + { + if (_entities.TryGetActive( + children[index], + out RuntimeEntityRecord child) + && child.Key is { } childKey) + { + operation.LostFamilyKeys.Add(childKey); + ArmLostDeadline(childKey, deadline); + } + } + } + + private void CancelLostFamilyDeadlines(Operation operation) + { + if (operation.Record.Key is { } rootKey) + { + RemoveLostDeadline(rootKey); + RemoveExpiredLostCell(rootKey); + } + IReadOnlyList currentChildren = _entities.ParentAttachments + .ChildrenAttachedToParent( + operation.Record.ServerGuid, + operation.Record.Incarnation); + for (int index = 0; index < currentChildren.Count; index++) + { + if (_entities.TryGetActive( + currentChildren[index], + out RuntimeEntityRecord child) + && child.Key is { } key) + { + RemoveLostDeadline(key); + RemoveExpiredLostCell(key); + } + } + operation.LostFamilyKeys?.Clear(); + } + + private void CancelExactLostKey(RuntimeEntityKey key) + { + RemoveLostDeadline(key); + RemoveExpiredLostCell(key); + foreach (Operation operation in _operations.Values) + operation.LostFamilyKeys?.Remove(key); + } + + private void RemoveExpiredLostCell(RuntimeEntityKey key) + { + if (!_expiredLostCellNodes.Remove( + key, + out LinkedListNode? node)) + { + return; + } + _expiredLostCells.Remove(node); + } + + private void ArmLostDeadline(RuntimeEntityKey key, double deadline) + { + RemoveLostDeadlineNode(key); + _lostDeadlines[key] = deadline; + int index = _lostDeadlineNodes.Count; + _lostDeadlineNodes.Add(new LostDeadlineEntry( + key, + deadline, + checked(++_nextLostDeadlineSequence))); + _lostDeadlineNodeIndex.Add(key, index); + BubbleLostDeadlineUp(index); + } + + private void RemoveLostDeadline(RuntimeEntityKey key) + { + _lostDeadlines.Remove(key); + RemoveLostDeadlineNode(key); + } + + private static bool IsEarlier( + in LostDeadlineEntry candidate, + in LostDeadlineEntry current) => + candidate.Deadline < current.Deadline + || (candidate.Deadline == current.Deadline + && candidate.Sequence < current.Sequence); + + private void BubbleLostDeadlineUp(int index) + { + while (index > 0) + { + int parent = (index - 1) / 2; + if (!IsEarlier( + _lostDeadlineNodes[index], + _lostDeadlineNodes[parent])) + break; + SwapLostDeadlineNodes(index, parent); + index = parent; + } + } + + private void BubbleLostDeadlineDown(int index) + { + while (true) + { + int left = checked(index * 2 + 1); + if (left >= _lostDeadlineNodes.Count) + return; + int right = left + 1; + int earlier = right < _lostDeadlineNodes.Count + && IsEarlier( + _lostDeadlineNodes[right], + _lostDeadlineNodes[left]) + ? right + : left; + if (!IsEarlier( + _lostDeadlineNodes[earlier], + _lostDeadlineNodes[index])) + return; + SwapLostDeadlineNodes(index, earlier); + index = earlier; + } + } + + private void SwapLostDeadlineNodes(int first, int second) + { + LostDeadlineEntry temporary = _lostDeadlineNodes[first]; + _lostDeadlineNodes[first] = _lostDeadlineNodes[second]; + _lostDeadlineNodes[second] = temporary; + _lostDeadlineNodeIndex[_lostDeadlineNodes[first].Key] = first; + _lostDeadlineNodeIndex[_lostDeadlineNodes[second].Key] = second; + } + + private void RemoveLostDeadlineNode(RuntimeEntityKey key) + { + if (_lostDeadlineNodeIndex.TryGetValue(key, out int index)) + RemoveLostDeadlineNodeAt(index); + } + + private void RemoveLostDeadlineNodeAt(int index) + { + LostDeadlineEntry removed = _lostDeadlineNodes[index]; + _lostDeadlineNodeIndex.Remove(removed.Key); + int last = _lostDeadlineNodes.Count - 1; + if (index != last) + { + LostDeadlineEntry moved = _lostDeadlineNodes[last]; + _lostDeadlineNodes[index] = moved; + _lostDeadlineNodeIndex[moved.Key] = index; + } + _lostDeadlineNodes.RemoveAt(last); + if (index >= _lostDeadlineNodes.Count) + return; + int parent = index == 0 ? -1 : (index - 1) / 2; + if (parent >= 0 + && IsEarlier( + _lostDeadlineNodes[index], + _lostDeadlineNodes[parent])) + { + BubbleLostDeadlineUp(index); + } + else + { + BubbleLostDeadlineDown(index); + } + } + + private void EnsureNotDisposed() => + ObjectDisposedException.ThrowIf(_disposed, this); +} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs new file mode 100644 index 00000000..4d914cd1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs @@ -0,0 +1,167 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.Core.Tests.Physics; + +public sealed class ShadowSetPositionCommitTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell1 = Landblock | 0x0001u; + private const uint Cell9 = Landblock | 0x0009u; + + [Fact] + public void NoneRefreshesFrameWithoutChangingExactMembership() + { + var registry = RegisteredSingle(); + ulong version = registry.GetOwnerVersion(1u); + var moved = new Vector3(14f, 12f, 50f); + + registry.CommitSetPosition( + 1u, moved, Quaternion.Identity, Cell1, 0f, 0f, + PhysicsShadowCommitAction.None, []); + + ShadowEntry entry = Assert.Single(registry.GetObjectsInCell(Cell1)); + Assert.Equal(moved, entry.Position); + Assert.Empty(registry.GetObjectsInCell(Cell9)); + Assert.Equal(version + 1UL, registry.GetOwnerVersion(1u)); + } + + [Fact] + public void ReplaceDeduplicatesExactCellsAndPreserveRetainsThem() + { + var registry = RegisteredSingle(); + var replaced = new Vector3(36f, 12f, 50f); + registry.CommitSetPosition( + 1u, replaced, Quaternion.Identity, Cell9, 0f, 0f, + PhysicsShadowCommitAction.Replace, + ImmutableArray.Create(Cell9, Cell9, 0u)); + + Assert.Empty(registry.GetObjectsInCell(Cell1)); + Assert.Equal(replaced, + Assert.Single(registry.GetObjectsInCell(Cell9)).Position); + + var preserved = new Vector3(38f, 12f, 50f); + registry.CommitSetPosition( + 1u, preserved, Quaternion.Identity, Cell9, 0f, 0f, + PhysicsShadowCommitAction.Preserve, []); + Assert.Equal(preserved, + Assert.Single(registry.GetObjectsInCell(Cell9)).Position); + + var emptyReplace = new Vector3(40f, 12f, 50f); + registry.CommitSetPosition( + 1u, emptyReplace, Quaternion.Identity, Cell9, 0f, 0f, + PhysicsShadowCommitAction.Replace, []); + Assert.Equal(emptyReplace, + Assert.Single(registry.GetObjectsInCell(Cell9)).Position); + } + + [Fact] + public void RecalculateUsesCanonicalFloodInsteadOfRetainedCells() + { + var registry = RegisteredSingle(); + var moved = new Vector3(36f, 12f, 50f); + + registry.CommitSetPosition( + 1u, moved, Quaternion.Identity, Cell9, 0f, 0f, + PhysicsShadowCommitAction.Recalculate, []); + + Assert.Empty(registry.GetObjectsInCell(Cell1)); + Assert.Equal(moved, + Assert.Single(registry.GetObjectsInCell(Cell9)).Position); + } + + [Fact] + public void SuspendedPreserveRestoresExactRowsAndConsumesReceipt() + { + var registry = RegisteredSingle(); + Assert.True(registry.Suspend(1u)); + Assert.Equal(0, registry.TotalRegistered); + Assert.Equal(1, registry.SuspendedRegistrationCount); + var moved = new Vector3(15f, 12f, 50f); + + registry.CommitSetPosition( + 1u, moved, Quaternion.Identity, Cell1, 0f, 0f, + PhysicsShadowCommitAction.Preserve, []); + + Assert.Equal(1, registry.TotalRegistered); + Assert.Equal(0, registry.SuspendedRegistrationCount); + Assert.Equal(moved, + Assert.Single(registry.GetObjectsInCell(Cell1)).Position); + } + + [Fact] + public void SuspendedReceiptCopiesAcrossCollisionRootAndClearsOnTeardown() + { + var source = RegisteredSingle(); + Assert.True(source.Suspend(1u)); + var destination = new ShadowObjectRegistry(); + + Assert.False(destination.RefreshRetainedOwnerFrom( + source, 1u, Landblock, out ulong version)); + Assert.Equal(source.GetOwnerVersion(1u), version); + Assert.Equal(1, destination.RetainedRegistrationCount); + Assert.Equal(1, destination.SuspendedRegistrationCount); + + destination.CommitSetPosition( + 1u, new Vector3(16f, 12f, 50f), Quaternion.Identity, + Cell1, 0f, 0f, PhysicsShadowCommitAction.None, []); + Assert.Single(destination.GetObjectsInCell(Cell1)); + destination.Deregister(1u); + Assert.Equal(0, destination.RetainedRegistrationCount); + Assert.Equal(0, destination.SuspendedRegistrationCount); + destination.Clear(); + Assert.Equal(0, destination.TotalRegistered); + } + + [Fact] + public void MultiPartNoneRefreshesEachPartFromRootTransform() + { + var registry = new ShadowObjectRegistry(); + IReadOnlyList shapes = + [ + Shape(0x01000001u, new Vector3(1f, 0f, 0f)), + Shape(0x01000002u, new Vector3(0f, 1f, 0f)), + ]; + registry.RegisterMultiPart( + 2u, new Vector3(12f, 12f, 50f), Quaternion.Identity, + shapes, 0u, EntityCollisionFlags.None, 0f, 0f, Landblock, + Cell1); + Quaternion rotation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, MathF.PI / 2f); + var root = new Vector3(14f, 12f, 50f); + + registry.CommitSetPosition( + 2u, root, rotation, Cell1, 0f, 0f, + PhysicsShadowCommitAction.None, []); + + ShadowEntry[] entries = registry.GetObjectsInCell(Cell1) + .Where(entry => entry.EntityId == 2u) + .OrderBy(entry => entry.GfxObjId) + .ToArray(); + Assert.Equal(2, entries.Length); + Assert.Equal(root + Vector3.Transform(shapes[0].LocalPosition, rotation), + entries[0].Position); + Assert.Equal(root + Vector3.Transform(shapes[1].LocalPosition, rotation), + entries[1].Position); + } + + private static ShadowObjectRegistry RegisteredSingle() + { + var registry = new ShadowObjectRegistry(); + registry.Register( + 1u, 0x01000001u, new Vector3(12f, 12f, 50f), + Quaternion.Identity, 1f, 0f, 0f, Landblock, + seedCellId: Cell1, isStatic: false); + return registry; + } + + private static ShadowShape Shape(uint gfxObjId, Vector3 local) => new( + gfxObjId, + local, + Quaternion.Identity, + 1f, + ShadowCollisionType.BSP, + 0.25f, + 0f); +} diff --git a/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs index dcdd433c..7a1f3811 100644 --- a/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs @@ -264,6 +264,87 @@ public sealed class ParentAttachmentStateTests Assert.Equal((ushort)6, projection.ChildPositionSequence); } + [Fact] + public void CommittedChildrenAppendInCommitOrder() + { + const uint parentGuid = 0x70000200u; + var relations = new ParentAttachmentState(); + + Commit(relations, Relation(parentGuid, 0x70000201u, parentInstance: 9)); + Commit(relations, Relation(parentGuid, 0x70000202u, parentInstance: 9)); + IReadOnlyList committed = relations.ChildrenAttachedToParent( + parentGuid, + 9); + Commit(relations, Relation(parentGuid, 0x70000203u, parentInstance: 9)); + + Assert.Same( + committed, + relations.ChildrenAttachedToParent(parentGuid, 9)); + Assert.Equal( + [0x70000201u, 0x70000202u, 0x70000203u], + committed); + } + + [Fact] + public void EndingChildProjectionSwapRemovesCommittedChild() + { + const uint parentGuid = 0x70000210u; + var relations = new ParentAttachmentState(); + Commit(relations, Relation(parentGuid, 0x70000211u, parentInstance: 9)); + Commit(relations, Relation(parentGuid, 0x70000212u, parentInstance: 9)); + Commit(relations, Relation(parentGuid, 0x70000213u, parentInstance: 9)); + + relations.EndChildProjection(0x70000211u); + + Assert.Equal( + [0x70000213u, 0x70000212u], + relations.ChildrenAttachedToParent(parentGuid, 9)); + } + + [Fact] + public void ReparentSwapRemovesOldEntryAndAppendsNewEntry() + { + const uint oldParentGuid = 0x70000220u; + const uint newParentGuid = 0x70000221u; + const uint childGuid = 0x70000222u; + var relations = new ParentAttachmentState(); + Commit(relations, Relation(oldParentGuid, childGuid, parentInstance: 9)); + Commit(relations, Relation(oldParentGuid, 0x70000223u, parentInstance: 9)); + Commit(relations, Relation(oldParentGuid, 0x70000224u, parentInstance: 9)); + Commit(relations, Relation(newParentGuid, 0x70000225u, parentInstance: 4)); + + Commit(relations, Relation(newParentGuid, childGuid, parentInstance: 4)); + + Assert.Equal( + [0x70000224u, 0x70000223u], + relations.ChildrenAttachedToParent(oldParentGuid, 9)); + Assert.Equal( + [0x70000225u, childGuid], + relations.ChildrenAttachedToParent(newParentGuid, 4)); + } + + [Fact] + public void CommittedChildrenAreIsolatedByParentGuidAndIncarnation() + { + const uint parentGuid = 0x70000230u; + const uint otherParentGuid = 0x70000231u; + var relations = new ParentAttachmentState(); + Commit(relations, Relation(parentGuid, 0x70000232u, parentInstance: 9)); + Commit(relations, Relation(parentGuid, 0x70000233u, parentInstance: 10)); + Commit(relations, Relation(otherParentGuid, 0x70000234u, parentInstance: 9)); + + Assert.Equal( + [0x70000232u], + relations.ChildrenAttachedToParent(parentGuid, 9)); + Assert.Equal( + [0x70000233u], + relations.ChildrenAttachedToParent(parentGuid, 10)); + Assert.Equal( + [0x70000234u], + relations.ChildrenAttachedToParent(otherParentGuid, 9)); + Assert.Empty(relations.ChildrenAttachedToParent(otherParentGuid, 10)); + } + private static void Resolve( ParentAttachmentState relations, InboundPhysicsStateController inbound, @@ -290,6 +371,20 @@ public sealed class ParentAttachmentStateTests out accepted) && relations.CommitProjection(relation); + private static void Commit( + ParentAttachmentState relations, + ParentAttachmentRelation relation) + { + relations.AcceptCreateObjectRelation(relation); + Assert.True(relations.CommitProjection(relation)); + } + + private static ParentAttachmentRelation Relation( + uint parentGuid, + uint childGuid, + ushort parentInstance) => + new(parentGuid, childGuid, 1, 2, parentInstance, 1); + private static WorldSession.EntitySpawn Spawn( uint guid, ushort instance, diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs new file mode 100644 index 00000000..1762d8f9 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -0,0 +1,2449 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World.Cells; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimeSetPositionStateTests +{ + private const uint SourceLandblock = 0xA9B40000u; + private const uint SourceCell = SourceLandblock | 0x0001u; + private const uint DestinationLandblock = 0xAAB40000u; + private const uint DestinationCell = DestinationLandblock | 0x0001u; + private const uint DestinationIndoorCell = DestinationLandblock | 0x0100u; + + [Fact] + public void AcceptedTokenExistsBeforeHostPreparationAndRejectsInvalidPortalAuthority() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001001u, 1); + + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + + Assert.True(token.IsValid); + Assert.Equal(record.Key, token.Entity); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + + var invalidPortal = new RuntimePortalPlacementAuthority( + Present: true, + RevealGeneration: 7, + TeleportSequence: 0, + new RuntimeWorldHostProjectionToken(7, DestinationCell)); + Assert.False(lifetime.Physics.SetPosition.BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + invalidPortal).IsValid); + + // 4B1 validates the immutable authority shape. The authoritative + // RuntimeWorldTransitState membership check is intentionally the + // tested 4B2 routing seam. + RuntimeEntityPlacementToken portalToken = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative, + invalidPortal); + Assert.True(portalToken.IsValid); + } + + [Fact] + public void ImmediateCommitIsCanonicalBeforeProjectionAndExactTokenRetriesUntilAck() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001002u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(delta => + { + Assert.Equal(RuntimePlacementProjectionKind.Place, + delta.Placement.Kind); + Assert.Same(body, record.PhysicsBody); + Assert.Equal(SourceCell, record.FullCellId); + Assert.Equal(new Vector3(12f, 18f, 7f), body.Position); + Assert.True(body.InWorld); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(12f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Single(observer.Deltas); + RuntimePlacementProjectionToken token = outcome.Projection; + lifetime.Physics.SetPosition.RetryPendingProjections(); + Assert.Equal(2, observer.Deltas.Count); + Assert.Equal(token, observer.Deltas[0].Placement.Token); + Assert.Equal(token, observer.Deltas[1].Placement.Token); + Assert.True(observer.Deltas[1].Stamp.Sequence + > observer.Deltas[0].Stamp.Sequence); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(token)); + RuntimeSetPositionOwnershipSnapshot ownership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(0, ownership.ActiveOperationCount); + Assert.Equal(0, ownership.PendingProjectionAcknowledgementCount); + Assert.Equal(1, ownership.PreparedMoverCount); + } + + [Fact] + public void InWorldSetPositionPreservesObjectClockEpochPendingTimeAndActiveState() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001110u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + body.LastUpdateTime = 3d; + _ = record.ObjectClock.Advance(0.01d); + ulong epoch = record.ObjectClockEpoch; + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(11f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(epoch, record.ObjectClockEpoch); + Assert.Equal(3d, body.LastUpdateTime); + Assert.Equal(0.01d, record.ObjectClock.PendingSeconds, precision: 12); + Assert.True(record.ObjectClock.IsActive); + Assert.True(body.TransientState.HasFlag(TransientStateFlags.Active)); + } + + [Fact] + public void InWorldSetPositionDoesNotWakeAnInactiveBody() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001111u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + body.LastUpdateTime = 4d; + record.ObjectClock.Deactivate(); + body.TransientState &= ~TransientStateFlags.Active; + ulong epoch = record.ObjectClockEpoch; + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(11f, 19f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(epoch, record.ObjectClockEpoch); + Assert.Equal(4d, body.LastUpdateTime); + Assert.False(record.ObjectClock.IsActive); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); + } + + [Fact] + public void WarmedImmediateCommitAllocationIsMeasuredBeforeProductionCutover() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000111Cu, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionCommand command = Command(Request( + SourceCell, + new Vector3(11f, 18f, 7f))); + for (int warm = 0; warm < 64; warm++) + { + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + command); + if (!lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)) + { + throw new InvalidOperationException("Warmup acknowledgement failed."); + } + } + + const int iterations = 1_000; + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < iterations; iteration++) + { + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + command); + if (!lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)) + { + throw new InvalidOperationException("Measured acknowledgement failed."); + } + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + // The dormant 4B1 owner still allocates its operation/projection + // envelope. Pin the measured Release ceiling so 4B2 cannot activate + // the route without making this cost explicit or reducing it. + Assert.InRange(allocated / iterations, 1L, 2_048L); + } + + [Fact] + public void MissingCrossLandblockCellParksExactBodyThenMatchingCellGenerationWakesIt() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + var gameClock = new GameRuntimeClock(); + _ = gameClock.Advance(35d); + var time = new ManualTimeProvider( + DateTimeOffset.UnixEpoch.AddSeconds(100d)); + using var lifetime = new RuntimeEntityObjectLifetime( + engine, + timeProvider: time, + gameClock: gameClock); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001003u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var remote = new RemoteMotion(body) + { + HasServerVelocity = true, + ServerVelocity = Vector3.UnitX, + }; + lifetime.Physics.SetRemoteMotion(record, remote); + body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact + | TransientStateFlags.OnWalkable + | TransientStateFlags.WaterContact + | TransientStateFlags.Sliding; + body.ContactPlaneValid = true; + body.ContactPlaneIsWater = true; + body.SlidingNormal = Vector3.UnitX; + body.set_velocity(new Vector3(3f, 4f, 5f)); + engine.ShadowObjects.Register( + record.Key!.Value.LocalEntityId, + 0x01000001u, + body.Position, + body.Orientation, + 0.48f, + 0f, + 0f, + SourceLandblock, + seedCellId: SourceCell, + isStatic: false); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, deferred.Status); + Assert.Equal(DestinationCell, deferred.ExactCellId); + Assert.Same(body, record.PhysicsBody); + Assert.Equal(DestinationCell, body.CellPosition.ObjCellId); + Assert.Equal(0u, record.FullCellId); + Assert.False(body.InWorld); + Assert.Equal(1d, body.LastUpdateTime); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + Assert.True(body.ContactPlaneIsWater); + Assert.Equal(Vector3.UnitX, body.SlidingNormal); + Assert.Equal(new Vector3(3f, 4f, 5f), body.Velocity); + Assert.False(lifetime.Physics.IsSpatialRoot(record)); + Assert.Equal(1, engine.ShadowObjects.SuspendedRegistrationCount); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, + Assert.Single(observer.Deltas).Placement.Kind); + + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + ulong suspendedEpoch = record.ObjectClockEpoch; + Assert.False(record.ObjectClock.IsActive); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 2, + ready: true); + Assert.Single(observer.Deltas); + + AddFlatLandblock(engine, DestinationLandblock, 192f); + _ = gameClock.Advance(5d); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + + Assert.Equal(2, observer.Deltas.Count); + RuntimePlacementProjectionSnapshot placed = observer.Deltas[1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.Equal(DestinationCell, placed.Token.ExactCellId); + Assert.Same(body, record.PhysicsBody); + Assert.Equal(DestinationCell, record.FullCellId); + Assert.True(body.InWorld); + Assert.True(double.IsFinite(body.LastUpdateTime)); + Assert.Equal(40d, body.LastUpdateTime); + Assert.Equal(suspendedEpoch + 1UL, record.ObjectClockEpoch); + Assert.Equal(0d, record.ObjectClock.PendingSeconds); + Assert.True(record.ObjectClock.IsActive); + Assert.True(body.TransientState.HasFlag(TransientStateFlags.Active)); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + Assert.Equal(100d, remote.LastServerPosTime); + var remoteUpdater = new RuntimeRemotePhysicsUpdater( + lifetime.Physics); + Assert.True(remoteUpdater.Tick( + record, + remote, + objectScale: 1f, + sequencer: null, + dt: 0.01f, + objectClockEpoch: record.ObjectClockEpoch, + new MotionDeltaFrame + { + Orientation = Quaternion.Identity, + }, + radius: 0.48f, + height: 1.835f, + liveCenterX: 1, + liveCenterY: 1)); + Assert.True(remote.HasServerVelocity); + Assert.Equal(Vector3.UnitX, remote.ServerVelocity); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + } + + [Fact] + public void FailedDeferredWakeRetainsLostOwnerDeadlineAndRetries() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001121u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + AddFlatLandblock(engine, DestinationLandblock, 192f); + engine.TransitionCellCollisionTestHook = + static (_, _, _, _) => TransitionState.Collided; + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + + RuntimeSetPositionOwnershipSnapshot failed = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.Equal(1, failed.ActiveOperationCount); + Assert.Equal(1, failed.DeferredCellCount); + Assert.Equal(1, failed.LostDeadlineCount); + Assert.Equal(1, failed.DeferredBucketCount); + Assert.True(failed.IndexesConsistent); + Assert.Equal(0u, record.FullCellId); + Assert.False(body.InWorld); + Assert.Single(observer.Deltas); + + engine.TransitionCellCollisionTestHook = null; + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + RuntimePlacementProjectionSnapshot placed = observer.Deltas[^1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.Equal(record.Key, placed.Token.Entity); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + Assert.Equal(DestinationCell, record.FullCellId); + Assert.True(body.InWorld); + } + + [Fact] + public void StandaloneDeferredWakeRetainsAcceptedSimulationTimeFallback() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001126u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + AddFlatLandblock(engine, DestinationLandblock, 192f); + + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + + Assert.Equal(10d, body.LastUpdateTime); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot placed)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + [Fact] + public void RejectedPreparationKeepsValidatedMoverAndExactTokenRetryable() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001122u, 1); + _ = AttachBody(lifetime, record, SourceCell); + ImmutableArray validated = + [ + new FlatCollisionSphere(new Vector3(0.25f, 0f, 0f), 0.3f), + new FlatCollisionSphere(new Vector3(-0.2f, 0f, 0.4f), 0.2f), + ]; + RuntimeSetPositionOutcome seeded = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request( + SourceCell, + new Vector3(12f, 18f, 7f)) with + { + Spheres = validated, + })); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + seeded.Projection)); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out int sphereCount)); + Assert.Equal(2, sphereCount); + + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + RuntimeSetPositionCommand replacement = Command(Request( + SourceCell, + new Vector3(13f, 18f, 7f)) with + { + Spheres = + [ + new FlatCollisionSphere(Vector3.Zero, 0.4f), + ], + }); + engine.TransitionCellCollisionTestHook = + static (_, _, _, _) => TransitionState.Collided; + RuntimeSetPositionOutcome rejected = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(token, replacement); + Assert.Equal(RuntimeSetPositionStatus.Rejected, rejected.Status); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out sphereCount)); + Assert.Equal(2, sphereCount); + + RuntimeSetPositionOutcome malformed = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + replacement with { GameTime = double.NaN }); + Assert.Equal(PhysicsSetPositionError.InvalidArguments, malformed.Error); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken retryToken)); + Assert.Equal(token, retryToken); + + engine.TransitionCellCollisionTestHook = null; + RuntimeSetPositionOutcome retried = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(retryToken, replacement); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + retried.Status); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out sphereCount)); + Assert.Equal(1, sphereCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + retried.Projection)); + } + + [Theory] + [InlineData(0u)] + [InlineData(SourceLandblock | 0x0041u)] + public void InvalidRetailCellFrameRejectsBeforeCoreAndPreparedCache( + uint invalidCellId) + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001128u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + int collisionCalls = 0; + engine.TransitionCellCollisionTestHook = (_, _, _, _) => + { + collisionCalls++; + return TransitionState.OK; + }; + + RuntimeSetPositionOutcome rejected = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request( + invalidCellId, + new Vector3(12f, 18f, 7f)))); + + Assert.Equal(RuntimeSetPositionStatus.Rejected, rejected.Status); + Assert.Equal(PhysicsSetPositionError.InvalidArguments, rejected.Error); + Assert.Equal(0, collisionCalls); + Assert.False(lifetime.Physics.SetPosition + .TryGetPreparedMoverSphereCount(record, out _)); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken retry)); + Assert.Equal(token, retry); + } + + [Fact] + public void InvalidRetailQuaternionRejectsBeforeCoreAndPreparedCache() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001129u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + int collisionCalls = 0; + engine.TransitionCellCollisionTestHook = (_, _, _, _) => + { + collisionCalls++; + return TransitionState.OK; + }; + Quaternion[] invalid = + [ + Quaternion.Zero, + new Quaternion(0f, 0f, 0f, 0.9f), + ]; + + foreach (Quaternion orientation in invalid) + { + RuntimeSetPositionOutcome rejected = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request( + SourceCell, + new Vector3(12f, 18f, 7f)) with + { + Orientation = orientation, + })); + Assert.Equal(RuntimeSetPositionStatus.Rejected, rejected.Status); + Assert.Equal( + PhysicsSetPositionError.InvalidArguments, + rejected.Error); + } + + Assert.Equal(0, collisionCalls); + Assert.False(lifetime.Physics.SetPosition + .TryGetPreparedMoverSphereCount(record, out _)); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken retry)); + Assert.Equal(token, retry); + } + + [Fact] + public void ExtremeScatterAttemptsRejectWithoutEnteringSynchronousLoop() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000112Au, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + int randomDraws = 0; + engine.SetPositionRandomUnit = () => + { + randomDraws++; + return 0.5d; + }; + + RuntimeSetPositionOutcome rejected = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request( + SourceCell, + new Vector3(12f, 18f, 7f)) with + { + Flags = PhysicsSetPositionFlags.RandomScatter, + ScatterRadiusX = 1f, + ScatterRadiusY = 1f, + ScatterAttempts = uint.MaxValue, + })); + + Assert.Equal(RuntimeSetPositionStatus.Rejected, rejected.Status); + Assert.Equal(PhysicsSetPositionError.InvalidArguments, rejected.Error); + Assert.Equal(0, randomDraws); + Assert.False(lifetime.Physics.SetPosition + .TryGetPreparedMoverSphereCount(record, out _)); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken retry)); + Assert.Equal(token, retry); + } + + [Fact] + public void BodylessWithdrawalCancellationDoesNotInventBodyOrProjection() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001123u, 1); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(token.IsValid); + + Assert.True(lifetime.Physics.SetPosition.Cancel( + record, + publishWithdrawal: true)); + + Assert.Null(record.PhysicsBody); + Assert.False(lifetime.Physics.SetPosition.TryPeekProjection(out _)); + RuntimeSetPositionOwnershipSnapshot ownership = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.Equal(0, ownership.ActiveOperationCount); + Assert.Equal(0, ownership.PendingProjectionAcknowledgementCount); + Assert.True(ownership.IndexesConsistent); + } + + [Fact] + public void ZeroExpectedVelocityPreservesBeginVersionAcrossInterveningVector() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001124u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + lifetime.Entities.AdvanceVectorAuthority(record); + body.set_velocity(new Vector3(-2f, 0f, 0f)); + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase is TransitionCellCollisionPhase.Environment) + transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX); + return observed; + }; + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request( + SourceCell, + new Vector3(14f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(new Vector3(-2f, 0f, 0f), body.Velocity); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + } + + [Fact] + public void CanonicalCommitReplacesDerivedBitsAndPublishesSlopeNormal() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001125u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + body.TransientState |= TransientStateFlags.Sliding + | TransientStateFlags.StationaryStop + | TransientStateFlags.StationaryStuck + | TransientStateFlags.WaterContact; + body.SlidingNormal = Vector3.UnitY; + Vector3 slope = Vector3.Normalize(new Vector3(0.2f, 0f, 1f)); + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(slope, 0f), + SourceCell); + transition.CollisionInfo.FramesStationaryFall = 1; + } + return observed; + }; + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request( + SourceCell, + new Vector3(15f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(slope, body.GroundNormal); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding)); + Assert.Equal(Vector3.Zero, body.SlidingNormal); + Assert.True(body.TransientState.HasFlag( + TransientStateFlags.StationaryFall)); + Assert.False(body.TransientState.HasFlag( + TransientStateFlags.StationaryStop)); + Assert.False(body.TransientState.HasFlag( + TransientStateFlags.StationaryStuck)); + Assert.False(body.TransientState.HasFlag( + TransientStateFlags.WaterContact)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + } + + [Fact] + public void ReentrantNewPlacementConvertsPublishedTokenToOrderedDiscard() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001004u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken replacement = default; + var observer = new PlacementObserver(delta => + { + if (delta.Placement.Kind is RuntimePlacementProjectionKind.Place) + { + replacement = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + } + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(13f, 18f, 7f)))); + + Assert.True(replacement.IsValid); + Assert.Equal(2, observer.Deltas.Count); + Assert.Equal(RuntimePlacementProjectionKind.Place, + observer.Deltas[0].Placement.Kind); + Assert.Equal(RuntimePlacementProjectionKind.Discard, + observer.Deltas[1].Placement.Kind); + Assert.Equal(outcome.Projection.TokenSequence(), + observer.Deltas[1].Placement.Token.TokenSequence()); + Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observer.Deltas[1].Placement.Token)); + lifetime.Physics.SetPosition.Forget( + record, + releasePreparedMover: true); + Assert.True(lifetime.Physics.SetPosition.CaptureOwnership().IsConverged); + } + + [Fact] + public void ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase == TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + SourceCell); + } + return observed; + }; + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001042u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + engine.ShadowObjects.Register( + record.Key!.Value.LocalEntityId, + 0x01000001u, + body.Position, + body.Orientation, + 0.48f, + 0f, + 0f, + SourceLandblock, + seedCellId: SourceCell, + isStatic: false); + RuntimeEntityPlacementToken replacement = default; + var remote = new ReentrantRemotePlacement(body) + { + CellId = SourceCell, + OnHitGround = () => + { + replacement = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + }, + }; + lifetime.Entities.SetRemoteMotion(record, remote); + + RuntimeSetPositionOutcome displaced = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(13f, 18f, 7f)))); + + Assert.Equal(RuntimeSetPositionStatus.Cancelled, displaced.Status); + Assert.True(replacement.IsValid); + RuntimeSetPositionOwnershipSnapshot ownership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(1, ownership.ActiveOperationCount); + Assert.Equal(1, ownership.AwaitingPreparationCount); + Assert.Equal(13f, body.Position.X); + Assert.Equal(18f, body.Position.Y); + Assert.Equal(SourceCell, record.FullCellId); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + ShadowEntry shadow = Assert.Single( + engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(body.Position, shadow.Position); + + RuntimeSetPositionOutcome retained = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + replacement, + Command(Request( + SourceCell, + new Vector3(14f, 18f, 7f)))); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + retained.Status); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + retained.Projection)); + lifetime.Physics.SetPosition.Forget( + record, + releasePreparedMover: true); + Assert.True(lifetime.Physics.SetPosition.CaptureOwnership().IsConverged); + } + + [Fact] + public void RetrySnapshotPreservesOrderWhenObserverAcknowledgesTwoPendingTokens() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord first = CreateRecord(lifetime, 0x70001040u, 1); + RuntimeEntityRecord second = CreateRecord(lifetime, 0x70001041u, 1); + _ = AttachBody(lifetime, first, SourceCell); + _ = AttachBody(lifetime, second, SourceCell); + bool acknowledgeRetries = false; + var retryOrder = new List(); + var observer = new PlacementObserver(delta => + { + if (!acknowledgeRetries) + return; + retryOrder.Add(delta.Placement.Token.Sequence); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + delta.Placement.Token)); + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + RuntimeSetPositionOutcome firstOutcome = lifetime.Physics.SetPosition.Apply( + first, + first.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(14f, 18f, 7f)))); + RuntimeSetPositionOutcome secondOutcome = lifetime.Physics.SetPosition.Apply( + second, + second.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(16f, 18f, 7f)))); + + acknowledgeRetries = true; + lifetime.Physics.SetPosition.RetryPendingProjections(); + + Assert.Equal( + [firstOutcome.Projection.Sequence, secondOutcome.Projection.Sequence], + retryOrder); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .PendingSetPositionHostAcknowledgementCount); + } + + [Fact] + public void ThrowingObserverDoesNotRollBackCanonicalCommitAndRetryCanAck() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001043u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + using IDisposable throwing = lifetime.Events.SubscribePlacement( + new ThrowingPlacementObserver()); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(17f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(new Vector3(17f, 18f, 7f), body.Position); + Assert.Equal(1, lifetime.Events.DispatchFailureCount); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .PendingSetPositionHostAcknowledgementCount); + + throwing.Dispose(); + var healthy = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(healthy); + lifetime.Physics.SetPosition.RetryPendingProjections(); + Assert.Equal(outcome.Projection, + Assert.Single(healthy.Deltas).Placement.Token); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + } + + [Fact] + public void ForgetRevisesPendingTokenAndRetryPublishesDiscardBeforeAck() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001117u, 1); + _ = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(17f, 17f, 7f)))); + + RuntimePlacementCancellationReceipt cancellation = + lifetime.Physics.SetPosition.Forget(record); + + Assert.True(cancellation.IsValid); + Assert.Single(observer.Deltas); + Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( + pending.Projection)); + lifetime.Physics.SetPosition.RetryPendingProjections(); + RuntimePlacementProjectionSnapshot discard = observer.Deltas[^1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Discard, discard.Kind); + Assert.Equal(pending.Projection.Sequence, discard.Token.Sequence); + Assert.True(discard.Token.Revision > pending.Projection.Revision); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + discard.Token)); + } + + [Fact] + public void ReplacementRetainsUnacknowledgedLostWithdrawalUntilPreparedCommandCanPlace() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000111Au, 1); + _ = AttachBody(lifetime, record, SourceCell); + using IDisposable throwing = lifetime.Events.SubscribePlacement( + new ThrowingPlacementObserver()); + RuntimeSetPositionOutcome original = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, original.Status); + + RuntimeEntityPlacementToken replacement = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(replacement.IsValid); + RuntimeSetPositionOutcome waiting = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + replacement, + Command(Request(SourceCell, new Vector3(18f, 18f, 7f)))); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, waiting.Status); + Assert.Equal(original.Projection, waiting.Projection); + Assert.Equal(0u, record.FullCellId); + + throwing.Dispose(); + var healthy = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(healthy); + lifetime.Physics.SetPosition.RetryPendingProjections(); + RuntimePlacementProjectionSnapshot withdrawal = + Assert.Single(healthy.Deltas).Placement; + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawal.Kind); + Assert.Equal(original.Projection, withdrawal.Token); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + withdrawal.Token)); + + Assert.Equal(2, healthy.Deltas.Count); + RuntimePlacementProjectionSnapshot placed = healthy.Deltas[1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.Equal(SourceCell, record.FullCellId); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + [Fact] + public void ReentrantNewerPositionDuringPickupDiscardSuppressesStalePickupDelta() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001118u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(17f, 16f, 7f)))); + var entities = new EntityObserver(); + using IDisposable entitySubscription = lifetime.Events.Subscribe(entities); + bool reentered = false; + RuntimeEventStamp discardStamp = default; + var placements = new PlacementObserver(delta => + { + if (reentered + || delta.Placement.Kind + is not RuntimePlacementProjectionKind.Discard) + { + return; + } + reentered = true; + discardStamp = delta.Stamp; + Assert.True(lifetime.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + record.ServerGuid, + new CreateObject.ServerPosition( + SourceCell, 12f, 20f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: record.Incarnation, + PositionSequence: 3, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out _, + out _, + out _)); + }); + using IDisposable placementSubscription = + lifetime.Events.SubscribePlacement(placements); + + Assert.False(lifetime.TryApplyPickup( + new PickupEvent.Parsed(record.ServerGuid, 1, 2), + acknowledgeProjection: null, + out _)); + + Assert.True(reentered); + RuntimeEntityDelta only = Assert.Single(entities.Deltas); + Assert.Equal(RuntimeEntityChange.Rebucketed, only.Change); + Assert.True(only.Stamp.Sequence > discardStamp.Sequence); + Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( + pending.Projection)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + Assert.Single(placements.Deltas).Placement.Token)); + } + + [Fact] + public void SyntheticWithdrawalAcknowledgementTerminatesOperation() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001044u, 1); + _ = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + Assert.True(lifetime.Physics.SetPosition.Cancel( + record, + publishWithdrawal: true)); + RuntimePlacementProjectionToken token = Assert.Single(observer.Deltas) + .Placement.Token; + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(token)); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + } + + [Fact] + public void ReentrantBeginDuringDiscardCannotBeOverwrittenByOuterBegin() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001045u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(18f, 18f, 7f)))); + RuntimeEntityPlacementToken inner = default; + var observer = new PlacementObserver(delta => + { + if (delta.Placement.Kind is RuntimePlacementProjectionKind.Discard) + { + inner = lifetime.Physics.SetPosition.BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + } + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + RuntimeEntityPlacementToken outer = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + + Assert.False(outer.IsValid); + Assert.True(inner.IsValid); + Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( + pending.Projection)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + Assert.Single(observer.Deltas).Placement.Token)); + RuntimeSetPositionOutcome committed = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + inner, + Command(Request(SourceCell, new Vector3(19f, 18f, 7f)))); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + committed.Status); + } + + [Fact] + public void CollisionRetirementRejectsActivePlacementBeforeResidentMutation() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001046u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + + Assert.Throws(() => + lifetime.Physics.SetPosition.ParkCollisionResidents( + SourceLandblock, + includeOutdoorCells: true)); + Assert.Equal(SourceCell, record.FullCellId); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + RuntimeSetPositionOutcome submitted = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request(SourceCell, new Vector3(20f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + submitted.Status); + } + + [Fact] + public void CollisionRetirementRejectsHostAckPendingPlacementBeforeResidentMutation() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000111Bu, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(20f, 19f, 7f)))); + + Assert.Throws(() => + lifetime.Physics.SetPosition.ParkCollisionResidents( + SourceLandblock, + includeOutdoorCells: true)); + Assert.Equal(SourceCell, record.FullCellId); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + pending.Projection)); + } + + [Fact] + public void CollisionRetirementInstallsEveryRootBeforeWithdrawObserverReentry() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord first = CreateRecord(lifetime, 0x7000111Du, 1); + RuntimeEntityRecord second = CreateRecord(lifetime, 0x7000111Eu, 1); + _ = AttachBody(lifetime, first, SourceCell); + _ = AttachBody(lifetime, second, SourceCell); + RuntimeEntityPlacementToken replacement = default; + RuntimeSetPositionOutcome submitted = default; + var observer = new PlacementObserver(delta => + { + if (delta.Placement.Kind is RuntimePlacementProjectionKind.Withdraw + && delta.Placement.Token.Entity == first.Key) + { + replacement = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + second, + second.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + submitted = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + replacement, + Command(Request( + SourceCell, + new Vector3(21f, 19f, 7f)))); + } + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + lifetime.Physics.SetPosition.ParkCollisionResidents( + SourceLandblock, + includeOutdoorCells: true); + + Assert.True(replacement.IsValid); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, submitted.Status); + Assert.Equal(2, observer.Deltas.Count); + Assert.All(observer.Deltas, delta => Assert.Equal( + RuntimePlacementProjectionKind.Withdraw, + delta.Placement.Kind)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observer.Deltas[0].Placement.Token)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observer.Deltas[1].Placement.Token)); + RuntimePlacementProjectionSnapshot placed = observer.Deltas[^1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.Equal(second.Key, placed.Token.Entity); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + [Fact] + public void RootAndCurrentDirectChildOwnIndependentExactLostDeadlines() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime( + engine, + timeProvider: time); + RuntimeEntityRecord root = CreateRecord(lifetime, 0x70001005u, 3); + RuntimeEntityRecord child = CreateRecord(lifetime, 0x70001006u, 9); + _ = AttachBody(lifetime, root, SourceCell); + var relation = new ParentAttachmentRelation( + root.ServerGuid, + child.ServerGuid, + ParentLocation: 1, + PlacementId: 2, + ParentInstanceSequence: root.Incarnation, + ChildPositionSequence: 1); + lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(lifetime.Entities.ParentAttachments.CommitProjection(relation)); + + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + root, + root.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + + Assert.Equal(2, lifetime.Physics.CaptureOwnership().LostCellDeadlineCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + time.Advance(TimeSpan.FromSeconds(26)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.Equal(2, lifetime.Physics.CaptureOwnership() + .ExpiredLostCellCount); + + var expired = new HashSet(); + while (lifetime.Physics.SetPosition.TryDequeueExpiredLostCell(out var key)) + expired.Add(key); + Assert.Equal(2, expired.Count); + Assert.Contains(root.Key!.Value, expired); + Assert.Contains(child.Key!.Value, expired); + Assert.Equal(0, lifetime.Physics.CaptureOwnership().LostCellDeadlineCount); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .ExpiredLostCellCount); + } + + [Fact] + public void ExpiredRootDeletionDoesNotConsumeIndependentChildExpiry() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine, timeProvider: time); + RuntimeEntityRecord root = CreateRecord(lifetime, 0x70001047u, 1); + RuntimeEntityRecord child = CreateRecord(lifetime, 0x70001048u, 1); + _ = AttachBody(lifetime, root, SourceCell); + CommitParent(lifetime, root, child); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + root, + root.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + time.Advance(TimeSpan.FromSeconds(26)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey expiredRoot)); + Assert.Equal(root.Key, expiredRoot); + lifetime.Physics.SetPosition.Forget(root); + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey expiredChild)); + Assert.Equal(child.Key, expiredChild); + } + + [Fact] + public void ChildPickupCancelsOnlyItsCapturedLostDeadline() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine, timeProvider: time); + RuntimeEntityRecord root = CreateRecord(lifetime, 0x70001049u, 1); + RuntimeEntityRecord child = CreateRecord(lifetime, 0x7000104Au, 1); + _ = AttachBody(lifetime, root, SourceCell); + CommitParent(lifetime, root, child); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + root, + root.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + Assert.True(lifetime.TryApplyPickup( + new PickupEvent.Parsed(child.ServerGuid, 1, 2), + acknowledgeProjection: null, + out _)); + time.Advance(TimeSpan.FromSeconds(26)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey only)); + Assert.Equal(root.Key, only); + Assert.False(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell(out _)); + } + + [Fact] + public void LostDeadlineUsesMonotonicTimeAndResetClearsExpiredOwnership() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine, timeProvider: time); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000104Bu, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + time.JumpUtc(TimeSpan.FromDays(30)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .ExpiredLostCellCount); + time.Advance(TimeSpan.FromSeconds(26)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .ExpiredLostCellCount); + + _ = lifetime.BeginSessionClear(); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .ExpiredLostCellCount); + Assert.False(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell(out _)); + } + + [Fact] + public void LostDeadlineTickIsZeroAllocationWhenEmptyAndExpiresByDeadlinePriority() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime( + engine, + timeProvider: time); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < 1_000; index++) + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); + + RuntimeEntityRecord first = CreateRecord(lifetime, 0x70001101u, 1); + RuntimeEntityRecord middle = CreateRecord(lifetime, 0x70001102u, 1); + RuntimeEntityRecord last = CreateRecord(lifetime, 0x70001103u, 1); + foreach (RuntimeEntityRecord record in new[] { first, middle, last }) + { + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + time.Advance(TimeSpan.FromSeconds(1)); + } + lifetime.Physics.SetPosition.LeaveWorld(middle); + time.Advance(TimeSpan.FromSeconds(24)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey firstExpired)); + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey secondExpired)); + Assert.Equal(first.Key, firstExpired); + Assert.Equal(last.Key, secondExpired); + Assert.False(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell(out _)); + } + + [Fact] + public void RearmedLostDeadlineSupersedesItsStalePriorityEntry() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime( + engine, + timeProvider: time); + RuntimeEntityRecord first = CreateRecord(lifetime, 0x70001112u, 1); + RuntimeEntityRecord second = CreateRecord(lifetime, 0x70001113u, 1); + _ = AttachBody(lifetime, first, SourceCell); + _ = AttachBody(lifetime, second, SourceCell); + + RuntimeSetPositionOutcome firstLost = lifetime.Physics.SetPosition.Apply( + first, + first.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + firstLost.Projection)); + time.Advance(TimeSpan.FromSeconds(1)); + RuntimeSetPositionOutcome secondLost = lifetime.Physics.SetPosition.Apply( + second, + second.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + secondLost.Projection)); + time.Advance(TimeSpan.FromSeconds(1)); + + RuntimeEntityPlacementToken replacement = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + first, + first.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + RuntimeSetPositionOutcome rearmed = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(replacement, Command(CrossLandblockRequest())); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, rearmed.Status); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + rearmed.Projection)); + + time.Advance(TimeSpan.FromSeconds(24.5)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey expired)); + Assert.Equal(second.Key, expired); + Assert.False(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell(out _)); + time.Advance(TimeSpan.FromSeconds(1)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out expired)); + Assert.Equal(first.Key, expired); + } + + [Fact] + public void RepeatedLostDeadlineRearmAndCancelKeepsExactHeapBounded() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001119u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + for (int iteration = 0; iteration < 100; iteration++) + { + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + deferred = lifetime.Physics.SetPosition.SubmitPreparedPlacement( + token, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + RuntimeSetPositionOwnershipSnapshot snapshot = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.True(snapshot.IndexesConsistent); + Assert.Equal(1, snapshot.LostDeadlineCount); + Assert.Equal(1, snapshot.LostDeadlineNodeCount); + Assert.Equal(1, snapshot.LostDeadlineIndexCount); + } + + lifetime.Physics.SetPosition.LeaveWorld(record); + RuntimeSetPositionOwnershipSnapshot final = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.True(final.IndexesConsistent); + Assert.Equal(0, final.LostDeadlineCount); + Assert.Equal(0, final.LostDeadlineNodeCount); + Assert.Equal(0, final.LostDeadlineIndexCount); + } + + [Fact] + public void CommittedGenerationWithoutExactIndoorCellRebindsAndWakesOnNextGeneration() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001114u, 1); + _ = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request( + DestinationIndoorCell, + new Vector3(10f, 12f, 7f)))); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, deferred.Status); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + RuntimeCollisionAdmission missing = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + using (PreparedLandblockCollisionGeneration prepared = + lifetime.Physics.PrepareCollisionGeneration(missing)) + { + lifetime.Physics.StageCollisionAssets( + missing, + prepared, + CollisionAssets(DestinationLandblock)); + Assert.True(CommitPrepared( + lifetime.Physics, + missing, + prepared).Committed); + } + Assert.Single(observer.Deltas); + RuntimePhysicsOwnershipSnapshot unbound = lifetime.Physics + .CaptureOwnership(); + Assert.Equal(1, unbound.UnboundDeferredSetPositionCellCount); + Assert.Equal(1, unbound.UnboundDeferredSetPositionCellOrderCount); + + RuntimeCollisionAdmission resident = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + using PreparedLandblockCollisionGeneration preparedResident = + lifetime.Physics.PrepareCollisionGeneration(resident); + lifetime.Physics.StageCollisionAssets( + resident, + preparedResident, + CollisionAssets(DestinationLandblock)); + AddSyntheticCell(preparedResident.DataCache, DestinationIndoorCell); + Assert.True(CommitPrepared( + lifetime.Physics, + resident, + preparedResident).Committed); + + RuntimePlacementProjectionSnapshot placed = observer.Deltas[^1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.Equal(DestinationIndoorCell, placed.Token.ExactCellId); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + RuntimePhysicsOwnershipSnapshot final = lifetime.Physics.CaptureOwnership(); + Assert.Equal(0, final.DeferredSetPositionBucketCount); + Assert.Equal(0, final.UnboundDeferredSetPositionCellCount); + } + + [Fact] + public void CollisionAdmissionSupersessionAndInvalidationPreserveDeferredSurvivorOrder() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord first = CreateRecord(lifetime, 0x70001115u, 1); + RuntimeEntityRecord second = CreateRecord(lifetime, 0x70001116u, 1); + _ = AttachBody(lifetime, first, SourceCell); + _ = AttachBody(lifetime, second, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + foreach (RuntimeEntityRecord record in new[] { first, second }) + { + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request( + DestinationIndoorCell, + new Vector3(10f, 12f, 7f)))); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + } + observer.Deltas.Clear(); + + RuntimeCollisionAdmission superseded = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + RuntimeCollisionAdmission invalidated = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + Assert.Throws(() => + lifetime.Physics.PrepareCollisionGeneration(superseded)); + lifetime.Physics.CancelCollisionGeneration(invalidated); + RuntimeCollisionAdmission current = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + using PreparedLandblockCollisionGeneration prepared = lifetime.Physics + .PrepareCollisionGeneration(current); + lifetime.Physics.StageCollisionAssets( + current, + prepared, + CollisionAssets(DestinationLandblock)); + AddSyntheticCell(prepared.DataCache, DestinationIndoorCell); + Assert.True(CommitPrepared( + lifetime.Physics, + current, + prepared).Committed); + + Assert.Equal(2, observer.Deltas.Count); + Assert.Equal(first.Key, observer.Deltas[0].Placement.Token.Entity); + Assert.Equal(second.Key, observer.Deltas[1].Placement.Token.Entity); + Assert.All(observer.Deltas, delta => Assert.Equal( + RuntimePlacementProjectionKind.Place, + delta.Placement.Kind)); + Assert.True(lifetime.Physics.CaptureOwnership() + .DeferredSetPositionBucketCount == 0); + } + + [Fact] + public void NextAdmissionMergesOlderUnboundAndNewerFutureBoundSurvivors() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord older = CreateRecord(lifetime, 0x7000111Fu, 1); + RuntimeEntityRecord newer = CreateRecord(lifetime, 0x70001120u, 1); + _ = AttachBody(lifetime, older, SourceCell); + _ = AttachBody(lifetime, newer, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + RuntimeSetPositionOutcome olderLost = lifetime.Physics.SetPosition.Apply( + older, + older.PositionAuthorityVersion, + Command(Request( + DestinationIndoorCell, + new Vector3(10f, 12f, 7f)))); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + olderLost.Projection)); + RuntimeCollisionAdmission cancelled = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + lifetime.Physics.CancelCollisionGeneration(cancelled); + + RuntimeSetPositionOutcome newerLost = lifetime.Physics.SetPosition.Apply( + newer, + newer.PositionAuthorityVersion, + Command(Request( + DestinationIndoorCell, + new Vector3(11f, 12f, 7f)))); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + newerLost.Projection)); + RuntimePhysicsOwnershipSnapshot split = lifetime.Physics.CaptureOwnership(); + Assert.Equal(1, split.UnboundDeferredSetPositionCellCount); + Assert.Equal(1, split.DeferredSetPositionBucketCount); + observer.Deltas.Clear(); + + RuntimeCollisionAdmission current = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + RuntimePhysicsOwnershipSnapshot merged = lifetime.Physics.CaptureOwnership(); + Assert.Equal(0, merged.UnboundDeferredSetPositionCellCount); + Assert.Equal(1, merged.DeferredSetPositionBucketCount); + using PreparedLandblockCollisionGeneration prepared = lifetime.Physics + .PrepareCollisionGeneration(current); + lifetime.Physics.StageCollisionAssets( + current, + prepared, + CollisionAssets(DestinationLandblock)); + AddSyntheticCell(prepared.DataCache, DestinationIndoorCell); + Assert.True(CommitPrepared( + lifetime.Physics, + current, + prepared).Committed); + + Assert.Equal(2, observer.Deltas.Count); + Assert.Equal(older.Key, observer.Deltas[0].Placement.Token.Entity); + Assert.Equal(newer.Key, observer.Deltas[1].Placement.Token.Entity); + Assert.All(observer.Deltas, delta => Assert.Equal( + RuntimePlacementProjectionKind.Place, + delta.Placement.Kind)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observer.Deltas[0].Placement.Token)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observer.Deltas[1].Placement.Token)); + } + + [Theory] + [InlineData(false, 0x0101u)] + [InlineData(true, 0x0001u)] + public void CollisionRetirementParksOnlyAffectedDynamicParentlessRoots( + bool fullWithdrawal, + uint lowCell) + { + const uint landblock = 0x01010000u; + PhysicsEngine engine = FlatEngine(landblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord dynamicRoot = CreateRecord(lifetime, 0x70001007u, 1); + uint cell = landblock | lowCell; + PhysicsBody dynamicBody = AttachBody( + lifetime, + dynamicRoot, + landblock | 0x0001u); + ImmutableArray authoredSpheres = + [ + new FlatCollisionSphere(new Vector3(0.25f, 0f, 0f), 0.3f), + new FlatCollisionSphere(new Vector3(-0.2f, 0.1f, 0.4f), 0.2f), + ]; + RuntimeSetPositionOutcome prepared = lifetime.Physics.SetPosition.Apply( + dynamicRoot, + dynamicRoot.PositionAuthorityVersion, + Command(Request( + landblock | 0x0001u, + new Vector3(12f, 18f, 7f)) with + { + Spheres = authoredSpheres, + })); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + prepared.Projection)); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + dynamicRoot, + out int sphereCount)); + Assert.Equal(2, sphereCount); + lifetime.Entities.SetFullCell( + dynamicRoot, + cell, + landblock | 0xFFFFu); + dynamicBody.SnapToCell(cell, dynamicBody.Position, dynamicBody.Position); + RuntimeEntityRecord staticRoot = CreateRecord(lifetime, 0x70001008u, 1); + _ = AttachBody(lifetime, staticRoot, cell, PhysicsStateFlags.Static); + RuntimeEntityRecord parent = CreateRecord(lifetime, 0x7000104Cu, 1); + _ = AttachBody(lifetime, parent, SourceCell); + RuntimeEntityRecord attached = CreateRecord(lifetime, 0x7000104Du, 1); + _ = AttachBody(lifetime, attached, cell); + CommitParent(lifetime, parent, attached); + + lifetime.Physics.SetPosition.ParkCollisionResidents( + landblock, + includeOutdoorCells: fullWithdrawal); + + Assert.True(lifetime.Physics.SetPosition.IsDeferred(dynamicRoot)); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + Assert.Equal(0u, dynamicRoot.FullCellId); + Assert.False(lifetime.Physics.IsSpatialRoot(dynamicRoot)); + Assert.False(lifetime.Physics.SetPosition.IsDeferred(staticRoot)); + Assert.Equal(cell, staticRoot.FullCellId); + Assert.True(lifetime.Physics.IsSpatialRoot(staticRoot)); + Assert.False(lifetime.Physics.SetPosition.IsDeferred(attached)); + Assert.Equal(cell, attached.FullCellId); + + lifetime.Entities.ParentAttachments.EndChildProjection( + attached.ServerGuid); + lifetime.Physics.SetPosition.ParkCollisionResidents( + landblock, + includeOutdoorCells: fullWithdrawal); + Assert.True(lifetime.Physics.SetPosition.IsDeferred(attached)); + } + + [Fact] + public void PickupStyleLeaveWorldCancelsLostWakeAndTerminalDisposeConverges() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001009u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + lifetime.Physics.SetPosition.LeaveWorld(record); + AddFlatLandblock(engine, DestinationLandblock, 192f); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + + Assert.Equal(0u, record.FullCellId); + Assert.Equal(0u, body.CellPosition.ObjCellId); + Assert.False(body.InWorld); + Assert.False(lifetime.Physics.SetPosition.IsDeferred(record)); + + lifetime.Dispose(); + Assert.True(lifetime.Physics.CaptureOwnership().IsConverged); + } + + [Fact] + public void CommittedParentDoesNotLeakAcrossChildGuidReuse() + { + const uint childGuid = 0x7000104Eu; + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord parent = CreateRecord(lifetime, 0x7000104Fu, 1); + RuntimeEntityRecord retired = CreateRecord(lifetime, childGuid, 1); + _ = AttachBody(lifetime, parent, SourceCell); + _ = AttachBody(lifetime, retired, SourceCell); + CommitParent(lifetime, parent, retired); + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(childGuid, 1), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(retired)); + + RuntimeEntityRecord replacement = CreateRecord(lifetime, childGuid, 2); + _ = AttachBody(lifetime, replacement, SourceCell); + lifetime.Physics.SetPosition.ParkCollisionResidents( + SourceLandblock, + includeOutdoorCells: true); + + Assert.True(lifetime.Physics.SetPosition.IsDeferred(replacement)); + } + + [Fact] + public void NewerPositionPickupAndParentEachCancelExactLostOperation() + { + VerifyPositionChannelCancellation(CancellationChannel.Position); + VerifyPositionChannelCancellation(CancellationChannel.Pickup); + VerifyPositionChannelCancellation(CancellationChannel.Parent); + } + + [Fact] + public void DeleteGuidReuseAndSessionResetCannotWakeStaleIncarnation() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + const uint guid = 0x7000100Du; + RuntimeEntityRecord retired = CreateRecord(lifetime, guid, 1); + _ = AttachBody(lifetime, retired, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + retired, + retired.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, 1), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(retired)); + + RuntimeEntityRecord replacement = CreateRecord(lifetime, guid, 2); + PhysicsBody replacementBody = AttachBody( + lifetime, + replacement, + SourceCell); + AddFlatLandblock(engine, DestinationLandblock, 192f); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + Assert.Equal(SourceCell, replacement.FullCellId); + Assert.Equal(SourceCell, replacementBody.CellPosition.ObjCellId); + Assert.False(lifetime.Physics.SetPosition.IsDeferred(replacement)); + + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + replacement, + replacement.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(15f, 18f, 7f)))); + Assert.True(pending.Projection.IsValid); + IReadOnlyList retiring = lifetime.BeginSessionClear(); + Assert.Single(retiring); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .SetPositionOperationCount); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .PendingSetPositionHostAcknowledgementCount); + } + + [Fact] + public void ColdCollisionRetirementWaitsForExactAuthoredMoverPreparation() + { + const uint landblock = 0x01010000u; + PhysicsEngine engine = FlatEngine(landblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000100Eu, 1); + _ = AttachBody(lifetime, record, landblock | 0x0101u); + + lifetime.Physics.SetPosition.ParkCollisionResidents( + landblock, + includeOutdoorCells: false); + + Assert.True(lifetime.Physics.SetPosition.IsDeferred(record)); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken token)); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + withdrawal.Token)); + ImmutableArray authored = + [ + new FlatCollisionSphere(new Vector3(0.4f, 0f, 0f), 0.35f), + new FlatCollisionSphere(new Vector3(-0.3f, 0f, 0.5f), 0.2f), + ]; + RuntimeSetPositionOutcome supplied = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request( + landblock | 0x0101u, + new Vector3(999f, 999f, 999f)) with + { + Spheres = authored, + })); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, supplied.Status); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + Assert.False(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out _)); + AddSyntheticCell(engine.DataCache!, landblock | 0x0101u); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + landblock, + generation: 1, + ready: true); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out int sphereCount)); + Assert.Equal(2, sphereCount); + Assert.Equal(landblock | 0x0101u, + record.PhysicsBody!.CellPosition.ObjCellId); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot placed)); + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + [Fact] + public void ColdMalformedWakeReindexesUntilCorrectedPreparationAndNextAdmission() + { + const uint landblock = 0x01010000u; + uint exactCell = landblock | 0x0101u; + PhysicsEngine engine = FlatEngine(landblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001127u, 1); + _ = AttachBody(lifetime, record, exactCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + lifetime.Physics.SetPosition.ParkCollisionResidents( + landblock, + includeOutdoorCells: false); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken token)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + Assert.Single(observer.Deltas).Placement.Token)); + observer.Deltas.Clear(); + + RuntimeSetPositionCommand malformed = Command(Request( + exactCell, + new Vector3(10f, 18f, 7f)) with + { + Spheres = + [ + new FlatCollisionSphere( + new Vector3(float.NaN, 0f, 0f), + 0.4f), + ], + }); + RuntimeSetPositionOutcome acceptedMalformed = lifetime.Physics + .SetPosition.SubmitPreparedPlacement(token, malformed); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, + acceptedMalformed.Status); + + RuntimeCollisionAdmission first = lifetime.Physics + .BeginCollisionAdmission(landblock); + using (PreparedLandblockCollisionGeneration prepared = lifetime.Physics + .PrepareCollisionGeneration(first)) + { + lifetime.Physics.StageCollisionAssets( + first, + prepared, + CollisionAssets(landblock)); + AddSyntheticCell(prepared.DataCache, exactCell); + Assert.True(CommitPrepared( + lifetime.Physics, + first, + prepared).Committed); + } + RuntimeSetPositionOwnershipSnapshot invalidWake = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.Equal(1, invalidWake.AwaitingPreparationCount); + Assert.Equal(1, invalidWake.DeferredBucketCount); + Assert.Equal(1, invalidWake.LostDeadlineCount); + Assert.Empty(observer.Deltas); + Assert.False(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out _)); + + RuntimeSetPositionCommand corrected = Command(Request( + exactCell, + new Vector3(10f, 18f, 7f)) with + { + Spheres = + [ + new FlatCollisionSphere(Vector3.Zero, 0.4f), + ], + }); + RuntimeSetPositionOutcome correctedPending = lifetime.Physics + .SetPosition.SubmitPreparedPlacement(token, corrected); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, + correctedPending.Status); + Assert.Equal(PhysicsResidenceDisposition.DeferredCell, + correctedPending.Residence); + Assert.Equal(exactCell, correctedPending.ExactCellId); + + RuntimeCollisionAdmission second = lifetime.Physics + .BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration preparedSecond = lifetime + .Physics.PrepareCollisionGeneration(second); + lifetime.Physics.StageCollisionAssets( + second, + preparedSecond, + CollisionAssets(landblock)); + AddSyntheticCell(preparedSecond.DataCache, exactCell); + Assert.True(CommitPrepared( + lifetime.Physics, + second, + preparedSecond).Committed); + + RuntimePlacementProjectionSnapshot placed = Assert.Single(observer.Deltas) + .Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out int sphereCount)); + Assert.Equal(1, sphereCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + private static void VerifyPositionChannelCancellation( + CancellationChannel channel) + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + uint guid = 0x7000100Au + (uint)channel; + RuntimeEntityRecord record = CreateRecord(lifetime, guid, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + switch (channel) + { + case CancellationChannel.Position: + Assert.True(lifetime.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + guid, + new CreateObject.ServerPosition( + SourceCell, 11f, 20f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out _, + out _, + out _)); + break; + case CancellationChannel.Pickup: + Assert.True(lifetime.TryApplyPickup( + new PickupEvent.Parsed(guid, 1, 2), + acknowledgeProjection: null, + out _)); + break; + case CancellationChannel.Parent: + RuntimeEntityRecord parent = CreateRecord( + lifetime, + guid + 0x100u, + 4); + Assert.True(lifetime.TryApplyParent( + new ParentEvent.Parsed( + parent.ServerGuid, + guid, + ParentLocation: 1, + PlacementId: 2, + ParentInstanceSequence: parent.Incarnation, + ChildPositionSequence: 2), + acknowledgeProjection: null, + out _)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(channel)); + } + + Assert.False(lifetime.Physics.SetPosition.IsDeferred(record)); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .DeferredSetPositionCount); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .LostCellDeadlineCount); + } + + private static void CommitParent( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord parent, + RuntimeEntityRecord child) + { + var relation = new ParentAttachmentRelation( + parent.ServerGuid, + child.ServerGuid, + ParentLocation: 1, + PlacementId: 2, + ParentInstanceSequence: parent.Incarnation, + ChildPositionSequence: 1); + lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(lifetime.Entities.ParentAttachments.CommitProjection(relation)); + } + + private static RuntimeSetPositionCommand Command( + PhysicsSetPositionRequest request) => new( + request, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 10d, + ExpectedVelocityAuthorityVersion: 0UL, + ShadowWorldOffsetX: request.CellId == SourceCell ? 0f : 192f, + ShadowWorldOffsetY: 0f); + + private static PhysicsSetPositionRequest CrossLandblockRequest() => + Request( + SourceCell, + new Vector3(193f, 12f, 7f), + new Vector3(193f, 12f, 7f)); + + private static PhysicsSetPositionRequest Request( + uint cellId, + Vector3 position, + Vector3? cellLocal = null) => new( + position, + Quaternion.Identity, + cellId, + cellLocal ?? position, + ImmutableArray.Empty, + Scale: 1f, + StepUpHeight: 0.4f, + StepDownHeight: 0.4f, + Flags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide); + + private static RuntimeEntityRecord CreateRecord( + RuntimeEntityObjectLifetime lifetime, + uint guid, + ushort incarnation) + { + RuntimeEntityRecord record = lifetime.RegisterEntity( + Spawn(guid, incarnation)).Canonical!; + lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity); + return record; + } + + private static PhysicsBody AttachBody( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record, + uint cellId, + PhysicsStateFlags state = PhysicsStateFlags.Gravity) + { + lifetime.Entities.SetFullCell( + record, + cellId, + (cellId & 0xFFFF0000u) | 0xFFFFu); + lifetime.Entities.SetFinalPhysicsState(record, state); + var body = new PhysicsBody + { + Position = new Vector3(10f, 20f, 7f), + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = state, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cellId, body.Position, body.Position); + lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true); + return body; + } + + private static PhysicsEngine FlatEngine(uint landblock, float worldOffsetX) + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + AddFlatLandblock(engine, landblock, worldOffsetX); + return engine; + } + + private static RuntimeLandblockCollisionAssets CollisionAssets( + uint landblockId) => new( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + WorldOffsetX: 192f, + WorldOffsetY: 0f, + CurrentCellId: 0u); + + private static RuntimeCollisionGenerationCommit CommitPrepared( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + while (true) + { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (seal.Completed) + break; + } + return physics.CommitCollisionGeneration(admission, prepared); + } + + private static void AddSyntheticCell( + PhysicsDataCache cache, + uint cellId) + { + cache.RegisterCellStructForTest( + cellId, + new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + Portals = [new PortalInfo(0, 0, 0)], + CellBSP = new CellBSPTree + { + Root = new CellBSPNode { Type = BSPNodeType.Leaf }, + }, + }); + cache.CellGraph.Add(new EnvCell( + cellId, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + seenOutside: false, + containmentBsp: null)); + } + + private static void AddFlatLandblock( + PhysicsEngine engine, + uint landblock, + float worldOffsetX) + { + engine.AddLandblock( + landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX, + worldOffsetY: 0f); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) + { + var position = new CreateObject.ServerPosition( + SourceCell, + 10f, + 20f, + 7f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Gravity, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "set-position-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.Gravity, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class PlacementObserver( + Action? onPlacement = null) + : IRuntimePlacementObserver + { + internal List Deltas { get; } = []; + + public void OnPlacement(in RuntimePlacementDelta delta) + { + Deltas.Add(delta); + onPlacement?.Invoke(delta); + } + } + + private sealed class EntityObserver : IRuntimeEntityObjectObserver + { + internal List Deltas { get; } = []; + + public void OnEntity(in RuntimeEntityDelta delta) => Deltas.Add(delta); + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + } + + private sealed class ThrowingPlacementObserver : IRuntimePlacementObserver + { + public void OnPlacement(in RuntimePlacementDelta delta) => + throw new InvalidOperationException("fixture observer failure"); + } + + private sealed class ReentrantRemotePlacement(PhysicsBody body) + : IRuntimeRemotePlacement + { + private Func? _readCell; + private Action? _writeCell; + private uint _cellId; + + public PhysicsBody Body { get; } = body; + public uint CellId + { + get => _readCell?.Invoke() ?? _cellId; + set + { + _cellId = value; + _writeCell?.Invoke(value); + } + } + public bool Airborne { get; set; } + public Vector3 LastServerPosition { get; set; } + public double LastServerPositionTime { get; set; } + public Vector3 LastShadowSyncPosition { get; set; } + public Quaternion LastShadowSyncOrientation { get; set; } + internal Action? OnHitGround { get; init; } + internal Action? OnLeaveGround { get; init; } + + public void BindCanonicalCell(Func read, Action write) + { + _readCell = read; + _writeCell = write; + } + + public void HitGround() => OnHitGround?.Invoke(); + + public void LeaveGround() => OnLeaveGround?.Invoke(); + } + + private sealed class ManualTimeProvider(DateTimeOffset utcNow) + : TimeProvider + { + private DateTimeOffset _utcNow = utcNow; + private long _timestamp; + + public override DateTimeOffset GetUtcNow() => _utcNow; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => _timestamp; + + internal void Advance(TimeSpan duration) + { + _utcNow += duration; + _timestamp += duration.Ticks; + } + + internal void JumpUtc(TimeSpan duration) => _utcNow += duration; + } + + private enum CancellationChannel : uint + { + Position, + Pickup, + Parent, + } +} + +internal static class RuntimePlacementProjectionTokenTestExtensions +{ + internal static ulong TokenSequence( + this RuntimePlacementProjectionToken token) => token.Sequence; +} From 270f5154b9125460c7ca46907c47492b097bf7ad Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 23:11:44 +0200 Subject: [PATCH 27/73] feat(runtime): expose dormant placement receipts --- docs/architecture/acdream-architecture.md | 8 ++ .../retail-divergence-register.md | 11 ++- .../2026-07-31-canonical-set-position.md | 30 +++++++ .../Entities/RuntimeEntityObjectLifetime.cs | 11 +++ src/AcDream.Runtime/GameRuntime.cs | 3 + .../RuntimePlacementProjectionChannel.cs | 88 +++++++++++++++++++ .../Physics/RuntimeSetPositionState.cs | 2 + .../Runtime/RuntimePhysicsOwnershipTests.cs | 28 ++++++ .../Physics/RuntimeSetPositionStateTests.cs | 46 ++++++++++ 9 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 76a38787..c360f637 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -244,6 +244,9 @@ src/ RuntimeSetPositionState.cs -> exact placement/lost-cell operations, authored mover retention, ordered host receipts, and collision-generation wake + RuntimePlacementProjectionChannel.cs -> generation-gated public host + observation/retry/exact-ack seam over the + one Runtime SetPosition receipt owner RuntimeRemotePhysicsUpdater.cs -> presentation-free remote simulation RuntimeOrdinaryPhysicsUpdater.cs -> presentation-free object simulation RuntimeProjectile.cs -> canonical projectile component/prediction owner @@ -332,6 +335,11 @@ src/ AppPluginHost.cs -> done ``` +The 4B2 production SetPosition routes and shared local-controller body remain +dormant until exact authored mover preparation, collision-report return, +presentation-only rebucketing, placement-prefix quiescence, and an atomic +Runtime body/controller publication transaction land as one reviewed cutover. + --- ## Movement And Collision Architecture diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bf6ef268..38c216af 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -80,12 +80,15 @@ loader branch there. Slice 4B must preserve the flag while mapping successful deferred placement to exact-cell, generation-scoped asynchronous admission; the presence of the flag in the immutable request is not claimed as exactness. -AP-1/AD-1 checkpoint (placement Slice 4B1, 2026-07-31): Runtime now owns the +AP-1/AD-1 checkpoint (placement Slice 4B2 checkpoint 1, 2026-07-31): Runtime now owns the exact accepted placement/lost-cell transaction, atomic body/contact/cell/ shadow/workset commit, adjusted retained frame, authored mover preparation, exact-cell/generation wake, append/swap lost buckets, a bounded indexed deadline heap, independent root/direct-child deadlines, and revisioned ordered -host receipts. Both rows remain open until 4B2 cuts graphical and no-window +host receipts. A public generation-gated observe/retry/exact-ack channel now +projects that one receipt owner. Shared local-controller body adoption remains +deferred to the atomic all-route ownership cutover. Both rows remain open until +4B2 cuts graphical and no-window production routes over, quiesces active placement before invoking the dormant collision-retirement entry, and binds portal authority to `RuntimeWorldTransitState`. AD-2 remains the deliberate async @@ -104,7 +107,7 @@ readiness/requeue adaptation. See | AD-51 | **Filed at Campaign N slice N4 (2026-07-29).** The inbound sequence tracker keeps a reclaimed-word pool (per-parked-word draw ordinals + `PriorityQueue` consumed lowest-draw-order-first) that retail has no counterpart for: on a VALIDATED cleartext `RejectRetransmit`, the word the gap walk parked for the reject packet's OWN sequence is removed, every later-drawn parked word is shifted down one position, and the excess word feeds the next fresh draws. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`OnCleartextRejectSequence`, `NextWord`, `ParkedWord`); trigger at `src/AcDream.Core.Net/WorldSession.cs` (RejectRetransmit consumption) | Retail's inbound invariant is "every missing id was an encrypted packet whose keystream word the server drew" — true against retail servers, whose cleartext packets always borrow live sequences (acks/NAKs reuse `highestIDSent_`; `FlowQueue::TransmitNewPackets @ 0x00547A60` sequences only reliable packets). ACE breaks it in exactly one place: `RejectRetransmit` takes a FRESH sequence through FlushPackets, cleartext, drawing NO S2C keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748). Without the reclaim, our gap walk pre-draws a word for that id, the inbound stream runs permanently one word ahead, and every later encrypted packet fails checksum — the N2 desync class reintroduced through the reject path. The pool is provably empty against a retail server, so retail behavior is untouched. Reject BODY ids keep the N2 discard (their words were drawn on both sides — consumed-in-place). Known unreachable corner: a reject whose own id later appears inside another reject's body (first reject pruned after 120 s of sustained loss with the session alive) would discard a never-drawn word; probabilistically impossible against ACE's 60 s silence timeout and the 0.6 s NAK cadence. | Against a hypothetical non-ACE server that assigns fresh cleartext sequences to packets OTHER than RejectRetransmit, those ids would still mis-park with no reclaim trigger — inbound desync. Only ACE-family servers exist for this client today, and ACE has exactly the one path. | `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the gap walk whose invariant ACE breaks); `SharedNet::HandleEmptyAck @ 0x005448F0` (retail's reject consumption — body ids only, no own-sequence machinery because retail never needs it) | | AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | -| AD-1 | **NARROWED 2026-07-31 (placement Slice 4B1).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, and host Withdraw/Place receipts. Production graphical/headless authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until 4B2 cuts those routes over. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owner is dormant and separately gated so landing it cannot change the accepted production world before the complete route/host-ack cutover. | Until 4B2, a production gap can still commit an outdoor approximation inside/under a building or lift a legitimate below-heightmap restore instead of entering the now-available Runtime lost-cell owner. | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | +| AD-1 | **NARROWED 2026-07-31 (placement Slice 4B2 checkpoint 1).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, revisioned Withdraw/Place receipts, and one public generation-gated observe/retry/exact-ack seam. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until the remaining 4B2 prerequisites and routes cut over atomically. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owner remains dormant and separately gated, so this ownership checkpoint cannot partially change the accepted production world. | Until 4B2, a production gap can still commit an outdoor approximation inside/under a building or lift a legitimate below-heightmap restore instead of entering the now-available Runtime lost-cell owner. | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | @@ -152,7 +155,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B1).** Core exposes the pure retail `SetPosition` transaction and Runtime now owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, and revisioned host receipts. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies exact authored mover preparation and cuts graphical/headless inbound families to this dormant owner. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md` | The mechanism, ownership, and failure/reentrancy gates land independently without partially changing production behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owner now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | +| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B2 checkpoint 1).** Core exposes the pure retail `SetPosition` transaction; Runtime owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, and revisioned host receipts; and one public generation-gated channel exposes observe/retry/exact-head acknowledgement without another queue. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies the real collision-report return, exact authored mover preparation, presentation-only rebucketing, placement-prefix quiescence, and the atomic graphical/headless route cutover. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md` | The mechanism, ownership, and host seam land independently without partially changing production placement behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owner now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | | ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 | diff --git a/docs/research/2026-07-31-canonical-set-position.md b/docs/research/2026-07-31-canonical-set-position.md index 6c1ae257..f738c038 100644 --- a/docs/research/2026-07-31-canonical-set-position.md +++ b/docs/research/2026-07-31-canonical-set-position.md @@ -262,6 +262,36 @@ Two boundaries intentionally remain open for 4B2: the per-object report/tracking owner required for that boolean is not yet in Runtime, and the former environment/object-presence guess is forbidden. +### Slice 4B2 checkpoint 1 — public dormant host seam + +The first 4B2 checkpoint exposes the dormant receipt owner through +`RuntimePlacementProjectionChannel`. Graphical and no-window hosts can observe +the one ordered placement stream, retry the exact immutable pending receipts, +peek the FIFO head, measure pending debt, and acknowledge only the exact head. +Mutation and retry calls require the current `RuntimeGenerationToken`; a stale +generation, stale revision, reordered token, duplicate acknowledgement, or +reused GUID cannot consume current placement debt. The channel delegates to +`RuntimeSetPositionState` and `RuntimeEntityObjectEventStream`; it owns no +second queue, mirror, or rollback path. + +Shared local-controller body adoption is deliberately deferred. A reviewed +prototype that prepared directly on the canonical body was rejected: a +snapshot/rollback lease cannot safely coexist with reentrant SetPosition, +remote/projectile binding, deletion/GUID reuse, owner replacement, object-clock +epoch changes, or disposal. Correct adoption requires either an exclusive +Runtime transaction integrated with every canonical writer, or off-canonical +preparation followed by one validated atomic body/controller publication. +Either choice belongs to the all-route ownership cutover, not this narrow +dormant-seam checkpoint. + +This is still a deliberately non-activating checkpoint. Production spawn, +Position, projectile, drop/pickup/parent, and portal routes do not submit to +the dormant SetPosition owner yet. The cutover remains blocked on the real +retail collision report/tracking return, exact ordered Setup spheres/scale/ +step heights/flags/cell-local preparation, presentation-only rebucketing, and +placement-prefix quiescence before collision retirement. AP-1 and AD-1 remain +open until those prerequisites and every production route land together. + AD-2 remains the explicit async adaptation: collision readiness can publish in a different frame from retail's blocking load. A failed wake is safely re- indexed to the next exact generation instead of inheriting retail's diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 4d7be7c5..5a43aa60 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -118,6 +118,9 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); Physics.SetPosition.BindEventStream(Events); + Placements = new RuntimePlacementProjectionChannel( + Events, + Physics.SetPosition); } internal RuntimeEntityObjectLifetime( @@ -140,6 +143,9 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); Physics.SetPosition.BindEventStream(Events); + Placements = new RuntimePlacementProjectionChannel( + Events, + Physics.SetPosition); } internal RuntimeEntityObjectLifetime( @@ -162,6 +168,9 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); Physics.SetPosition.BindEventStream(Events); + Placements = new RuntimePlacementProjectionChannel( + Events, + Physics.SetPosition); } public RuntimeEntityDirectory Entities { get; } @@ -170,6 +179,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable public IRuntimeEntityView EntityView { get; } public IRuntimeInventoryView InventoryView { get; } public RuntimeEntityObjectEventStream Events { get; } + public RuntimePlacementProjectionChannel Placements { get; } public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership() { @@ -204,6 +214,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { EnsureNotDisposed(); Events.BindContext(generation, frameNumber); + Placements.BindGeneration(generation); } /// diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 1625d47b..4a15341a 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -1,5 +1,6 @@ using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; using AcDream.Runtime.Session; using AcDream.Runtime.World; @@ -303,6 +304,8 @@ public sealed class GameRuntime public RuntimeWorldEnvironmentState EnvironmentOwner { get; } public RuntimeWorldTransitState TransitOwner { get; } public RuntimeGenerationReset GenerationReset { get; } + public RuntimePlacementProjectionChannel Placements => + EntityObjects.Placements; public RuntimeGenerationToken Generation => Session.Generation; diff --git a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs new file mode 100644 index 00000000..807c178d --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs @@ -0,0 +1,88 @@ +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Physics; + +/// +/// Public host boundary for Runtime-owned SetPosition projection receipts. +/// The channel owns no placement state: observation, retry, and exact-token +/// acknowledgement delegate to the canonical entity lifetime's event stream +/// and SetPosition owner. +/// +public sealed class RuntimePlacementProjectionChannel +{ + private readonly RuntimeEntityObjectEventStream _events; + private readonly RuntimeSetPositionState _setPosition; + private Func _generation = static () => default; + private bool _generationBound; + + internal RuntimePlacementProjectionChannel( + RuntimeEntityObjectEventStream events, + RuntimeSetPositionState setPosition) + { + _events = events ?? throw new ArgumentNullException(nameof(events)); + _setPosition = setPosition + ?? throw new ArgumentNullException(nameof(setPosition)); + } + + /// + /// Observes ordered immutable projection receipts on the Runtime commit + /// thread. A host must acknowledge only after its projection succeeds. + /// + public IDisposable Subscribe(IRuntimePlacementObserver observer) => + _events.SubscribePlacement(observer); + + internal void BindGeneration(Func generation) + { + ArgumentNullException.ThrowIfNull(generation); + if (_generationBound) + { + throw new InvalidOperationException( + "The Runtime placement generation source is already bound."); + } + + _generation = generation; + _generationBound = true; + } + + /// + /// Acknowledges only the exact oldest outstanding receipt. Stale, + /// reordered, superseded, or already acknowledged tokens are rejected. + /// + public bool Acknowledge( + RuntimeGenerationToken expectedGeneration, + in RuntimePlacementProjectionToken token) => + IsCurrent(expectedGeneration) + && _setPosition.AcknowledgeProjection(token); + + /// + /// Republishes every still-pending immutable receipt in canonical order. + /// Runtime authority is never replayed or recommitted by a retry. + /// + public bool RetryPending(RuntimeGenerationToken expectedGeneration) + { + if (!IsCurrent(expectedGeneration)) + return false; + _setPosition.RetryPendingProjections(); + return true; + } + + /// + /// Returns the exact oldest outstanding receipt without consuming it. + /// + public bool TryPeek( + RuntimeGenerationToken expectedGeneration, + out RuntimePlacementProjectionSnapshot projection) + { + if (IsCurrent(expectedGeneration)) + return _setPosition.TryPeekProjection(out projection); + projection = default; + return false; + } + + public int PendingCount => _setPosition.PendingProjectionCount; + + private bool IsCurrent(RuntimeGenerationToken expectedGeneration) => + _generationBound + && expectedGeneration.Value != 0UL + && expectedGeneration == _generation(); +} diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index a4381184..302318e1 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -283,6 +283,8 @@ internal sealed class RuntimeSetPositionState : IDisposable _preparedMovers.Count); } + internal int PendingProjectionCount => _pendingProjection.Count; + internal void BindEventStream(RuntimeEntityObjectEventStream events) { EnsureNotDisposed(); diff --git a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs index 25e3ee4e..37082f4f 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs @@ -4,6 +4,34 @@ namespace AcDream.App.Tests.Runtime; public sealed class RuntimePhysicsOwnershipTests { + [Fact] + public void PlacementProjectionChannelRemainsDormantInProductionHosts() + { + string root = FindRepositoryRoot(); + foreach (string relative in new[] + { + Path.Combine("src", "AcDream.App"), + Path.Combine("src", "AcDream.Headless"), + }) + { + foreach (string file in Directory.EnumerateFiles( + Path.Combine(root, relative), + "*.cs", + SearchOption.AllDirectories)) + { + string source = File.ReadAllText(file); + Assert.DoesNotContain( + ".Placements.", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "RuntimePlacementProjectionChannel", + source, + StringComparison.Ordinal); + } + } + } + [Fact] public void ProductionAppBorrowsTheRuntimePhysicsWorld() { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index 1762d8f9..6a2b14a3 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -103,6 +103,52 @@ public sealed class RuntimeSetPositionStateTests Assert.Equal(1, ownership.PreparedMoverCount); } + [Fact] + public void PublicPlacementChannelObservesRetriesAndAcknowledgesExactToken() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + var generation = new RuntimeGenerationToken(7UL); + lifetime.BindEventContext(() => generation, static () => 11UL); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001012u, 1); + _ = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Placements.Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(14f, 19f, 7f)))); + + Assert.Single(observer.Deltas); + Assert.True(lifetime.Placements.TryPeek(generation, out var pending)); + Assert.Equal(outcome.Projection, pending.Token); + Assert.True(lifetime.Placements.RetryPending(generation)); + Assert.Equal(2, observer.Deltas.Count); + Assert.Equal( + observer.Deltas[0].Placement, + observer.Deltas[1].Placement); + + RuntimePlacementProjectionToken stale = outcome.Projection with + { + Revision = outcome.Projection.Revision + 1UL, + }; + Assert.False(lifetime.Placements.Acknowledge(generation, stale)); + Assert.False(lifetime.Placements.RetryPending( + new RuntimeGenerationToken(8UL))); + Assert.False(lifetime.Placements.TryPeek( + new RuntimeGenerationToken(8UL), + out _)); + Assert.True(lifetime.Placements.Acknowledge( + generation, + outcome.Projection)); + Assert.Equal(0, lifetime.Placements.PendingCount); + Assert.False(lifetime.Placements.TryPeek(generation, out _)); + Assert.False(lifetime.Placements.Acknowledge( + generation, + outcome.Projection)); + } + [Fact] public void InWorldSetPositionPreservesObjectClockEpochPendingTimeAndActiveState() { From ec627c13a2777799d219b18a958ba95075a4f431 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 23:13:46 +0200 Subject: [PATCH 28/73] docs(physics): hand off remaining divergence campaign --- ...7-31-remaining-physics-campaign-handoff.md | 491 ++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 docs/research/2026-07-31-remaining-physics-campaign-handoff.md diff --git a/docs/research/2026-07-31-remaining-physics-campaign-handoff.md b/docs/research/2026-07-31-remaining-physics-campaign-handoff.md new file mode 100644 index 00000000..2593ce45 --- /dev/null +++ b/docs/research/2026-07-31-remaining-physics-campaign-handoff.md @@ -0,0 +1,491 @@ +# Remaining physics-divergence campaign handoff — 2026-07-31 + +## Purpose and stopping point + +This is the deliberate handoff boundary requested after placement Slice 4B2 +checkpoint 1. The repository is stopped before any production graphical or +headless route submits to the canonical Runtime SetPosition owner. + +The completed foundation is useful and tested, but the overall campaign is +**not complete**. AP-1 and AD-1 remain narrowed/open. AP-22 and AD-10 remain +open. Do not retire those rows until their exact automated and connected gates +pass. + +### Exact workspace + +- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream` +- Branch: `codex/port-claude-agents` +- Handoff code checkpoint: `270f5154` + (`feat(runtime): expose dormant placement receipts`) +- Immediately preceding residence-owner checkpoint: `4c02ac42` + (`feat(runtime): own deferred set-position residence`) +- Pure Core SetPosition checkpoint: `e84a388e` + (`feat(physics): port canonical retail set-position core`) +- No upstream is configured for this worktree branch. +- Remotes: + - `origin`: `https://git.snakedesert.se/erik/acdream.git` + - `github`: `git@github.com:eriknihlen/acdream.git` + +The handoff was written in this same worktree. The next agent should continue +there rather than creating a different worktree unless the user explicitly +requests it. + +## Worktree hygiene + +The worktree intentionally reports unrelated modifications. Preserve them. +Never use `git add -A`, `git reset --hard`, or checkout/revert commands against +these paths. + +At the checkpoint, `AGENTS.md` has a real unrelated content diff. The following +paths report modified due to existing line-ending/stat noise but have no +content diff against the index: + +- `src/AcDream.App/Input/PlayerModeController.cs` +- `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` +- `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs` +- `src/AcDream.App/World/LiveEntityRuntime.cs` +- `src/AcDream.Core/Physics/CellArray.cs` +- `src/AcDream.Core/Physics/PhysicsBody.cs` +- `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` +- `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` +- `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` +- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` +- `tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs` +- `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs` +- `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs` +- `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` +- `tools/A8CellAudit/A8CellAudit.csproj` + +Before every commit, stage exact paths and inspect: + +```powershell +git diff --check +git diff --cached --check +git diff --cached --stat +git status --short +``` + +## What is complete + +### Campaign baseline and issue #273 + +- `c24bc571` — retail StepDown support-radius behavior for tight gaps. +- `10b55d74` — tight-gap controls and diagnostics. + +### Retail retry and edge/StepDown dispatcher + +- `e5f855ac` — nested per-cell collision retries. +- `67d1e9b3` — refreshed-cell retry state. +- `4ca7230b` — retained cell across inner retries. +- `c559c48d` — retail edge-response ordering. +- `4fbd93ec` — edge-slide stop semantics. +- `1fd5da67` — StepDown placement validation. +- `acec33ec` — StepDown probe state. +- `75b6f6b6` — Path-6 collision response. +- `d3c0d9ec` — TS-4 production chronology gate. + +These retire AP-3, AP-4, AP-5, AD-53, AD-54, and TS-4 under the tests and +research already recorded in the divergence register. + +### Exact cell availability and atomic collision generations + +- `7716c2ee` — retail cell-availability semantics. +- `3e0f3b62` — containment-root validation. +- `be94bc9b` — atomic collision-generation activation. +- `d94145e6` — seal before activation. +- `6b28ff99` — starvation-free activation. + +These retire AD-3, AD-4, and AD-6. The active collision world remains visible +until a complete replacement generation is atomically committed. + +### Canonical SetPosition Core and Runtime residence owner + +- `e84a388e` ports the pure Core SetPosition transaction. +- `4c02ac42` adds `RuntimeSetPositionState`, including: + - exact accepted operation ownership; + - canonical body/contact/cell/shadow/workset commit; + - authored mover retention; + - exact-cell and collision-generation wake; + - 25-second root/direct-child lost-cell lifetime; + - bounded indexed deadline structures; + - revisioned ordered Withdraw/Place/Discard host receipts; + - cancellation/GUID-reuse/reset/disposal convergence; + - structural cell/quaternion validation and bounded scatter work. +- `270f5154` adds `RuntimePlacementProjectionChannel`, a public, + generation-gated host seam over the one existing receipt owner: + - subscribe to ordered immutable receipts; + - peek the exact FIFO head; + - retry pending receipts without recommitting Runtime state; + - acknowledge only the exact current FIFO-head token; + - observe pending receipt debt. + +The channel owns no second queue and has no App or Headless production +consumer. A source guard pins that dormancy. This is intentional. + +### Validation at the stopping point + +The final channel-only checkpoint passed: + +- Release solution build: 0 errors, 18 existing warnings. +- Complete Release suite: 10,279 passed, 4 skipped, 0 failed. +- Runtime SetPosition focused tests: 47/47. +- App dormancy/ownership guards: 3/3. +- `git diff --check`: clean. +- Retail-conformance re-review: clean. +- Architecture/adversarial re-review: clean. + +## Rejected prototype — do not resurrect it + +A prototype made graphical and headless `PlayerMovementController` instances +borrow the canonical `RuntimeEntityRecord.PhysicsBody` immediately and used a +snapshot/rollback lease to recover from late construction failures. It was +fully removed before `270f5154`. + +The design was rejected because it was failure-atomic only without +reentrancy. While the lease was open, a nested SetPosition, remote/projectile +bind or update, deletion/GUID replacement, object-clock epoch change, or +disposal could establish newer authority. The outer rollback could then erase +that newer commit or detach a body already used by another canonical owner. + +The next implementation must use one of these complete solutions: + +1. A Runtime-owned exclusive/versioned controller/body publication + transaction respected by **every** canonical body writer, binding path, + SetPosition operation, clock epoch transition, deletion, reset, and + disposal; or +2. Off-canonical preparation followed by one validated atomic Runtime commit + that publishes the prepared controller/body relationship without copying + stale state over newer authority. + +Because every writer must participate, this belongs to the atomic production +route cutover. Do not reintroduce a local snapshot lease or a Commit method +that merely checks the body reference at the end. + +## What remains — required execution order + +### Slice 4B2 prerequisite A — real collision-report ownership + +`RuntimePhysicsState.HandleSetPositionCollisions` still returns `false`. +Retail `CPhysicsObj::SetPositionInternal` (`0x00515330`) returns the real +per-object report/tracking result. Establish the Runtime owner for that result +and return it exactly. Do not restore the former environment/object-presence +guess. + +Required tests: + +- report/no-report objects; +- collided object set ordering and lifetime; +- reentrant deletion/reset; +- graphical/headless equality; +- no report state surviving GUID reuse. + +### Slice 4B2 prerequisite B — exact authored mover preparation + +Every production preparation must pass the full SetPosition request using: + +- Setup's exact ordered authored spheres; +- exact scale, including valid zero/presence semantics; +- exact StepUp and StepDown heights; +- exact flags; +- exact cell-local frame and orientation; +- current position/vector/state authority versions. + +Do not reconstruct a cylinder from visual radius/height, clamp a positive +scale, use the projectile mover as a generic object fallback, or pre-mutate +`FullCellId`, `PhysicsBody`, `WorldEntity`, App buckets, or shadows. + +### Slice 4B2 prerequisite C — atomic local controller/body publication + +Implement the complete transaction described in the rejected-prototype note. +Graphical and no-window controllers must end with the exact same Runtime body, +but construction cannot expose or mutate canonical state before the validated +atomic commit. + +Adversarial gates must include: + +- nested construction; +- reentrant SetPosition; +- remote and projectile binding/update; +- deletion and same-GUID new incarnation; +- projection-owner replacement; +- object-clock epoch change; +- reset and disposal; +- commit and rollback after replacement; +- late graphical camera/shadow/host failure; +- late headless prepared-collision failure. + +### Slice 4B2 prerequisite D — presentation-only host projection + +Add one graphical and one headless `IRuntimePlacementObserver` using +`GameRuntime.Placements`. + +Host receipt rules: + +- `Withdraw`: remove render/spatial presentation, picking, radar, audio, and + targeting while retaining logical Runtime ownership. +- `Place`: project only the immutable Runtime-committed frame, then + acknowledge the exact token. +- `Discard`: discard the older projection revision, then acknowledge it. +- A host exception or unavailable backend does not roll Runtime back; retry + the same FIFO head. + +`LiveEntityRuntime.RebucketLiveEntity` must become presentation-only. Its +current `CommitRebucket` call is a second spatial authority and must be removed +as part of the same cutover. + +### Slice 4B2 prerequisite E — collision-prefix quiescence + +Before landblock collision demotion, removal, or replacement: + +1. quiesce the prefix; +2. drain/ack the existing placement receipt prefix; +3. call `RuntimeSetPositionState.ParkCollisionResidents`; +4. commit/withdraw the collision generation atomically; +5. wake only exact cell+generation residents. + +Cover `LandblockPhysicsPublisher.DemoteToTerrain`, `RemoveLandblock`, +replacement commit, and the headless collision-retirement path. No partially +observable collision generation is allowed. + +## Production route cutover + +Cut routes only after all prerequisites above are present. The canonical chain +for every route is: + +```text +wire acceptance + -> BeginAcceptedPlacement exact token + -> exact DAT/Setup preparation + -> Runtime SetPosition canonical commit or deferred residence + -> immutable host projection receipt + -> exact host acknowledgement +``` + +### 1. Initial login and CreateObject + +Current duplicate authority: + +- `DatLiveEntityProjectionMaterializer.MaterializeProjection` immediately + positions/rebuckets the world entity. +- `PlayerModeController.BuildControllerAndCamera` builds its own body and runs + `Resolve`/`ResolvePlacement`. +- Headless performs its own initial resolve/placement/body construction. + +Required order: + +1. register identity cellless; +2. begin initial/remote-create placement before hydration; +3. load exact Setup mover; +4. prepare the atomic Runtime controller/body relationship; +5. submit canonical SetPosition; +6. publish presentation only from `Place`; +7. acknowledge, then enable player mode/simulation. + +Tests: outdoor/indoor login, unavailable destination then exact-generation +wake, malformed or delayed Setup, one body identity, one enter-world clock +reset, no early visible entity, graphical/headless identical snapshots. + +### 2. Local ForcePosition + +Delete the placement authority in `LocalForcePositionTransaction` and the +direct `BlipPosition`/pre-commit acknowledgement in +`LiveEntityNetworkUpdateController.OnPosition`. + +Required order: accept timestamp and preserve heading; begin +`LocalAuthoritative`; canonical SetPosition; host `Place` acknowledgement; +then send the outbound Position acknowledgement. A missing destination cell +must not acknowledge ACE early. + +Tests: same/cross cell, preserved heading/velocity, missing-cell wake, +reentrant newer Position, stale host ack, exactly one outbound ack. + +### 3. Portal transit and materialization + +Remove placement authority from `LocalPlayerTeleportPlacement.Place` and its +direct resolve/controller/world-entity/rebucket/spatial mutations. + +Bind a Runtime portal-placement authority to the active +`RuntimeWorldTransitState` reveal generation, teleport sequence, exact +destination cell, and placement token. Readiness permits submission only. +Materialization and simulation release happen only after canonical commit, +host projection, and exact acknowledgement. Cancellation/replacement produces +`Discard`; a stale generation/sequence/cell/token can never reveal. + +Tests: `/ls`, spell recall, ordinary portal, same-location revisit, missing +destination, cancelled/replaced reveal, host throw/retry, no early world reveal +or LoginComplete. + +### 4. Remote CreateObject and Position + +Delete `RemoteTeleportController`, `RemoteTeleportPlacement`, their pending +dictionary/rollback/lost-cell ownership, and pre-placement +`WorldEntity.SetPosition`/rebucket calls. + +Preserve retail `MoveOrTeleport` classification: + +- fresh Teleport timestamp or cellless body: teleport hook then SetPosition; +- ordinary nearby grounded update: interpolation remains; +- distant update: stop interpolation then SetPosition. + +Accept the timestamp, begin the exact token before hydration/body/App changes, +unparent first, run the retail teleport hook when required, submit the exact +mover, project after Runtime commit, then re-arm constraints. + +Tests: visible/hidden/parented CreateObject, first Position, Teleport timestamp, +near interpolation, >96 m far placement, unloaded indoor destination, racing +velocity, delete/GUID reuse during host callback. + +### 5. Projectile authoritative create/corrections + +Remove authoritative placement from App `ProjectileController` and the direct +SnapToCell/cell/shadow commit in `RuntimeProjectilePhysicsUpdater`. + +Use `ProjectileAuthoritative` with the same Runtime body and exact projectile +Setup sphere for initial create and authoritative corrections. Preserve +prediction/component/effect identity. Do not route ordinary per-quantum +projectile integration through SetPosition. + +Tests: arrow, bolt, spell projectile, mid-flight correction, unloaded cell, +landblock crossing, delete during ack, no duplicate body/projectile/effect. + +### 6. Drops and unparent-to-world + +`InventoryWorldDropProjectionController.TryRecoverUnknownPosition` may create +the logical object, but it must enter the same canonical create-placement +transaction. Do not expose a stale source position or replay create-time +effects. + +Tests: whole item, split stack, new GUID, second drop position, attached child +becoming a world root, unavailable destination, newer Position while waiting. + +### 7. Pickup, Parent, and Delete + +Runtime hooks already exist in `RuntimeEntityObjectLifetime`, but the route +cutover must ensure pickup/parent/delete cancel the exact active +placement/lost-cell family first and publish `Discard`/`Withdraw` before the +later entity/inventory delta. + +Tests: pickup during preparation/deferred residence, parent during pending +withdrawal, delete during host callback, GUID reuse, reset/disposal ownership +convergence. + +### 8. Headless parity + +Delete the independent resolve/placement/direct SetPosition and Blip logic in +`HeadlessSessionWorldProjection`. Headless must prepare/commit/ack through the +same Runtime operations as graphical presentation. Portal completion also +waits for the exact placement acknowledgement. + +Tests: byte-identical login, ForcePosition, portal, missing-cell wake, +reconnect, and teardown snapshots. + +## AP-22 — retail-authored collision shapes + +After AP-1/AD-1 production cutover is stable: + +- Make `ShadowShapeBuilder` the single Core authority for prepared Setup + primitives. +- Preserve authored cylinder order. +- If no cylinders exist, preserve authored spheres as spheres. +- Mixed data uses retail cylinder-first precedence. +- A truly shapeless Setup emits no world shadow. +- Remove `Setup.Radius/Height` collision synthesis, `Radius * 2` height guesses, + and sphere-to-cylinder coercion. +- Cut graphical static, headless static, and live-entity publication over + together. +- Do not alter transition dummy spheres, sticky/range radius, or projectile + mover shapes. + +Automated gates: raw/prepared parity, cylinder order, sphere-only, mixed, +shapeless, scale, graphical/headless equality, representative installed DATs, +and dropped/portal/sign/door behavior. + +## AD-10 — canonical remote slope projection + +After AP-22: + +- Prove remote movement uses the full `ResolveWithTransition` sweep. +- Remove terrain-normal preprojection from `RemoteMotionCombiner`. +- Remove Runtime terrain-normal sampling calls and delete the sampler if no + longer used. +- Let `CTransition::adjust_offset` project against the retained actual contact + plane. +- Preserve interpolation queues, correction replacement, Hidden behavior, + network cadence, and graphical/headless parity. + +Tests must deliberately make terrain normals disagree with BSP/prop contact +normals, then cover uphill/downhill motion, seams, stairs, jumping, landing, +queue-empty/head-reached boundaries, and two-client observation. + +## Closeout gates + +Do not mark the campaign complete from narrow tests alone. + +Automated: + +```powershell +dotnet build AcDream.slnx -c Release +dotnet test AcDream.slnx -c Release --no-build --nologo +``` + +Also run every focused fixture named in the campaign plan: #273 tight gap, +#271 stair side, #269 slope, #265 landing, #185 stairs, #137 sliding normal, +#116 head collision, roof/cellar wedge, missing-cell, generation replacement, +GUID reuse, graphical/headless parity, and allocation/quiescence gates. + +Connected/visual: + +- login and portal arrival at outdoor, indoor, dungeon, stair-lip, and world + edge locations; +- repeated `/ls`, spell recall, ordinary portals, same-location revisit, and + reconnect; +- no early world reveal, outdoor demotion, floor snap, terrain-Z lift, or void; +- tight gaps, stairs, steep roofs, ledges, doors, crowds, shallow water, and + landblock seams; +- dropped objects, portals, signs, doors, and shapeless decorations; +- two-client uphill/downhill movement and sloped props; +- headless/graphical trace equality and graceful zero-residue teardown. + +Only then retire AP-1, AD-1, AP-22, and AD-10, update the architecture, +divergence register, campaign/roadmap/milestones, research notes, durable +memory, `CLAUDE.md`, and `AGENTS.md`, and record final rollback SHAs. + +## Review procedure for every remaining behavior commit + +1. Implement one bisectable mechanism and run focused tests. +2. Run a retail-conformance reviewer against named retail symbols/addresses. +3. Run an architecture/adversarial reviewer against reentrancy, stale + sequences, malformed data, GUID reuse, streaming replacement, host failure, + reset, and disposal. +4. Fix every confirmed finding at its root cause. +5. Re-run the same reviewers until clean. +6. Run Release build plus the complete Release test suite. +7. Update the divergence/docs in the same behavior commit. +8. Stage exact paths only and commit. + +## Rollback points + +Newest first: + +```powershell +git revert 270f5154 # dormant public placement receipt channel +git revert 4c02ac42 # Runtime SetPosition/lost-cell residence owner +git revert e84a388e # pure Core retail SetPosition transaction +``` + +Earlier campaign commits are individually bisectable and listed in the +completed sections above. Revert only the responsible mechanism; do not +restore the rejected snapshot lease or revive legacy compensation elsewhere. + +## First action for the next agent + +1. Read this file completely. +2. Read `docs/research/2026-07-31-canonical-set-position.md` and the AP-1/AD-1 + rows in `docs/architecture/retail-divergence-register.md`. +3. Confirm `HEAD` contains `270f5154` in the exact worktree above. +4. Confirm only `AGENTS.md` has a real unrelated unstaged diff. +5. Implement prerequisite A (real Runtime collision-report ownership) as its + own reviewed commit. +6. Then design prerequisites B/C together so exact mover preparation and the + atomic controller/body transaction cannot create another partial ownership + state. From 237d1184d2bdf6553a2f23a0fad0ee83e21d17fd Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 00:15:11 +0200 Subject: [PATCH 29/73] feat(runtime): own SetPosition collision reports --- docs/architecture/acdream-architecture.md | 11 +- .../retail-divergence-register.md | 4 +- docs/plans/2026-04-11-roadmap.md | 21 +- docs/plans/2026-05-12-milestones.md | 16 +- .../2026-07-31-canonical-set-position.md | 28 +- ...7-31-remaining-physics-campaign-handoff.md | 6 + ...et-position-collision-reporting-handoff.md | 243 +++ ...untime-set-position-collision-reporting.md | 182 ++ memory/project_collision_port.md | 20 + src/AcDream.Core/Physics/PhysicsObjUpdate.cs | 72 +- .../Physics/ShadowObjectRegistry.cs | 26 + .../Entities/RuntimeEntityDirectory.cs | 8 + .../Entities/RuntimeEntityObjectLifetime.cs | 28 +- .../Entities/RuntimeEntityRecord.cs | 23 + .../Physics/RuntimeCollisionReportingState.cs | 918 ++++++++ .../Physics/RuntimePhysicsState.cs | 113 +- .../Physics/RuntimeSetPositionState.cs | 201 +- .../Runtime/RuntimePhysicsOwnershipTests.cs | 67 + .../RuntimeCollisionReportingStateTests.cs | 1860 +++++++++++++++++ 19 files changed, 3744 insertions(+), 103 deletions(-) create mode 100644 docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md create mode 100644 docs/research/2026-07-31-runtime-set-position-collision-reporting.md create mode 100644 src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index c360f637..4caf2952 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -241,6 +241,9 @@ src/ Physics/ RuntimePhysicsState.cs -> per-session engine/cache/scratch/shadows, collision receipts, bodies/hosts/worksets + RuntimeCollisionReportingState.cs -> exact-key retail collision table, + environment latch, ordered callbacks, and + SetPosition report-result ownership RuntimeSetPositionState.cs -> exact placement/lost-cell operations, authored mover retention, ordered host receipts, and collision-generation wake @@ -336,9 +339,11 @@ src/ ``` The 4B2 production SetPosition routes and shared local-controller body remain -dormant until exact authored mover preparation, collision-report return, -presentation-only rebucketing, placement-prefix quiescence, and an atomic -Runtime body/controller publication transaction land as one reviewed cutover. +dormant. Runtime now owns the exact collision table, environment latch, and +report-result semantics needed by that cutover. Activation still waits for +exact authored mover preparation, presentation-only rebucketing, +placement-prefix quiescence, and an atomic Runtime body/controller publication +transaction to land as one reviewed cutover. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 38c216af..0e5decb4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -107,7 +107,7 @@ readiness/requeue adaptation. See | AD-51 | **Filed at Campaign N slice N4 (2026-07-29).** The inbound sequence tracker keeps a reclaimed-word pool (per-parked-word draw ordinals + `PriorityQueue` consumed lowest-draw-order-first) that retail has no counterpart for: on a VALIDATED cleartext `RejectRetransmit`, the word the gap walk parked for the reject packet's OWN sequence is removed, every later-drawn parked word is shifted down one position, and the excess word feeds the next fresh draws. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`OnCleartextRejectSequence`, `NextWord`, `ParkedWord`); trigger at `src/AcDream.Core.Net/WorldSession.cs` (RejectRetransmit consumption) | Retail's inbound invariant is "every missing id was an encrypted packet whose keystream word the server drew" — true against retail servers, whose cleartext packets always borrow live sequences (acks/NAKs reuse `highestIDSent_`; `FlowQueue::TransmitNewPackets @ 0x00547A60` sequences only reliable packets). ACE breaks it in exactly one place: `RejectRetransmit` takes a FRESH sequence through FlushPackets, cleartext, drawing NO S2C keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748). Without the reclaim, our gap walk pre-draws a word for that id, the inbound stream runs permanently one word ahead, and every later encrypted packet fails checksum — the N2 desync class reintroduced through the reject path. The pool is provably empty against a retail server, so retail behavior is untouched. Reject BODY ids keep the N2 discard (their words were drawn on both sides — consumed-in-place). Known unreachable corner: a reject whose own id later appears inside another reject's body (first reject pruned after 120 s of sustained loss with the session alive) would discard a never-drawn word; probabilistically impossible against ACE's 60 s silence timeout and the 0.6 s NAK cadence. | Against a hypothetical non-ACE server that assigns fresh cleartext sequences to packets OTHER than RejectRetransmit, those ids would still mis-park with no reclaim trigger — inbound desync. Only ACE-family servers exist for this client today, and ACE has exactly the one path. | `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the gap walk whose invariant ACE breaks); `SharedNet::HandleEmptyAck @ 0x005448F0` (retail's reject consumption — body ids only, no own-sequence machinery because retail never needs it) | | AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | -| AD-1 | **NARROWED 2026-07-31 (placement Slice 4B2 checkpoint 1).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, revisioned Withdraw/Place receipts, and one public generation-gated observe/retry/exact-ack seam. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until the remaining 4B2 prerequisites and routes cut over atomically. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owner remains dormant and separately gated, so this ownership checkpoint cannot partially change the accepted production world. | Until 4B2, a production gap can still commit an outdoor approximation inside/under a building or lift a legitimate below-heightmap restore instead of entering the now-available Runtime lost-cell owner. | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | +| AD-1 | **NARROWED 2026-07-31 (placement Slice 4B2 checkpoint 2).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, revisioned Withdraw/Place receipts, one public generation-gated observe/retry/exact-ack seam, and the retail collision-table/report-result state needed by SetPosition. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until the remaining authored-mover, rebucketing, prefix-quiescence, body-publication, and route-cutover prerequisites land atomically. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owners remain dormant and separately gated, so this ownership checkpoint cannot partially change the accepted production world. | Until 4B2, a production gap can still commit an outdoor approximation inside/under a building or lift a legitimate below-heightmap restore instead of entering the now-available Runtime lost-cell owner. | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945; `CPhysicsObj::handle_all_collisions` 0x00514780 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | @@ -155,7 +155,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B2 checkpoint 1).** Core exposes the pure retail `SetPosition` transaction; Runtime owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, and revisioned host receipts; and one public generation-gated channel exposes observe/retry/exact-head acknowledgement without another queue. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies the real collision-report return, exact authored mover preparation, presentation-only rebucketing, placement-prefix quiescence, and the atomic graphical/headless route cutover. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md` | The mechanism, ownership, and host seam land independently without partially changing production placement behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owner now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | +| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B2 checkpoint 2).** Core exposes the pure retail `SetPosition` transaction; Runtime owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, revisioned host receipts, and exact-key retail collision table/environment-latch/report-result state; and one public generation-gated channel exposes observe/retry/exact-head acknowledgement without another placement queue. Collision starts, expiry/force ends, static and `ReportAsEnvironment` routing, reciprocal eligibility, missile-state clearing, callback ordering, and failed-placement `Collided` versus `NoValidPosition` classification now share one presentation-free owner. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies exact authored mover preparation, presentation-only rebucketing, placement-prefix quiescence, and the atomic graphical/headless route cutover. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md`; `docs/research/2026-07-31-runtime-set-position-collision-reporting.md` | The mechanism, ownership, report-result oracle, and host seam land independently without partially changing production placement behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owners now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | | ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index a00872c4..f631d63a 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -60,12 +60,27 @@ automated gates pass and the connected buff/death gate was user-accepted on 2026-07-31. The final session also accepted burden/exhaustion, wall/corner, crowd, two-client remote/door/portal, and shallow-water behavior. The user waived the general sweep and explicitly deferred the barred-house gate as -#274. A separate tight-gap clearance mismatch is carried as #273 pending an -exact-location capture. World-interaction Slice 5 vendor browsing resumes. +#274. The later exact-location #273 tight-gap gate is now fixed and accepted. + +**Remaining physics-divergence closeout (ACTIVE 2026-07-31):** the user then +authorized retirement of the remaining proven collision/placement gaps before +vendor work resumes. Nested retry, edge/StepDown/Path-6 ordering, exact cell +availability, atomic collision generations, canonical Core SetPosition, +Runtime lost-cell residence, and the dormant placement receipt channel are +landed. Placement Slice 4B2 checkpoint 2 adds the presentation-free Runtime +owner for retail collision tracking, environment latch, ordered callbacks, +missile-state clearing, expiry/force-end lifetime, and SetPosition's exact +report-result boolean. It deliberately does not activate an App or Headless +production route. Next are exact authored mover preparation, the atomic shared +body/controller transaction, presentation-only placement observers, collision- +prefix quiescence, and the all-route cutover which can retire AP-1/AD-1. AP-22 +authored object shapes and AD-10 remote contact-plane projection follow, then +the final matrix and ledger closeout. Detailed handoff: +[`2026-07-31-runtime-set-position-collision-reporting-handoff.md`](../research/2026-07-31-runtime-set-position-collision-reporting-handoff.md). --- -## Current program: world interaction completion (M4 prelude) +## Paused program: world interaction completion (M4 prelude) The active work order is [`2026-07-23-world-interaction-completion.md`](2026-07-23-world-interaction-completion.md). diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 0044ae4b..5c22e9c5 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -87,10 +87,18 @@ program: spell-bar overflow, status Use/Assess, assessment information, equipped-child picking, vendor browsing, and authoritative vendor transactions. This is deliberately using the extracted interaction owners and canonical shared main-panel host before quest/emote/character-creation bodies -broaden the feature surface. Slices 1–4 are user-accepted. Campaign P closed -on 2026-07-31 with tight-gap collision clearance (#273) and the deferred -restricted-house gate (#274) explicitly carried; resume at Slice 5 vendor -browsing. +broaden the feature surface. Slices 1–4 are user-accepted. Campaign P's +connected feel matrix closed on 2026-07-31 with tight-gap collision clearance +(#273) and the deferred restricted-house gate (#274) explicitly carried. The +user subsequently authorized the remaining physics-divergence closeout before +vendor work. Placement Slice 4B2 checkpoint 2 now gives Runtime the retail +SetPosition collision table, environment latch, ordered report callbacks, and +exact report-result owner without activating production placement. The +remaining order is authored mover/body preparation, atomic graphical/no-window +body publication, presentation-only projection and prefix quiescence, then the +all-route SetPosition cutover; AP-22 shape fidelity and AD-10 remote contact- +plane projection follow. Resume Slice 5 vendor browsing only after that +closeout or a new explicit user direction. The separately authorized modern-runtime performance program has completed Slices A–D: corrected measurement, prepared-package bake/dedup, package-only diff --git a/docs/research/2026-07-31-canonical-set-position.md b/docs/research/2026-07-31-canonical-set-position.md index f738c038..af957e30 100644 --- a/docs/research/2026-07-31-canonical-set-position.md +++ b/docs/research/2026-07-31-canonical-set-position.md @@ -252,15 +252,21 @@ prepared-mover caching, and caps synchronous Scatter/RandomScatter work at 64 attempts; this keeps valid authored retail request shapes while rejecting a hostile `uint.MaxValue` loop at the authority boundary. -Two boundaries intentionally remain open for 4B2: +One authority boundary intentionally remains open for 4B2: - `RuntimePortalPlacementAuthority` validates immutable token shape only; 4B2 must bind it to active `RuntimeWorldTransitState` generation, teleport sequence, destination, and host acknowledgement before reveal. -- Runtime applies the canonical collision-report state but fails the handler - return closed. Retail returns report/track success, not collision presence; - the per-object report/tracking owner required for that boolean is not yet in - Runtime, and the former environment/object-presence guess is forbidden. + +The former collision-report boundary is closed by +`RuntimeCollisionReportingState`. Runtime now owns retail's exact-key object +contact table, environment latch, strict ordinary/ethereal expiry, force-end, +static and `ReportAsEnvironment` routing, reciprocal callback eligibility, +missile-state clearing, ordered reentrant dispatch, and the report-result +boolean which distinguishes placement `Collided` from `NoValidPosition`. +Successful SetPosition commits reporting after Contact/OnWalkable and ground +callbacks but before its single physical response and shadow reflood. See +`docs/research/2026-07-31-runtime-set-position-collision-reporting.md`. ### Slice 4B2 checkpoint 1 — public dormant host seam @@ -284,13 +290,13 @@ preparation followed by one validated atomic body/controller publication. Either choice belongs to the all-route ownership cutover, not this narrow dormant-seam checkpoint. -This is still a deliberately non-activating checkpoint. Production spawn, +This remains a deliberately non-activating checkpoint. Production spawn, Position, projectile, drop/pickup/parent, and portal routes do not submit to -the dormant SetPosition owner yet. The cutover remains blocked on the real -retail collision report/tracking return, exact ordered Setup spheres/scale/ -step heights/flags/cell-local preparation, presentation-only rebucketing, and -placement-prefix quiescence before collision retirement. AP-1 and AD-1 remain -open until those prerequisites and every production route land together. +the dormant SetPosition owner yet. The cutover remains blocked on exact +ordered Setup spheres/scale/step heights/flags/cell-local preparation, +presentation-only rebucketing, and placement-prefix quiescence before +collision retirement. AP-1 and AD-1 remain open until those prerequisites and +every production route land together. AD-2 remains the explicit async adaptation: collision readiness can publish in a different frame from retail's blocking load. A failed wake is safely re- diff --git a/docs/research/2026-07-31-remaining-physics-campaign-handoff.md b/docs/research/2026-07-31-remaining-physics-campaign-handoff.md index 2593ce45..ac3cd0d5 100644 --- a/docs/research/2026-07-31-remaining-physics-campaign-handoff.md +++ b/docs/research/2026-07-31-remaining-physics-campaign-handoff.md @@ -1,5 +1,11 @@ # Remaining physics-divergence campaign handoff — 2026-07-31 +> **Checkpoint 2 update:** Slice 4B2 prerequisite A, Runtime SetPosition +> collision-report ownership, is implemented in the next checkpoint. Continue +> with the dedicated +> [`runtime SetPosition collision-reporting handoff`](2026-07-31-runtime-set-position-collision-reporting-handoff.md), +> not the prerequisite-A instructions preserved below as historical context. + ## Purpose and stopping point This is the deliberate handoff boundary requested after placement Slice 4B2 diff --git a/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md b/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md new file mode 100644 index 00000000..5fdc05d3 --- /dev/null +++ b/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md @@ -0,0 +1,243 @@ +# Runtime SetPosition collision-report ownership handoff - 2026-07-31 + +## Purpose and exact stopping point + +This handoff records placement Slice 4B2 checkpoint 2: the isolated Runtime +owner for retail SetPosition collision tracking and report-result semantics. +The checkpoint intentionally stops before authored mover preparation, shared +local-controller body publication, graphical/headless placement projection, +collision-prefix quiescence, or any production SetPosition route cutover. + +Production behavior is therefore unchanged by this checkpoint. The new owner +is populated only by the dormant `RuntimeSetPositionState` and focused tests. +AP-1 and AD-1 remain narrowed/open; AP-22 and AD-10 remain open. + +## Exact workspace + +- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream` +- Branch: `codex/port-claude-agents` +- Starting checkpoint: `ec627c13` + (`docs(physics): hand off remaining divergence campaign`) +- This handoff belongs to the same behavior commit as the implementation. +- No upstream is configured for this worktree branch. +- Remotes: + - `origin`: `https://git.snakedesert.se/erik/acdream.git` + - `github`: `git@github.com:eriknihlen/acdream.git` + +Continue in this worktree unless the user explicitly requests otherwise. +`AGENTS.md` has an unrelated pre-existing content diff and must not be staged, +restored, or rewritten as part of this checkpoint. Several other paths report +line-ending/stat noise without a content diff; stage only the exact paths +listed in the final commit. + +## Retail oracle + +The complete readable oracle is +[`2026-07-31-runtime-set-position-collision-reporting.md`](2026-07-31-runtime-set-position-collision-reporting.md). +The named-retail anchors are: + +- `CPhysicsObj::report_object_collision_end` `0x00510A90` +- `CPhysicsObj::report_environment_collision` `0x00512FC0` +- `CPhysicsObj::report_object_collision` `0x00513060` +- `CPhysicsObj::track_object_collision` `0x00513F10` +- `CPhysicsObj::report_collision_start` `0x00513FD0` +- `CPhysicsObj::report_collision_end` `0x00514620` +- `CPhysicsObj::handle_all_collisions` `0x00514780` +- successful `CPhysicsObj::SetPositionInternal(CTransition const*)` + `0x00515330` +- `CPhysicsObj::leave_world` `0x005155A0` +- placement failure in `CPhysicsObj::SetPositionInternal` `0x00515BD0` + +The source is `docs/research/named-retail/acclient_2013_pseudo_c.txt`; the +struct authority is `docs/research/named-retail/acclient.h`. + +## What this checkpoint implements + +`RuntimeCollisionReportingState` is the sole per-session owner of: + +- one environment-collision latch per exact `RuntimeEntityKey`; +- one ordered object-contact table per exact owner incarnation; +- retained peer server GUID, touch time, and ethereal-at-touch state; +- static and `ReportAsEnvironment` routing; +- asymmetric `IgnoreCollisions` and reciprocal `ReportCollisions` eligibility; +- strict ordinary `age > 1.0` and ethereal `age > 0.0` expiry; +- force-end-before-callback mutation for reentrant safety; +- missing-peer self-only end reports without resolving a later GUID reuse; +- exact `Missile | AlignPath | PathClipped` clearing on the canonical record, + borrowed body, retained shadow state, and mutation version; +- a monotonic immutable report FIFO with observer-failure isolation; +- the retail callback-eligibility boolean used by failed placement to choose + `Collided` versus `NoValidPosition`; +- terminal ownership diagnostics and deterministic session/disposal cleanup. + +Successful dormant SetPosition commits contact, water/walkable and ground +edges first, runs reporting next, applies physical response once, and then +refloods the shadow. An intervening Vector or Movement update suppresses only +the stale physical response; it does not erase collision tracking or reports. +Failed placement always supplies retail's `previousContact = false` and +`previousOnWalkable = false`, reports once, applies its one response pass, and +maps the report result exactly. + +Hidden, teleport/withdrawal, deletion, session reset, and disposal use distinct +lifetime edges. Leaving the world force-ends the departing owner's table but +retains its environment latch and incoming peer records. Destruction then +forgets only the departing owner state. Other owners retain exact-key contacts +until their own expiry/force pass and can emit a missing-target end using the +preserved server GUID. Hidden and session-clear paths force-end while the old +report flags and bodies are still eligible, before state/reset teardown. + +## Architectural boundaries + +- Runtime owns all canonical collision-report state and report-result logic. +- Core exposes only the exact successful SetPosition ordering seam and the + retained-shadow collision identity required by Runtime. +- App and Headless gain no report table, queue, heuristic, or production + placement consumer. +- Reports are presentation-free and keyed by exact Runtime identity. +- Network/update callbacks may re-enter, but every later mutation revalidates + current identity, body, and the relevant authority version. +- Physical-response velocity authority is deliberately separate from report + authority, matching retail's ordering without overwriting a newer vector. + +## Validation and independent review + +The saved final diff passed: + +- combined focused Runtime collision-report and SetPosition tests: 76/76; +- complete Runtime project: 562/562; +- graphical/headless Runtime-physics ownership and dormancy guards: 4/4; +- focused Core SetPosition/contact/response ordering tests: 29/29; +- complete Core project: 4,224 passed / 1 intentional skip; +- from-source Release solution rebuild: 0 errors and 21 pre-existing test- + project nullable/analyzer warnings; this checkpoint introduces none; +- complete Release solution: 10,309 passed / 4 intentional skips; +- warmed steady-contact refresh: 0 managed bytes across 10,000 calls; +- warmed immediate dormant SetPosition commit: still below the existing + 2,048-byte-per-operation ceiling, with no new captured-delegate cost; +- architecture/adversarial re-review: clean after fixing Hidden/session/delete + reentrancy, stale shadow-state authority, allocation churn, and batch cost; +- retail-conformance re-review: clean against every named address above. + +The final retail re-review found and closed two last ordering defects before +sign-off: object collision now snapshots the mover's Missile bit before the +source callback and, when that snapshot was set, unconditionally masks the +current `Missile | AlignPath | PathClipped` bits afterward. Thus an ordinary +callback-added Missile is retained when the mover was not previously a missile, +but a callback which clears Missile and re-adds path bits cannot evade the +pre-gated retail mask. Environment collision retains retail's post-callback +current-Missile test. Successful SetPosition now +publishes reports before installing the new stationary-fall counter, applies +the physical response next, installs StationaryFall/Stop/Stuck transient bits +after response, and only then refloods the shadow. + +The host guard reads both production source trees. It proves App and Headless +borrow `GameRuntime.EntityObjects.Physics`, declare no second collision table +or return heuristic, and still contain no placement-channel consumer. No +connected/live gate is required for this dormant checkpoint because no +production route can populate or publish the new report owner. + +## Exact implementation and test paths + +The behavior commit containing this handoff changes exactly these ten code and +test paths: + +- `src/AcDream.Core/Physics/PhysicsObjUpdate.cs` +- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` +- `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs` +- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` +- `src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs` +- `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs` +- `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` +- `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` +- `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs` +- `tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs` + +The same commit synchronizes the architecture, divergence register, canonical +SetPosition research, roadmap, milestones, project memory, prior campaign +handoff pointer, retail-oracle note, and this detailed handoff. `AGENTS.md` and +the pre-existing line-ending/stat-noise paths are deliberately excluded. + +## Remaining work - required order + +### 1. Exact authored mover preparation + +Build every SetPosition request from Setup's ordered authored spheres, exact +scale/presence semantics, StepUp/StepDown heights, flags, cell-local frame and +orientation, and current position/vector/state authority versions. Do not +synthesize a cylinder from visual radius/height or pre-mutate canonical state. + +### 2. Atomic local-controller/body publication + +Prepare off-canonical, then perform one Runtime-validated atomic transaction +which publishes the exact same body to graphical and no-window controllers. +Every body writer, remote/projectile binding, SetPosition operation, clock +epoch, deletion, reset and disposal path must participate. Do not resurrect +the rejected snapshot/rollback lease documented in the prior handoff. + +### 3. Presentation-only host projection + +Implement graphical and headless observers over the existing dormant placement +receipt channel. Withdraw removes presentation/spatial consumers while +retaining Runtime identity; Place projects only the immutable committed frame; +Discard retires the older revision. Host failure retries the exact FIFO head +and never rolls Runtime back. + +### 4. Collision-prefix quiescence and atomic route activation + +Park SetPosition residents before retiring their collision prefix, publish the +complete replacement generation, wake exact matching residents, and cut every +spawn/Position/portal/projectile/drop/pickup/parent/delete route over together. +Only then may AP-1 and AD-1 retire. + +### 5. Remaining campaign slices + +- Port retail-authored object collision shape precedence and retire AP-22. +- Remove remote terrain-normal preprojection and let the transition resolver + use the retained contact plane, retiring AD-10. +- Run the full automated and connected matrix, update all ledgers, and close + the remaining physics campaign only with direct evidence. + +## Rollback + +This checkpoint is one bisectable commit. Revert the commit containing this +file to remove collision-report ownership without disturbing the earlier +SetPosition residence and receipt-channel checkpoints. Do not revive the old +collision-presence guess or the rejected body snapshot lease. + +Because a Git commit cannot embed its own final hash, resolve the exact +checkpoint and revert command without ambiguity using: + +```powershell +$checkpoint = git log -1 --format=%H -- ` + docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md +git show --stat $checkpoint +git revert $checkpoint +``` + +Earlier rollback points remain: + +```powershell +git revert 270f5154 # dormant public placement receipt channel +git revert 4c02ac42 # Runtime SetPosition/lost-cell residence owner +git revert e84a388e # pure Core retail SetPosition transaction +``` + +## Resume procedure + +1. Continue in + `C:\Users\erikn\.codex\worktrees\af5e\acdream` and verify + `git branch --show-current` reports `codex/port-claude-agents`. +2. Resolve the exact checkpoint with the `git log` command above and confirm + it is the current `HEAD` before starting the next behavior slice. +3. Read `AGENTS.md`, `docs/architecture/acdream-architecture.md`, this file, + the collision-report oracle, the canonical SetPosition research, and the + prior remaining-campaign handoff completely. +4. Run `git status --short`. Preserve the unrelated `AGENTS.md` content diff + and every documented line-ending/stat-noise path. Never stage by blanket. +5. Begin only **Exact authored mover preparation**, the first remaining item + above. Do not activate production routes, retire AP-1/AD-1, begin AP-22 or + AD-10, or resurrect the rejected body snapshot/rollback lease. +6. Use exact-path staging and rerun the matching focused projects, + `dotnet build AcDream.slnx -c Release --nologo`, and + `dotnet test AcDream.slnx -c Release --no-build --nologo` before the next + reviewed checkpoint. diff --git a/docs/research/2026-07-31-runtime-set-position-collision-reporting.md b/docs/research/2026-07-31-runtime-set-position-collision-reporting.md new file mode 100644 index 00000000..9d280c67 --- /dev/null +++ b/docs/research/2026-07-31-runtime-set-position-collision-reporting.md @@ -0,0 +1,182 @@ +# Runtime SetPosition collision-report ownership + +**Scope:** placement/streaming Slice 4B2 prerequisite A only. This closes the +missing Runtime owner for retail collision tracking and the boolean returned by +`CPhysicsObj::handle_all_collisions`. It does **not** activate any graphical or +headless production SetPosition route. + +## Named-retail oracle + +Primary sources: + +- `CPhysicsObj::report_object_collision_end` `0x00510A90` +- `CPhysicsObj::report_environment_collision` `0x00512FC0` +- `CPhysicsObj::report_object_collision` `0x00513060` +- `CPhysicsObj::track_object_collision` `0x00513F10` +- `CPhysicsObj::report_collision_start` `0x00513FD0` +- `CPhysicsObj::report_collision_end` `0x00514620` +- `CPhysicsObj::handle_all_collisions` `0x00514780` +- `CPhysicsObj::SetPositionInternal(CTransition const*)` `0x00515330` +- `CPhysicsObj::leave_world` `0x005155A0` +- placement failure path in `CPhysicsObj::SetPositionInternal` `0x00515BD0` +- `CPhysicsObj::CollisionRecord`, `EnvCollisionProfile`, + `ObjCollisionProfile`, and `AtkCollisionProfile` in + `docs/research/named-retail/acclient.h` + +The source text is +`docs/research/named-retail/acclient_2013_pseudo_c.txt`. The addresses above +are the behavioral authority; the older unnamed chunks remain fallback only. + +### Environment reporting + +```text +report_environment_collision(meInContact): + reported = false + if !colliding_with_environment: + if self.ReportCollisions && self.weenie != null: + DoCollision(EnvCollisionProfile(self.velocity, meInContact)) + reported = true + colliding_with_environment = true + if self.Missile: + self.state &= ~(Missile | AlignPath | PathClipped) + return reported +``` + +The latch is independent of callback eligibility. An object with no collision +callback still latches its environment contact, and a repeated environment hit +returns false. Retail has no environment-end callback. `leave_world` does not +clear this latch; the next `handle_all_collisions` call re-arms it only after a +non-environment frame. + +### Object reporting and tracking + +```text +track_object_collision(other, meInContact): + if other.Static: + return report_environment_collision(meInContact) + + record = { touched_time = PhysicsTimer.curr_time, + ethereal = other.Ethereal } + existed = collision_table.clobber(other.id, record) + if existed: + return false + return report_object_collision(other, meInContact) +``` + +The table insert/refresh precedes callbacks. Duplicate contacts refresh their +time but never replay a start callback. DAT/static classification and physics +state come from the exact shadow object which produced the collision; object-ID +presence is not a valid substitute. + +`report_object_collision` first maps `ReportAsEnvironment` to the environment +path. Otherwise: + +- the mover reports only when the other object is not `IgnoreCollisions` and + the mover has `ReportCollisions` plus a weenie; +- a mover which had Missile set before the source callback unconditionally + masks its current `Missile | AlignPath | PathClipped` bits after striking a + non-ignored object, even when the callback cleared Missile but re-added path + bits; when pre-callback Missile was clear, callback-added Missile is retained; +- the reciprocal report occurs only when the other has `ReportCollisions`, the + mover is not `IgnoreCollisions`, and the other has a weenie; +- the return is true when at least one callback is attempted. It is never a + collision-presence boolean. + +### Expiry and end reporting + +`report_collision_end(force)` removes records before dispatching callbacks. +This ordering is required for safe reentrancy. + +```text +ordinary record: remove when age > 1.0, or force +ethereal record: remove when age > 0.0, or force +``` + +Equality remains alive. A still-resolvable non-`ReportAsEnvironment` peer may +receive reciprocal collision-end callbacks. When the peer no longer resolves, +the owner can still receive its self-only end using the stored retail object +ID. A later incarnation must never satisfy the old contact record. + +### `handle_all_collisions` and SetPosition ordering + +```text +handle_all_collisions(info, previousContact, previousOnWalkable): + reported = false + for other in info.collidedObjects, in encounter order: + reported |= track_object_collision(other, previousContact) + report_collision_end(force = false) + + if environment latch is already set: + latch = info.collided_with_environment + else if info.collided_with_environment + || (!previousOnWalkable && self.OnWalkable): + reported |= report_environment_collision(previousContact) + + apply retail collision velocity/stationary response + return reported +``` + +Successful `SetPositionInternal(CTransition const*)` commits the resolved +cell/frame, Contact/WaterContact/OnWalkable state, and HitGround/LeaveGround +edge before `handle_all_collisions`; it ignores the returned boolean and only +then replaces/refloods shadows. Collision reports observe the old stationary- +fall state; the new counter is installed before physical response, while the +StationaryFall/Stop/Stuck transient bits are replaced after response and before +shadow reflood. The placement failure path calls +`handle_all_collisions(info, false, false)` and maps true to +`SetPositionError::Collided` (`4`) and false to `NoValidPosition` (`2`). + +Consequently acdream must keep report/tracking separate from the physical +response: failed placement runs both once, while successful Runtime commit +runs reporting between the contact/ground commit and shadow reflood without +double-applying velocity response. + +## Runtime ownership contract + +The implementation is presentation-free and belongs to the per-session +`RuntimePhysicsState` graph. Its invariants are: + +- owner and peer identities are exact `RuntimeEntityKey` values, not server + GUID or local ID alone; +- each tracked record retains the peer server GUID, touch time in the Runtime + simulation-clock domain, and ethereal-at-touch bit; +- collided IDs and authored/static ownership are admitted through the exact + retained `ShadowObjectRegistry` registration which produced the collision; + every dynamic Static/Ethereal/Ignore/ReportAsEnvironment decision then reads + the current canonical `PhysicsBody.State`, never a stale shadow snapshot; +- immutable reports preserve encounter order and dispatch through a retained, + reentrancy-safe FIFO; +- callback exceptions are isolated, while the retail report-result boolean is + determined by callback eligibility and does not depend on subscribers; +- every callback boundary revalidates the exact record/body/authority before + any later canonical mutation; +- force-end mutates the complete expired set before publishing ends; exact-key + admission guards prevent callback reentry from recreating a leaving owner, + and session teardown blocks the whole owner batch before its first callback; +- one source lifetime token covers a complete precollected end batch, so a + callback-accepted delete stops every later peer report even while teardown + sidecars remain resolvable; +- lifetime forget, session reset, and disposal cannot donate state to GUID + reuse; +- terminal ownership diagnostics include contact/report state and converge to + zero; +- graphical and no-window hosts borrow the same Runtime owner. No host owns a + second collision table or report-result heuristic. + +The warmed steady-contact refresh path allocates zero managed bytes. Expired +contact storage is allocated lazily only after the first actual expiry, and +session-batch teardown is linear in owner count. + +## Deliberately deferred + +The canonical SetPosition owner remains dormant in production. The following +belong to later 4B2 commits and are not part of this checkpoint: + +- exact ordered Setup spheres, authored scale and step-height preparation; +- the atomic shared local-controller body transaction; +- presentation-only rebucketing and placement-prefix quiescence; +- graphical/headless spawn, Position, portal, projectile, drop, pickup, + parent, and delete route cutover. + +AP-1 and AD-1 therefore remain open, narrowed only by removal of the +collision-report prerequisite. diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md index 6ee52a74..5d8409ed 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -1,5 +1,25 @@ # Collision System Port - Status and Plan +## 2026-07-31 placement checkpoint 2 + +Runtime now owns the retail collision-report state required by canonical +SetPosition: exact-incarnation object-contact tables, the environment latch, +ordinary/ethereal expiry, force-end ordering, asymmetric object/environment +report eligibility, missile-state clearing, reentrant ordered reports, and the +report-result boolean which distinguishes failed-placement `Collided` from +`NoValidPosition`. Successful dormant SetPosition commits reporting after +contact/ground state and before its one response plus shadow reflood. The +named-retail oracle and next-agent instructions are in: + +- `docs/research/2026-07-31-runtime-set-position-collision-reporting.md` +- `docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md` + +This checkpoint deliberately does not activate production placement. Next, +4B2 must prepare exact authored movers and land one atomic Runtime body/local- +controller transaction, followed by presentation-only host projection, +collision-prefix quiescence, and an all-route cutover. AP-1 and AD-1 remain +open until that cutover; AP-22 and AD-10 remain later campaign slices. + ## Current State (2026-04-29) The collision system is no longer a pure placeholder and should not be treated diff --git a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs index fd51c73e..afd699ab 100644 --- a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs +++ b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs @@ -81,14 +81,53 @@ public static class PhysicsObjUpdate Action? leaveGround = null, Func? isCurrent = null, Func? isVelocityCurrent = null) + { + if (!CommitSetPositionContactTransition( + body, + inContact, + onWalkable, + previousOnWalkable, + hitGround, + leaveGround, + isCurrent)) + { + return false; + } + + // Position, Vector, and Movement are independently timestamped but + // can all install m_velocityVector. If a later one arrived from a + // callback above, retain its vector and finish the non-overlapping + // contact/pose commit without applying this older collision response. + if (isVelocityCurrent?.Invoke() == false) + return isCurrent?.Invoke() ?? true; + + HandleAllCollisions( + body, + collisionNormalValid, + collisionNormal, + previousContact, + previousOnWalkable, + body.OnWalkable); + return isCurrent?.Invoke() ?? true; + } + + /// + /// Commits retail's Contact/OnWalkable and HitGround/LeaveGround prefix, + /// stopping immediately before handle_all_collisions. Runtime uses + /// this seam to run the canonical collision-table reports without adding + /// another per-placement delegate allocation. + /// + public static bool CommitSetPositionContactTransition( + PhysicsBody body, + bool inContact, + bool onWalkable, + bool previousOnWalkable, + Action? hitGround = null, + Action? leaveGround = null, + Func? isCurrent = null) { ArgumentNullException.ThrowIfNull(body); - // SetPositionInternal replaces Contact first but retains the source - // OnWalkable bit through its first calc_acceleration call. A deferred - // teleport may have parked the live body with both bits cleared, so - // restore the captured source bit explicitly before reproducing that - // ordering. if (previousOnWalkable) body.TransientState |= TransientStateFlags.OnWalkable; else @@ -106,10 +145,6 @@ public static class PhysicsObjUpdate else body.TransientState &= ~TransientStateFlags.OnWalkable; - // AP-10 (Campaign P Slice P4, 2026-07-30): mirror WATER_CONTACT_TS - // alongside CONTACT_TS/ON_WALKABLE_TS, same as ApplySetPositionContact. - // Callers (e.g. RemoteTeleportPlacement) already set body.ContactPlaneIsWater - // before invoking this commit. if (body.ContactPlaneIsWater) body.TransientState |= TransientStateFlags.WaterContact; else @@ -128,21 +163,6 @@ public static class PhysicsObjUpdate return false; } body.calc_acceleration(); - - // Position, Vector, and Movement are independently timestamped but - // can all install m_velocityVector. If a later one arrived from a - // callback above, retain its vector and finish the non-overlapping - // contact/pose commit without applying this older collision response. - if (isVelocityCurrent?.Invoke() == false) - return isCurrent?.Invoke() ?? true; - - HandleAllCollisions( - body, - collisionNormalValid, - collisionNormal, - previousContact, - previousOnWalkable, - finalOnWalkable); return isCurrent?.Invoke() ?? true; } @@ -179,14 +199,14 @@ public static class PhysicsObjUpdate // is now owned by the SetPositionInternal-derived contact flags, not a Velocity.Z<=0 // gate). A grounded corridor wall-slide keeps its tangential velocity (should_reflect // false), exactly as retail. - bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding); + bool sledding = (body.State & PhysicsStateFlags.Sledding) != 0; bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding); if (body.FramesStationaryFall <= 1) { if (shouldReflect && collisionNormalValid) { - if (body.State.HasFlag(PhysicsStateFlags.Inelastic)) + if ((body.State & PhysicsStateFlags.Inelastic) != 0) { body.Velocity = Vector3.Zero; // pc:282720-282722 } diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 874addd3..845f98b2 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -1563,6 +1563,32 @@ public sealed class ShadowObjectRegistry internal bool HasLogicalOwner(uint entityId) => _entityReg.ContainsKey(entityId); + /// + /// Returns the exact collision identity consumed by retail + /// CPhysicsObj::track_object_collision. Reporting must classify + /// an encountered object from the same retained shadow registration that + /// produced the collision; presence of an entity id alone is not enough + /// to infer either a static/environment collision or physics state. + /// + internal bool TryGetCollisionOwner( + uint entityId, + out uint physicsState, + out bool isStatic) + { + if (_entityReg.TryGetValue( + entityId, + out RegistrationRecord? registration)) + { + physicsState = registration.State; + isStatic = registration.IsStatic; + return true; + } + + physicsState = 0u; + isStatic = false; + return false; + } + public int PrefixOwnerSlotCapacityForDiagnostics(uint landblockId) => _prefixOwnerSlots.TryGetValue( landblockId & 0xFFFF0000u, diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 73308b39..578a558b 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -323,6 +323,14 @@ public sealed class RuntimeEntityDirectory record.FinalPhysicsState = state; } + internal bool StopMissileAfterCollision( + RuntimeEntityRecord record, + bool requireCurrentMissile) + { + EnsureKnown(record); + return record.StopMissileAfterCollision(requireCurrentMissile); + } + public void SetHasPartArray(RuntimeEntityRecord record, bool value) { EnsureKnown(record); diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 5a43aa60..951c189f 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -450,6 +450,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Physics.SetPosition.Forget( canonical, releasePreparedMover: true); + Physics.CollisionReports.Forget(canonical); Physics.RemoveSpatialProjection(canonical); Entities.SetRemoteMotion(canonical, null); Entities.SetRemoteMotionBindingInProgress(canonical, false); @@ -540,6 +541,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvancePositionAuthority(canonical); + Physics.CollisionReports.LeaveWorld(canonical); RuntimePlacementCancellationReceipt cancellation = Physics.SetPosition.Forget(canonical); Entities.SuspendObjectClock(canonical); @@ -631,6 +633,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimePlacementCancellationReceipt cancellation = Physics.SetPosition.Forget(canonical); + Physics.CollisionReports.LeaveWorld(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); ulong spatialVersion = canonical.SpatialAuthorityVersion; @@ -730,6 +733,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable } Entities.RefreshSnapshot(canonical, accepted); + RetailPhysicsStateTransition preview = + RetailPhysicsStateTransitions.Apply( + canonical.FinalPhysicsState, + (PhysicsStateFlags)update.PhysicsState); + ulong priorPhysicsMutation = canonical.PhysicsStateMutationVersion; + if (preview.HiddenTransition is RetailHiddenTransition.BecameHidden) + { + Physics.CollisionReports.LeaveWorld(canonical); + if (!Entities.IsCurrent(canonical) + || canonical.PhysicsStateMutationVersion + != priorPhysicsMutation) + { + transition = default; + return false; + } + } transition = Entities.ApplyRawPhysicsState( canonical, update.PhysicsState); @@ -878,6 +897,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable if (!Entities.IsCurrent(canonical)) return false; + Physics.CollisionReports.LeaveWorld(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); ulong spatialVersion = canonical.SpatialAuthorityVersion; @@ -955,6 +975,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable && active.Incarnation == delete.InstanceSequence && Entities.RemoveActive(active)) { + Physics.CollisionReports.Forget(active); RuntimePlacementCancellationReceipt cancellation = Physics.SetPosition.Forget( active, @@ -1030,9 +1051,11 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return Array.Empty(); _sessionClearInProgress = true; - Physics.SetPosition.ResetSession(); - Entities.BeginSessionClear(); RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); + Physics.CollisionReports.LeaveWorldBatch(active); + Physics.SetPosition.ResetSession(); + Physics.CollisionReports.ResetSession(); + Entities.BeginSessionClear(); foreach (RuntimeEntityRecord canonical in active) { Physics.SetPosition.Forget(canonical); @@ -1166,6 +1189,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvancePositionAuthority(canonical); + Physics.CollisionReports.LeaveWorld(canonical); RuntimePlacementCancellationReceipt cancellation = Physics.SetPosition.Forget(canonical); ulong positionVersion = canonical.PositionAuthorityVersion; diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs index f180b915..55b66168 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -164,6 +164,29 @@ public sealed class RuntimeEntityRecord PhysicsBody.State = FinalPhysicsState; } + /// + /// Retail collision reporting clears Missile, AlignPath, and PathClipped + /// directly on the live CPhysicsObj. Keep the canonical record and its + /// borrowed body in the same mutation edge. + /// + internal bool StopMissileAfterCollision(bool requireCurrentMissile) + { + const PhysicsStateFlags stopped = PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + if (requireCurrentMissile + && (FinalPhysicsState & PhysicsStateFlags.Missile) == 0) + return false; + PhysicsStateFlags final = FinalPhysicsState & ~stopped; + if (final == FinalPhysicsState) + return false; + PhysicsStateMutationVersion++; + FinalPhysicsState = final; + if (PhysicsBody is not null) + PhysicsBody.State = FinalPhysicsState; + return true; + } + internal void RefreshDerivedState(bool refreshPosition = true) { if (refreshPosition && Snapshot.Position is { } position) diff --git a/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs b/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs new file mode 100644 index 00000000..cbd61c95 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs @@ -0,0 +1,918 @@ +using System.Collections.Immutable; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Physics; + +internal enum RuntimeCollisionReportKind +{ + ObjectCollision, + ObjectCollisionEnd, + EnvironmentCollision, +} + +/// +/// Immutable presentation-free projection of one retail weenie collision +/// callback. Runtime commits the callback before an observer can re-enter. +/// +internal readonly record struct RuntimeCollisionReport( + ulong Sequence, + RuntimeCollisionReportKind Kind, + RuntimeEntityKey Recipient, + uint RecipientServerGuid, + RuntimeEntityKey? Other, + uint? OtherServerGuid, + bool RecipientWasInContact, + bool OtherWasInContact); + +internal interface IRuntimeCollisionReportObserver +{ + void OnCollisionReport(in RuntimeCollisionReport report); +} + +internal readonly record struct RuntimeCollisionReportingOwnershipSnapshot( + int OwnerCount, + int TrackedObjectCount, + int ReversePeerCount, + int ObserverCount, + int PendingReportCount, + int LeavingOwnerCount, + int AdmissionBlockedOwnerCount, + bool IsDispatching, + long DispatchFailureCount, + bool IsDisposed) +{ + internal bool IsConverged => + IsDisposed + && OwnerCount == 0 + && TrackedObjectCount == 0 + && ReversePeerCount == 0 + && ObserverCount == 0 + && PendingReportCount == 0 + && LeavingOwnerCount == 0 + && AdmissionBlockedOwnerCount == 0 + && !IsDispatching; +} + +/// +/// Runtime owner for retail CPhysicsObj::collision_table and +/// colliding_with_environment. +/// +/// +/// Records are keyed by exact and retain the +/// peer's server GUID. A deleted incarnation therefore cannot donate contact +/// state to a later GUID reuse, while retail's missing-object collision-end +/// callback can still name the departed server object. +/// +internal sealed class RuntimeCollisionReportingState : IDisposable +{ + private readonly RuntimeEntityDirectory _entities; + private readonly ShadowObjectRegistry _shadows; + private readonly Dictionary _owners = new(); + private readonly Dictionary> + _ownersByPeer = new(); + private readonly Queue _pendingReports = new(); + private readonly HashSet _leaving = []; + private readonly HashSet _admissionBlocked = []; + private IRuntimeCollisionReportObserver[] _observers = []; + private ulong _nextSequence; + private ulong _dispatchEpoch = 1UL; + private long _dispatchFailureCount; + private bool _dispatching; + private bool _disposed; + + internal RuntimeCollisionReportingState( + RuntimeEntityDirectory entities, + ShadowObjectRegistry shadows) + { + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); + } + + internal RuntimeCollisionReportingOwnershipSnapshot CaptureOwnership() + { + int tracked = 0; + foreach ((_, OwnerState owner) in _owners) + tracked += owner.Records.Count; + return new RuntimeCollisionReportingOwnershipSnapshot( + _owners.Count, + tracked, + _ownersByPeer.Count, + _observers.Length, + _pendingReports.Count, + _leaving.Count, + _admissionBlocked.Count, + _dispatching, + _dispatchFailureCount, + _disposed); + } + + internal IDisposable Subscribe(IRuntimeCollisionReportObserver observer) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(observer); + if (Array.IndexOf(_observers, observer) >= 0) + { + throw new InvalidOperationException( + "A collision-report observer cannot be subscribed twice."); + } + + var replacement = new IRuntimeCollisionReportObserver[ + _observers.Length + 1]; + Array.Copy(_observers, replacement, _observers.Length); + replacement[^1] = observer; + _observers = replacement; + return new Subscription(this, observer); + } + + /// + /// Ports the reporting/tracking portion of retail + /// CPhysicsObj::handle_all_collisions (0x00514780). The return is + /// true only when at least one weenie collision callback was produced by + /// this invocation; physical collision response is deliberately separate. + /// + internal bool HandleReports( + RuntimeEntityRecord owner, + PhysicsBody ownerBody, + double physicsTime, + bool previousContact, + bool previousOnWalkable, + bool collidedWithEnvironment, + ImmutableArray collidedObjectIds) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(owner); + ArgumentNullException.ThrowIfNull(ownerBody); + if (!double.IsFinite(physicsTime) + || !TryGetCurrentParticipant(owner, ownerBody, out RuntimeEntityKey key)) + { + return false; + } + + bool reported = false; + if (collidedObjectIds.IsDefault) + collidedObjectIds = ImmutableArray.Empty; + for (int index = 0; index < collidedObjectIds.Length; index++) + { + if (!IsCurrentParticipant(owner, ownerBody, key)) + return reported; + + uint collidedId = collidedObjectIds[index]; + if (collidedId == 0u || collidedId == key.LocalEntityId) + continue; + if (!_shadows.TryGetCollisionOwner( + collidedId, + out _, + out bool registeredStatic)) + { + continue; + } + + if (registeredStatic) + { + reported |= ReportEnvironment( + owner, + ownerBody, + key, + previousContact); + continue; + } + + if (!TryGetCurrentParticipant( + collidedId, + out RuntimeEntityRecord target, + out PhysicsBody targetBody, + out RuntimeEntityKey targetKey)) + { + continue; + } + + // The shadow registry proves exact collision ownership/static + // classification only. Retail evaluates every dynamic behavior + // bit from the live target CPhysicsObj, whose state may already + // be newer than a fallible presentation/shadow acknowledgement. + PhysicsStateFlags targetState = targetBody.State; + if ((targetState & PhysicsStateFlags.Static) != 0) + { + reported |= ReportEnvironment( + owner, + ownerBody, + key, + previousContact); + continue; + } + + OwnerState ownerState = GetOrCreateOwner(key); + bool isNew = !ownerState.Records.ContainsKey(targetKey); + ownerState.Records[targetKey] = new CollisionRecord( + physicsTime, + (targetState & PhysicsStateFlags.Ethereal) != 0, + target.ServerGuid); + if (!isNew) + continue; + + ownerState.Order.Add(targetKey); + AddReverseOwner(targetKey, key); + reported |= ReportObject( + owner, + ownerBody, + key, + target, + targetBody, + targetKey, + targetState, + previousContact); + } + + if (!IsCurrentParticipant(owner, ownerBody, key)) + return reported; + + EndExpiredObjectCollisions( + owner, + ownerBody, + key, + physicsTime, + force: false); + if (!IsCurrentParticipant(owner, ownerBody, key)) + return reported; + + OwnerState? retained = TryGetOwner(key); + if (retained?.CollidingWithEnvironment == true) + { + retained.CollidingWithEnvironment = collidedWithEnvironment; + } + else if (collidedWithEnvironment + || (!previousOnWalkable && ownerBody.OnWalkable)) + { + reported |= ReportEnvironment( + owner, + ownerBody, + key, + previousContact); + } + + TrimEmptyOwner(key); + return reported; + } + + /// + /// Retail leave-world/teleport/Hidden edge: force-end this object's own + /// collision table. Incoming peer records and the environment latch are + /// intentionally retained, matching retail object lookup/lifetime rules. + /// + internal void LeaveWorld(RuntimeEntityRecord record) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key) + return; + if (!_admissionBlocked.Add(key)) + return; + try + { + ForceEnd(record, key); + } + finally + { + _admissionBlocked.Remove(key); + } + } + + /// + /// Session teardown admission transaction. Every exact owner is blocked + /// before the first force-end callback, so a later owner's callback cannot + /// recreate an earlier owner's contact table. + /// + internal void LeaveWorldBatch( + IReadOnlyList records) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(records); + var blocked = new List<(RuntimeEntityRecord Record, RuntimeEntityKey Key)>( + records.Count); + for (int index = 0; index < records.Count; index++) + { + if (records[index].Key is { } key + && _admissionBlocked.Add(key)) + { + blocked.Add((records[index], key)); + } + } + try + { + for (int index = 0; index < blocked.Count; index++) + ForceEnd(blocked[index].Record, blocked[index].Key); + } + finally + { + for (int index = 0; index < blocked.Count; index++) + _admissionBlocked.Remove(blocked[index].Key); + } + } + + /// + /// Destruction edge. Retail first force-ends the departing object's own + /// table and then destroys its state. Other owners retain exact-key + /// records until their own expiry/force pass; they then emit the + /// missing-target self-only end with the preserved server GUID. + /// + internal void Forget(RuntimeEntityRecord record) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key) + return; + // A session-clear batch blocks every owner before publishing the + // first force-end callback. A callback may synchronously accept the + // deletion of a later, already-blocked owner. That owner must still + // publish its own retail collision-end suffix before its table is + // forgotten; only an owner already inside ForceEnd is recursive. + if (_admissionBlocked.Contains(key)) + { + if (!_leaving.Contains(key)) + ForceEnd(record, key); + } + else + { + LeaveWorld(record); + } + _owners.Remove(key); + } + + internal void ResetSession() + { + EnsureNotDisposed(); + _owners.Clear(); + _ownersByPeer.Clear(); + _pendingReports.Clear(); + _leaving.Clear(); + _admissionBlocked.Clear(); + _dispatchEpoch = checked(_dispatchEpoch + 1UL); + } + + public void Dispose() + { + if (_disposed) + return; + _owners.Clear(); + _ownersByPeer.Clear(); + _pendingReports.Clear(); + _leaving.Clear(); + _admissionBlocked.Clear(); + _observers = []; + _dispatchEpoch = checked(_dispatchEpoch + 1UL); + _disposed = true; + } + + private bool ReportObject( + RuntimeEntityRecord owner, + PhysicsBody ownerBody, + RuntimeEntityKey ownerKey, + RuntimeEntityRecord target, + PhysicsBody targetBody, + RuntimeEntityKey targetKey, + PhysicsStateFlags targetState, + bool previousContact) + { + if ((targetState & PhysicsStateFlags.ReportAsEnvironment) != 0) + { + return ReportEnvironment( + owner, + ownerBody, + ownerKey, + previousContact); + } + + PhysicsStateFlags ownerState = ownerBody.State; + bool ownerWasMissile = + (ownerState & PhysicsStateFlags.Missile) != 0; + bool ownerReported = (targetState + & PhysicsStateFlags.IgnoreCollisions) == 0 + && (ownerState & PhysicsStateFlags.ReportCollisions) != 0; + if (ownerReported) + { + Publish(new RuntimeCollisionReport( + NextSequence(), + RuntimeCollisionReportKind.ObjectCollision, + ownerKey, + owner.ServerGuid, + targetKey, + target.ServerGuid, + previousContact, + targetBody.InContact)); + } + + if (ownerWasMissile + && (targetState & PhysicsStateFlags.IgnoreCollisions) == 0) + { + StopMissile( + owner, + ownerBody, + ownerKey, + requireCurrentMissile: false); + } + + // Retail reads reciprocal eligibility after the source callback. A + // reentrant state update can therefore suppress this second report. + bool targetReported = IsCurrentParticipant(target, targetBody, targetKey) + && (targetBody.State & PhysicsStateFlags.ReportCollisions) != 0 + && IsCurrentParticipant(owner, ownerBody, ownerKey) + && (ownerBody.State & PhysicsStateFlags.IgnoreCollisions) == 0; + if (targetReported) + { + Publish(new RuntimeCollisionReport( + NextSequence(), + RuntimeCollisionReportKind.ObjectCollision, + targetKey, + target.ServerGuid, + ownerKey, + owner.ServerGuid, + targetBody.InContact, + previousContact)); + } + return ownerReported || targetReported; + } + + private bool ReportEnvironment( + RuntimeEntityRecord owner, + PhysicsBody ownerBody, + RuntimeEntityKey ownerKey, + bool previousContact) + { + OwnerState state = GetOrCreateOwner(ownerKey); + if (state.CollidingWithEnvironment) + return false; + + bool reported = (ownerBody.State + & PhysicsStateFlags.ReportCollisions) != 0; + state.CollidingWithEnvironment = true; + if (reported) + { + Publish(new RuntimeCollisionReport( + NextSequence(), + RuntimeCollisionReportKind.EnvironmentCollision, + ownerKey, + owner.ServerGuid, + Other: null, + OtherServerGuid: null, + previousContact, + OtherWasInContact: false)); + } + StopMissile(owner, ownerBody, ownerKey); + return reported; + } + + private void EndExpiredObjectCollisions( + RuntimeEntityRecord owner, + PhysicsBody? ownerBody, + RuntimeEntityKey ownerKey, + double physicsTime, + bool force) + { + if (!_owners.TryGetValue(ownerKey, out OwnerState? state) + || state.Records.Count == 0) + { + return; + } + + List? ended = null; + for (int index = 0; index < state.Order.Count; index++) + { + RuntimeEntityKey targetKey = state.Order[index]; + if (!state.Records.TryGetValue( + targetKey, + out CollisionRecord collision)) + { + continue; + } + double age = physicsTime - collision.TouchedTime; + if (!force + && !(age > 1d) + && !(collision.Ethereal && age > 0d)) + { + continue; + } + (ended ??= []).Add(new EndedCollision( + targetKey, + collision.ServerGuid)); + } + + if (ended is null) + return; + + // Retail deletes the complete expired set before issuing any end + // callback. Precommit every Runtime index for reentrant safety too. + ulong reportEpoch = _dispatchEpoch; + for (int index = 0; index < ended.Count; index++) + { + EndedCollision collision = ended[index]; + state.Records.Remove(collision.Key); + state.Order.Remove(collision.Key); + RemoveReverseOwner(collision.Key, ownerKey); + } + + // One accepted delete/replace invalidates the complete precollected + // suffix. Capture the source token once for the whole callback loop; + // recapturing it per peer would let a retained teardown sidecar emit + // a second source report after the first callback accepted deletion. + ulong sourceSessionVersion = _entities.SessionLifetimeVersion; + ulong sourceLifetimeMutation = + _entities.CurrentLifetimeMutation(owner.ServerGuid); + for (int index = 0; index < ended.Count; index++) + { + if (_disposed + || reportEpoch != _dispatchEpoch + || _entities.SessionLifetimeVersion != sourceSessionVersion + || _entities.CurrentLifetimeMutation(owner.ServerGuid) + != sourceLifetimeMutation) + { + break; + } + EndedCollision collision = ended[index]; + if (TryGetParticipant( + collision.Key, + out RuntimeEntityRecord target, + out PhysicsBody targetBody)) + { + // report_object_collision_end returns immediately for a + // resolved ReportAsEnvironment target: neither side gets an + // object-end callback. + if ((targetBody.State + & PhysicsStateFlags.ReportAsEnvironment) != 0) + { + continue; + } + PublishResolvedObjectEnd( + owner, + ownerBody, + ownerKey, + target, + targetBody, + collision.Key); + } + else + { + PublishMissingObjectEnd( + owner, + ownerBody, + ownerKey, + collision.Key, + collision.ServerGuid); + } + } + TrimEmptyOwner(ownerKey); + } + + private void PublishResolvedObjectEnd( + RuntimeEntityRecord owner, + PhysicsBody? ownerBody, + RuntimeEntityKey ownerKey, + RuntimeEntityRecord target, + PhysicsBody targetBody, + RuntimeEntityKey targetKey) + { + ulong sourceSessionVersion = _entities.SessionLifetimeVersion; + ulong sourceLifetimeMutation = + _entities.CurrentLifetimeMutation(owner.ServerGuid); + ulong reportEpoch = _dispatchEpoch; + if (ownerBody is not null + && (ownerBody.State & PhysicsStateFlags.ReportCollisions) != 0 + && IsKnownParticipant(owner, ownerBody, ownerKey)) + { + Publish(new RuntimeCollisionReport( + NextSequence(), + RuntimeCollisionReportKind.ObjectCollisionEnd, + ownerKey, + owner.ServerGuid, + targetKey, + target.ServerGuid, + ownerBody.InContact, + targetBody.InContact)); + } + + if ((targetBody.State & PhysicsStateFlags.ReportCollisions) != 0 + && reportEpoch == _dispatchEpoch + && !_disposed + && _entities.SessionLifetimeVersion == sourceSessionVersion + && _entities.CurrentLifetimeMutation(owner.ServerGuid) + == sourceLifetimeMutation + && ownerBody is not null + && IsKnownParticipant(owner, ownerBody, ownerKey) + && IsKnownParticipant(target, targetBody, targetKey)) + { + Publish(new RuntimeCollisionReport( + NextSequence(), + RuntimeCollisionReportKind.ObjectCollisionEnd, + targetKey, + target.ServerGuid, + ownerKey, + owner.ServerGuid, + targetBody.InContact, + ownerBody?.InContact ?? false)); + } + } + + private void PublishMissingObjectEnd( + RuntimeEntityRecord owner, + PhysicsBody? ownerBody, + RuntimeEntityKey ownerKey, + RuntimeEntityKey targetKey, + uint targetServerGuid) + { + if (ownerBody is null + || (ownerBody.State & PhysicsStateFlags.ReportCollisions) == 0 + || !IsKnownParticipant(owner, ownerBody, ownerKey)) + { + return; + } + Publish(new RuntimeCollisionReport( + NextSequence(), + RuntimeCollisionReportKind.ObjectCollisionEnd, + ownerKey, + owner.ServerGuid, + targetKey, + targetServerGuid, + ownerBody.InContact, + OtherWasInContact: false)); + } + + private void StopMissile( + RuntimeEntityRecord owner, + PhysicsBody ownerBody, + RuntimeEntityKey ownerKey, + bool requireCurrentMissile = true) + { + if (!IsCurrentParticipant(owner, ownerBody, ownerKey) + || !_entities.StopMissileAfterCollision( + owner, + requireCurrentMissile)) + { + return; + } + _shadows.UpdatePhysicsState( + ownerKey.LocalEntityId, + (uint)owner.FinalPhysicsState); + } + + private OwnerState GetOrCreateOwner(RuntimeEntityKey key) + { + if (!_owners.TryGetValue(key, out OwnerState? owner)) + { + owner = new OwnerState(); + _owners.Add(key, owner); + } + return owner; + } + + private OwnerState? TryGetOwner(RuntimeEntityKey key) => + _owners.TryGetValue(key, out OwnerState? owner) ? owner : null; + + private void TrimEmptyOwner(RuntimeEntityKey key) + { + if (_owners.TryGetValue(key, out OwnerState? owner) + && owner.Records.Count == 0 + && !owner.CollidingWithEnvironment) + { + _owners.Remove(key); + } + } + + private void AddReverseOwner( + RuntimeEntityKey peer, + RuntimeEntityKey owner) + { + if (!_ownersByPeer.TryGetValue( + peer, + out List? owners)) + { + owners = []; + _ownersByPeer.Add(peer, owners); + } + if (!owners.Contains(owner)) + owners.Add(owner); + } + + private void RemoveReverseOwner( + RuntimeEntityKey peer, + RuntimeEntityKey owner) + { + if (!_ownersByPeer.TryGetValue( + peer, + out List? owners)) + { + return; + } + owners.Remove(owner); + if (owners.Count == 0) + _ownersByPeer.Remove(peer); + } + + private bool TryGetCurrentParticipant( + RuntimeEntityRecord record, + PhysicsBody body, + out RuntimeEntityKey key) + { + key = record.Key ?? default; + return key != default + && IsCurrentParticipant(record, body, key); + } + + private bool TryGetCurrentParticipant( + uint localEntityId, + out RuntimeEntityRecord record, + out PhysicsBody body, + out RuntimeEntityKey key) + { + if (_entities.TryGetByLocalId(localEntityId, out record!) + && record.PhysicsBody is { } retained + && record.Key is { } retainedKey + && retainedKey.LocalEntityId == localEntityId + && !_leaving.Contains(retainedKey) + && !_admissionBlocked.Contains(retainedKey) + && retained.InWorld + && (retained.State & PhysicsStateFlags.Hidden) == 0 + && _entities.IsCurrent(record)) + { + body = retained; + key = retainedKey; + return true; + } + body = null!; + key = default; + return false; + } + + private bool TryGetParticipant( + RuntimeEntityKey key, + out RuntimeEntityRecord record, + out PhysicsBody body) + { + if (_entities.TryGetByLocalId(key.LocalEntityId, out record!) + && record.Key == key + && !_leaving.Contains(key) + && _entities.IsCurrent(record) + && record.PhysicsBody is { } retained) + { + body = retained; + return true; + } + body = null!; + return false; + } + + private bool IsCurrentParticipant( + RuntimeEntityRecord record, + PhysicsBody body, + RuntimeEntityKey key) => + _entities.IsCurrent(record) + && !_leaving.Contains(key) + && !_admissionBlocked.Contains(key) + && body.InWorld + && (body.State & PhysicsStateFlags.Hidden) == 0 + && IsKnownParticipant(record, body, key); + + private void ForceEnd( + RuntimeEntityRecord record, + RuntimeEntityKey key) + { + if (!_leaving.Add(key)) + return; + try + { + EndExpiredObjectCollisions( + record, + record.PhysicsBody, + key, + physicsTime: 0d, + force: true); + } + finally + { + _leaving.Remove(key); + } + } + + private bool IsKnownParticipant( + RuntimeEntityRecord record, + PhysicsBody body, + RuntimeEntityKey key) => + record.Key == key + && ReferenceEquals(record.PhysicsBody, body) + && _entities.TryGetByLocalId( + key.LocalEntityId, + out RuntimeEntityRecord retained) + && ReferenceEquals(retained, record); + + private ulong NextSequence() => checked(++_nextSequence); + + private void Publish(in RuntimeCollisionReport report) + { + _pendingReports.Enqueue(new PendingReport(_dispatchEpoch, report)); + if (_dispatching) + return; + + _dispatching = true; + try + { + while (!_disposed && _pendingReports.TryDequeue(out PendingReport pending)) + { + if (pending.Epoch != _dispatchEpoch) + continue; + IRuntimeCollisionReportObserver[] observers = _observers; + for (int index = 0; index < observers.Length; index++) + { + try + { + observers[index].OnCollisionReport(pending.Report); + } + catch (Exception error) + { + _dispatchFailureCount++; + System.Diagnostics.Trace.TraceError( + "Runtime collision-report observer failed: {0}", + error); + } + if (_disposed || pending.Epoch != _dispatchEpoch) + break; + } + } + } + finally + { + _dispatching = false; + if (_disposed) + _pendingReports.Clear(); + } + } + + private void Unsubscribe(IRuntimeCollisionReportObserver observer) + { + int index = Array.IndexOf(_observers, observer); + if (index < 0) + return; + if (_observers.Length == 1) + { + _observers = []; + return; + } + var replacement = new IRuntimeCollisionReportObserver[ + _observers.Length - 1]; + if (index > 0) + Array.Copy(_observers, 0, replacement, 0, index); + if (index < _observers.Length - 1) + { + Array.Copy( + _observers, + index + 1, + replacement, + index, + _observers.Length - index - 1); + } + _observers = replacement; + } + + private void EnsureNotDisposed() => + ObjectDisposedException.ThrowIf(_disposed, this); + + private sealed class OwnerState + { + internal Dictionary Records { get; } + = new(); + internal List Order { get; } = []; + internal bool CollidingWithEnvironment { get; set; } + } + + private readonly record struct CollisionRecord( + double TouchedTime, + bool Ethereal, + uint ServerGuid); + + private readonly record struct EndedCollision( + RuntimeEntityKey Key, + uint ServerGuid); + + private readonly record struct PendingReport( + ulong Epoch, + RuntimeCollisionReport Report); + + private sealed class Subscription : IDisposable + { + private RuntimeCollisionReportingState? _owner; + private readonly IRuntimeCollisionReportObserver _observer; + + internal Subscription( + RuntimeCollisionReportingState owner, + IRuntimeCollisionReportObserver observer) + { + _owner = owner; + _observer = observer; + } + + public void Dispose() + { + RuntimeCollisionReportingState? owner = + Interlocked.Exchange(ref _owner, null); + owner?.Unsubscribe(_observer); + } + } +} diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 3df6294c..e67465c5 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -23,6 +23,14 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( int UnboundDeferredSetPositionCellCount, int UnboundDeferredSetPositionCellOrderCount, int PreparedSetPositionMoverCount, + int CollisionReportOwnerCount, + int TrackedCollisionObjectCount, + int CollisionReportReversePeerCount, + int CollisionReportObserverCount, + int PendingCollisionReportCount, + int LeavingCollisionReportOwnerCount, + int CollisionReportAdmissionBlockedOwnerCount, + bool IsCollisionReportDispatching, int CollisionAdmissionCount, int CollisionGenerationCount, bool OwnsProductionDataCache, @@ -49,6 +57,14 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( && UnboundDeferredSetPositionCellCount == 0 && UnboundDeferredSetPositionCellOrderCount == 0 && PreparedSetPositionMoverCount == 0 + && CollisionReportOwnerCount == 0 + && TrackedCollisionObjectCount == 0 + && CollisionReportReversePeerCount == 0 + && CollisionReportObserverCount == 0 + && PendingCollisionReportCount == 0 + && LeavingCollisionReportOwnerCount == 0 + && CollisionReportAdmissionBlockedOwnerCount == 0 + && !IsCollisionReportDispatching && CollisionAdmissionCount == 0 && CollisionGenerationCount == 0 && OwnsProductionDataCache; @@ -1063,6 +1079,9 @@ public sealed class RuntimePhysicsState : IDisposable Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; Engine.ShadowObjects.OwnerPrefixMembershipChanged += OnCollisionOwnerPrefixMembershipChanged; + CollisionReports = new RuntimeCollisionReportingState( + Entities, + Engine.ShadowObjects); SetPosition = new RuntimeSetPositionState(this, Entities); } @@ -1082,12 +1101,16 @@ public sealed class RuntimePhysicsState : IDisposable Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; Engine.ShadowObjects.OwnerPrefixMembershipChanged += OnCollisionOwnerPrefixMembershipChanged; + CollisionReports = new RuntimeCollisionReportingState( + Entities, + Engine.ShadowObjects); SetPosition = new RuntimeSetPositionState(this, Entities); } internal RuntimeEntityDirectory Entities { get; } public PhysicsEngine Engine { get; } public PhysicsDataCache DataCache { get; } + internal RuntimeCollisionReportingState CollisionReports { get; } internal RuntimeSetPositionState SetPosition { get; } public int SpatialRootCount => _spatialRoots.Count; public int SpatialRemoteCount => _spatialRemotes.Count; @@ -1107,6 +1130,8 @@ public sealed class RuntimePhysicsState : IDisposable { RuntimeSetPositionOwnershipSnapshot setPosition = SetPosition.CaptureOwnership(); + RuntimeCollisionReportingOwnershipSnapshot collisionReports = + CollisionReports.CaptureOwnership(); return new( Engine.LandblockCount, Engine.ShadowObjects.RetainedRegistrationCount, @@ -1127,6 +1152,14 @@ public sealed class RuntimePhysicsState : IDisposable setPosition.UnboundDeferredCellCount, setPosition.UnboundDeferredCellOrderCount, setPosition.PreparedMoverCount, + collisionReports.OwnerCount, + collisionReports.TrackedObjectCount, + collisionReports.ReversePeerCount, + collisionReports.ObserverCount, + collisionReports.PendingReportCount, + collisionReports.LeavingOwnerCount, + collisionReports.AdmissionBlockedOwnerCount, + collisionReports.IsDispatching, _collisionAdmissions.Count, _collisionGenerations.Count, ReferenceEquals(Engine.DataCache, DataCache), @@ -2303,6 +2336,7 @@ public sealed class RuntimePhysicsState : IDisposable } _preparedCollisionGenerations.Clear(); SetPosition.Dispose(); + CollisionReports.Dispose(); _collisionOwnerJournal.Clear(); _collisionOwnerSubscribers.Clear(); Engine.Clear(); @@ -2511,6 +2545,7 @@ public sealed class RuntimePhysicsState : IDisposable ulong positionAuthorityVersion, ulong spatialAuthorityVersion, ulong velocityAuthorityVersion, + double physicsTime, bool previousContact, bool previousOnWalkable, in PhysicsSetPositionCollisionReport report) @@ -2518,22 +2553,39 @@ public sealed class RuntimePhysicsState : IDisposable if (!Entities.IsCurrent(record) || record.PositionAuthorityVersion != positionAuthorityVersion || record.SpatialAuthorityVersion != spatialAuthorityVersion - || (velocityAuthorityVersion != 0UL - && record.VelocityAuthorityVersion - != velocityAuthorityVersion) || record.PhysicsBody is not { } body) { return false; } + _ = previousContact; + _ = previousOnWalkable; + bool current = HandleSetPositionCollisionReports( + record, + positionAuthorityVersion, + spatialAuthorityVersion, + physicsTime, + previousContact: false, + previousOnWalkable: false, + collidedWithEnvironment: report.CollidedWithEnvironment, + collidedObjectIds: report.CollidedObjectIds, + collisionHandlerResult: out bool collisionHandlerResult); + if (!current) + return collisionHandlerResult; + if (velocityAuthorityVersion != 0UL + && record.VelocityAuthorityVersion != velocityAuthorityVersion) + { + return collisionHandlerResult; + } + body.FramesStationaryFall = report.FramesStationaryFall; PhysicsObjUpdate.HandleAllCollisions( body, report.CollisionNormalValid, report.CollisionNormal, - previousContact, - previousOnWalkable, - body.OnWalkable); + prevContact: false, + prevOnWalkable: false, + nowOnWalkable: body.OnWalkable); body.TransientState &= ~(TransientStateFlags.StationaryFall | TransientStateFlags.StationaryStop | TransientStateFlags.StationaryStuck); @@ -2544,12 +2596,49 @@ public sealed class RuntimePhysicsState : IDisposable 3 => TransientStateFlags.StationaryStuck, _ => TransientStateFlags.None, }; - // Retail returns the result of collision reporting, not a collision- - // presence guess. Runtime does not yet own the per-object report/ - // tracking table required to reproduce that return value, so fail - // closed. This preserves ordinary placement rejection and leaves the - // already-registered reporting seam explicit for the 4B2 cutover. - return false; + return collisionHandlerResult; + } + + /// + /// Runs only retail's report/tracking half of handle_all_collisions. A + /// successful SetPosition invokes this after contact/ground callbacks and + /// before physical response and shadow reflood; invalid placement invokes + /// it before its one response pass. + /// + internal bool HandleSetPositionCollisionReports( + RuntimeEntityRecord record, + ulong positionAuthorityVersion, + ulong spatialAuthorityVersion, + double physicsTime, + bool previousContact, + bool previousOnWalkable, + bool collidedWithEnvironment, + System.Collections.Immutable.ImmutableArray collidedObjectIds, + out bool collisionHandlerResult) + { + collisionHandlerResult = false; + if (!Entities.IsCurrent(record) + || record.PositionAuthorityVersion != positionAuthorityVersion + || record.SpatialAuthorityVersion != spatialAuthorityVersion + || record.PhysicsBody is not { } body) + { + return false; + } + + collisionHandlerResult = CollisionReports.HandleReports( + record, + body, + physicsTime, + previousContact, + previousOnWalkable, + collidedWithEnvironment, + collidedObjectIds); + return Entities.IsCurrent(record) + && record.PositionAuthorityVersion == positionAuthorityVersion + && record.SpatialAuthorityVersion == spatialAuthorityVersion + && ReferenceEquals(record.PhysicsBody, body) + && body.InWorld + && (body.State & PhysicsStateFlags.Hidden) == 0; } private void OnCollisionOwnerMutated(uint ownerId, ulong version) diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 302318e1..b0692f5a 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -213,6 +213,25 @@ internal sealed class RuntimeSetPositionState : IDisposable } } + private sealed class ContactCommitGuard( + RuntimeSetPositionState owner, + Operation operation, + RuntimeEntityRecord record, + PhysicsBody body, + ulong placementCommitVersion, + uint fullCellId) + { + internal bool IsCurrent() => + owner.IsCanonicalPlacementCommitCurrent( + operation, + record, + body, + placementCommitVersion, + fullCellId, + requireSpatialRoot: false) + && owner.IsCollisionReportingEligible(record, body); + } + private readonly RuntimePhysicsState _physics; private readonly RuntimeEntityDirectory _entities; private readonly Dictionary _operations = []; @@ -549,9 +568,12 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.PositionAuthorityVersion, operation.SourceSpatialAuthorityVersion, operation.SourceVelocityAuthorityVersion, + canonicalCommand.GameTime, operation.PreviousContact, operation.PreviousOnWalkable, report)); + if (!IsCurrent(operation)) + return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); operation.Result = result; if (!result.IsSuccessful) { @@ -1236,11 +1258,14 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.PositionAuthorityVersion, operation.SpatialAuthorityVersion, operation.SourceVelocityAuthorityVersion, + operation.Command.GameTime, operation.PreviousContact, operation.PreviousOnWalkable, report)) : InvalidResult(operation.Command.Physics); operation.CollisionGenerationReady = false; + if (!IsCurrent(operation)) + return; if (result.IsDeferred) { operation.Result = result; @@ -1320,18 +1345,6 @@ internal sealed class RuntimeSetPositionState : IDisposable body.TransientState |= TransientStateFlags.Sliding; else body.TransientState &= ~TransientStateFlags.Sliding; - body.FramesStationaryFall = result.FramesStationaryFall; - body.TransientState &= ~(TransientStateFlags.StationaryFall - | TransientStateFlags.StationaryStop - | TransientStateFlags.StationaryStuck); - body.TransientState |= result.FramesStationaryFall switch - { - 1 => TransientStateFlags.StationaryFall, - 2 => TransientStateFlags.StationaryStop, - 3 => TransientStateFlags.StationaryStuck, - _ => TransientStateFlags.None, - }; - IRuntimeRemotePlacement? remote = record.RemoteMotion as IRuntimeRemotePlacement; if (record.FullCellId != result.CellId) @@ -1354,6 +1367,109 @@ internal sealed class RuntimeSetPositionState : IDisposable remote.LastShadowSyncOrientation = result.Orientation; } + uint committedCellId = result.CellId; + bool collidedWithEnvironment = result.CollidedWithEnvironment; + System.Collections.Immutable.ImmutableArray collidedObjectIds = + result.CollidedObjectIds; + if (!IsCanonicalPlacementCommitCurrent( + operation, + record, + body, + canonicalCommitVersion, + committedCellId, + requireSpatialRoot: false)) + return false; + bool contactCommitted; + if (remote is null) + { + contactCommitted = PhysicsObjUpdate.CommitSetPositionContactTransition( + body, + result.InContact, + result.OnWalkable, + operation.PreviousOnWalkable); + } + else + { + var guard = new ContactCommitGuard( + this, + operation, + record, + body, + canonicalCommitVersion, + committedCellId); + contactCommitted = PhysicsObjUpdate.CommitSetPositionContactTransition( + body, + result.InContact, + result.OnWalkable, + operation.PreviousOnWalkable, + remote.HitGround, + remote.LeaveGround, + guard.IsCurrent); + } + if (!contactCommitted + || !IsCanonicalPlacementCommitCurrent( + operation, + record, + body, + canonicalCommitVersion, + committedCellId, + requireSpatialRoot: false) + || !IsCollisionReportingEligible(record, body)) + { + return false; + } + bool reportingCurrent = _physics.HandleSetPositionCollisionReports( + record, + operation.PositionAuthorityVersion, + operation.SpatialAuthorityVersion, + operation.Command.GameTime, + operation.PreviousContact, + operation.PreviousOnWalkable, + collidedWithEnvironment, + collidedObjectIds, + out _); + if (!reportingCurrent + || !IsCanonicalPlacementCommitCurrent( + operation, + record, + body, + canonicalCommitVersion, + committedCellId, + requireSpatialRoot: false) + || !IsCollisionReportingEligible(record, body)) + return false; + body.FramesStationaryFall = result.FramesStationaryFall; + if (IsVelocityCurrent(operation)) + { + PhysicsObjUpdate.HandleAllCollisions( + body, + result.CollisionNormalValid, + result.CollisionNormal, + operation.PreviousContact, + operation.PreviousOnWalkable, + body.OnWalkable); + } + body.TransientState &= ~(TransientStateFlags.StationaryFall + | TransientStateFlags.StationaryStop + | TransientStateFlags.StationaryStuck); + body.TransientState |= result.FramesStationaryFall switch + { + 1 => TransientStateFlags.StationaryFall, + 2 => TransientStateFlags.StationaryStop, + 3 => TransientStateFlags.StationaryStuck, + _ => TransientStateFlags.None, + }; + if (remote is not null) + remote.Airborne = !body.OnWalkable; + if (!IsCanonicalPlacementCommitCurrent( + operation, + record, + body, + canonicalCommitVersion, + committedCellId, + requireSpatialRoot: false)) + return false; + _physics.Engine.ShadowObjects.CommitSetPosition( operation.Key.LocalEntityId, result.Position, @@ -1370,39 +1486,44 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.EnteringWorldFromCelllessResidence = false; CancelLostFamilyDeadlines(operation); - uint committedCellId = result.CellId; - bool IsCanonicalCommitCurrent() => - _entities.IsCurrent(record) - && ReferenceEquals(record.PhysicsBody, body) - && record.PlacementCommitVersion == canonicalCommitVersion - && record.FullCellId == committedCellId - && _physics.IsSpatialRoot(record); - Action? hitGround = remote is null ? null : remote.HitGround; - Action? leaveGround = remote is null ? null : remote.LeaveGround; - if (!PhysicsObjUpdate.CommitSetPositionTransition( - body, - result.InContact, - result.OnWalkable, - result.CollisionNormalValid, - result.CollisionNormal, - operation.PreviousContact, - operation.PreviousOnWalkable, - hitGround, - leaveGround, - IsCanonicalCommitCurrent, - () => IsCanonicalCommitCurrent() - && IsVelocityCurrent(operation))) - { - return false; - } - if (remote is not null) - remote.Airborne = !body.OnWalkable; return IsCurrent(operation) - && IsCanonicalCommitCurrent(); + && IsCanonicalPlacementCommitCurrent( + operation, + record, + body, + canonicalCommitVersion, + committedCellId, + requireSpatialRoot: true); } + private bool IsCanonicalPlacementCommitCurrent( + Operation operation, + RuntimeEntityRecord record, + PhysicsBody body, + ulong placementCommitVersion, + uint fullCellId, + bool requireSpatialRoot) => + _entities.IsCurrent(record) + && ReferenceEquals(record.PhysicsBody, body) + && record.PositionAuthorityVersion + == operation.PositionAuthorityVersion + && record.SpatialAuthorityVersion + == operation.SpatialAuthorityVersion + && record.PlacementCommitVersion == placementCommitVersion + && record.FullCellId == fullCellId + && (!requireSpatialRoot || _physics.IsSpatialRoot(record)); + + private bool IsCollisionReportingEligible( + RuntimeEntityRecord record, + PhysicsBody body) => + _entities.IsCurrent(record) + && ReferenceEquals(record.PhysicsBody, body) + && body.InWorld + && (body.State & PhysicsStateFlags.Hidden) == 0; + private void WithdrawCanonical(RuntimeEntityRecord record) { + _physics.CollisionReports.LeaveWorld(record); _physics.RemoveSpatialProjection(record); if (record.Key is { } key) _physics.Engine.ShadowObjects.Suspend(key.LocalEntityId); diff --git a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs index 37082f4f..5c64367d 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs @@ -63,6 +63,73 @@ public sealed class RuntimePhysicsOwnershipTests StringComparison.Ordinal); } + [Fact] + public void GraphicalAndHeadlessHostsBorrowOneRuntimeCollisionAuthority() + { + string root = FindRepositoryRoot(); + string appRoot = Path.Combine(root, "src", "AcDream.App"); + string headlessRoot = Path.Combine(root, "src", "AcDream.Headless"); + string gameWindow = File.ReadAllText(Path.Combine( + appRoot, + "Rendering", + "GameWindow.cs")); + string headlessProjection = File.ReadAllText(Path.Combine( + headlessRoot, + "Hosting", + "HeadlessSessionWorldProjection.cs")); + string productionHosts = string.Join( + "\n", + Directory.EnumerateFiles( + appRoot, + "*.cs", + SearchOption.AllDirectories) + .Concat(Directory.EnumerateFiles( + headlessRoot, + "*.cs", + SearchOption.AllDirectories)) + .Select(File.ReadAllText)); + + Assert.Contains( + "private RuntimeEntityObjectLifetime _runtimeEntityObjects =>", + gameWindow, + StringComparison.Ordinal); + Assert.Contains( + "_runtime.EntityObjects;", + gameWindow, + StringComparison.Ordinal); + Assert.Contains( + "_runtimeEntityObjects.Physics", + gameWindow, + StringComparison.Ordinal); + Assert.Contains( + "private readonly GameRuntime _runtime;", + headlessProjection, + StringComparison.Ordinal); + Assert.Contains( + "_runtime.EntityObjects.Physics", + headlessProjection, + StringComparison.Ordinal); + + // Hosts may project reports later, but they must never own a second + // collision table or reproduce Runtime's return/error heuristic. + Assert.DoesNotContain( + "new RuntimeCollisionReportingState", + productionHosts, + StringComparison.Ordinal); + Assert.DoesNotContain( + "class RuntimeCollisionReportingState", + productionHosts, + StringComparison.Ordinal); + Assert.DoesNotContain( + "HandleSetPositionCollisions(", + productionHosts, + StringComparison.Ordinal); + Assert.DoesNotContain( + "CollidingWithEnvironment", + productionHosts, + StringComparison.Ordinal); + } + [Fact] public void AppPhysicsFilesArePresentationAndPreparedAssetAdapters() { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs new file mode 100644 index 00000000..90de9944 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs @@ -0,0 +1,1860 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimeCollisionReportingStateTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = Landblock | 0x0001u; + + [Fact] + public void ExactObjectReportsAreOrderedDeduplicatedAndExpireAtRetailThreshold() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002001u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord first = Entity( + lifetime, + 0x70002002u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord second = Entity( + lifetime, + 0x70002003u, + 1, + PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, first); + RegisterDynamicShadow(lifetime, second); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + time: 10d, + Collisions( + first.Key!.Value.LocalEntityId, + first.Key.Value.LocalEntityId, + second.Key!.Value.LocalEntityId))); + + Assert.Collection( + observer.Reports, + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollision, + owner, + first), + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollision, + first, + owner), + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollision, + owner, + second)); + RuntimeCollisionReportingOwnershipSnapshot tracked = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(1, tracked.OwnerCount); + Assert.Equal(2, tracked.TrackedObjectCount); + Assert.Equal(2, tracked.ReversePeerCount); + + observer.Reports.Clear(); + Assert.False(Handle(lifetime, owner, time: 11d, Collisions())); + Assert.Empty(observer.Reports); + Assert.Equal(2, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + + Assert.False(Handle(lifetime, owner, time: 11.0001d, Collisions())); + Assert.Collection( + observer.Reports, + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollisionEnd, + owner, + first), + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollisionEnd, + first, + owner), + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollisionEnd, + owner, + second)); + RuntimeCollisionReportingOwnershipSnapshot ended = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ended.OwnerCount); + Assert.Equal(0, ended.TrackedObjectCount); + Assert.Equal(0, ended.ReversePeerCount); + } + + [Fact] + public void NoReportParticipantsTrackWithoutTurningPresenceIntoSuccess() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002010u, + 1, + PhysicsStateFlags.None); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002011u, + 1, + PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, target); + + Assert.False(Handle( + lifetime, + owner, + time: 3d, + Collisions(target.Key!.Value.LocalEntityId))); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(1, ownership.OwnerCount); + Assert.Equal(1, ownership.TrackedObjectCount); + } + + [Fact] + public void ExactStaticAndReportAsEnvironmentObjectsUseEnvironmentLatch() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002020u, + 1, + PhysicsStateFlags.ReportCollisions); + uint staticId = 0x00F00001u; + RegisterShadow( + lifetime, + staticId, + PhysicsStateFlags.None, + isStatic: true); + RuntimeEntityRecord environmentObject = Entity( + lifetime, + 0x70002021u, + 1, + PhysicsStateFlags.ReportAsEnvironment); + RegisterDynamicShadow(lifetime, environmentObject); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + time: 1d, + Collisions( + staticId, + environmentObject.Key!.Value.LocalEntityId, + environmentCollision: true))); + Assert.Single(observer.Reports); + AssertReport( + observer.Reports[0], + RuntimeCollisionReportKind.EnvironmentCollision, + owner, + other: null); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + + observer.Reports.Clear(); + Assert.False(Handle( + lifetime, + owner, + time: 1.1d, + Collisions(staticId, environmentCollision: true))); + Assert.Empty(observer.Reports); + + Assert.False(Handle(lifetime, owner, time: 1.2d, Collisions())); + Assert.True(Handle( + lifetime, + owner, + time: 1.3d, + Collisions(staticId, environmentCollision: true))); + Assert.Single(observer.Reports); + } + + [Fact] + public void UnknownObjectAndNonFiniteClockFailClosedWithoutTracking() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002030u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002031u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + + Assert.False(Handle( + lifetime, + owner, + time: double.NaN, + Collisions(target.Key!.Value.LocalEntityId))); + Assert.False(Handle( + lifetime, + owner, + time: 2d, + Collisions(0x00ABCDEFu))); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.TrackedObjectCount); + } + + [Fact] + public void EtherealContactExpiresAfterItsTouchedQuantum() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002040u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002041u, + 1, + PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Ethereal); + RegisterDynamicShadow(lifetime, target); + + Assert.True(Handle( + lifetime, + owner, + time: 4d, + Collisions(target.Key!.Value.LocalEntityId))); + Assert.False(Handle(lifetime, owner, time: 4d, Collisions())); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + Assert.False(Handle(lifetime, owner, time: 4.0001d, Collisions())); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void IgnoreAsymmetryAndMissileStopFollowRetailStateReads() + { + using var lifetime = Lifetime(); + PhysicsStateFlags missile = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + RuntimeEntityRecord owner = Entity( + lifetime, 0x70002042u, 1, missile); + RuntimeEntityRecord ignored = Entity( + lifetime, + 0x70002043u, + 1, + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.IgnoreCollisions); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, ignored); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + 4d, + Collisions(ignored.Key!.Value.LocalEntityId))); + RuntimeCollisionReport reciprocal = Assert.Single(observer.Reports); + Assert.Equal(ignored.Key, reciprocal.Recipient); + Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Missile)); + + RuntimeEntityRecord ordinary = Entity( + lifetime, 0x70002044u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, ordinary); + ulong mutation = owner.PhysicsStateMutationVersion; + Assert.True(Handle( + lifetime, + owner, + 4.1d, + Collisions(ordinary.Key!.Value.LocalEntityId))); + Assert.Equal(mutation + 1UL, owner.PhysicsStateMutationVersion); + PhysicsStateFlags cleared = PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + Assert.Equal(0u, (uint)(owner.FinalPhysicsState & cleared)); + Assert.Equal(owner.FinalPhysicsState, owner.PhysicsBody!.State); + Assert.True(lifetime.Physics.Engine.ShadowObjects + .TryGetCollisionOwner( + owner.Key!.Value.LocalEntityId, + out uint shadowState, + out _)); + Assert.Equal((uint)owner.FinalPhysicsState, shadowState); + + RuntimeEntityRecord sourceIgnoring = Entity( + lifetime, + 0x70002045u, + 1, + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.IgnoreCollisions); + RuntimeEntityRecord reportingTarget = Entity( + lifetime, + 0x70002046u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, reportingTarget); + observer.Reports.Clear(); + Assert.True(Handle( + lifetime, + sourceIgnoring, + 4.2d, + Collisions(reportingTarget.Key!.Value.LocalEntityId))); + RuntimeCollisionReport sourceOnly = Assert.Single(observer.Reports); + Assert.Equal(sourceIgnoring.Key, sourceOnly.Recipient); + } + + [Fact] + public void ObjectReportDoesNotClearMissileAddedBySourceCallback() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002093u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002094u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, target); + bool added = false; + var observer = new CollisionObserver(report => + { + if (added + || report.Kind is not RuntimeCollisionReportKind.ObjectCollision + || report.Recipient != owner.Key) + { + return; + } + PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + added = lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)state, + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + 4d, + Collisions(target.Key!.Value.LocalEntityId))); + + Assert.True(added); + PhysicsStateFlags retained = owner.FinalPhysicsState; + Assert.NotEqual(0u, (uint)(retained & PhysicsStateFlags.Missile)); + Assert.NotEqual(0u, (uint)(retained & PhysicsStateFlags.AlignPath)); + Assert.NotEqual(0u, (uint)(retained & PhysicsStateFlags.PathClipped)); + } + + [Fact] + public void PreCallbackMissileGateClearsCurrentPathBitsAfterCallback() + { + using var lifetime = Lifetime(); + PhysicsStateFlags initial = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + RuntimeEntityRecord owner = Entity( + lifetime, 0x70002095u, 1, initial); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002096u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + bool changed = false; + var observer = new CollisionObserver(report => + { + if (changed + || report.Kind is not RuntimeCollisionReportKind.ObjectCollision + || report.Recipient != owner.Key) + { + return; + } + PhysicsStateFlags callbackState = + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + changed = lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)callbackState, + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + 4d, + Collisions(target.Key!.Value.LocalEntityId))); + + Assert.True(changed); + const PhysicsStateFlags stopped = PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + Assert.Equal(0u, (uint)(owner.FinalPhysicsState & stopped)); + Assert.Equal(owner.FinalPhysicsState, owner.PhysicsBody!.State); + Assert.True(lifetime.Physics.Engine.ShadowObjects + .TryGetCollisionOwner( + owner.Key!.Value.LocalEntityId, + out uint shadowState, + out _)); + Assert.Equal((uint)owner.FinalPhysicsState, shadowState); + } + + [Fact] + public void CanonicalDynamicStateOverridesStaleShadowClassification() + { + using var lifetime = Lifetime(); + PhysicsStateFlags missile = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + RuntimeEntityRecord owner = Entity( + lifetime, 0x7000208Bu, 1, missile); + RuntimeEntityRecord ignored = Entity( + lifetime, 0x7000208Cu, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, ignored); + ignored.PhysicsBody!.State = PhysicsStateFlags.IgnoreCollisions; + lifetime.Entities.SetFinalPhysicsState( + ignored, + PhysicsStateFlags.IgnoreCollisions); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.False(Handle( + lifetime, + owner, + 4d, + Collisions(ignored.Key!.Value.LocalEntityId))); + Assert.Empty(observer.Reports); + Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Missile)); + + RuntimeEntityRecord environmentOwner = Entity( + lifetime, + 0x7000208Du, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord newlyEnvironment = Entity( + lifetime, 0x7000208Eu, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, newlyEnvironment); + newlyEnvironment.PhysicsBody!.State = + PhysicsStateFlags.ReportAsEnvironment; + lifetime.Entities.SetFinalPhysicsState( + newlyEnvironment, + PhysicsStateFlags.ReportAsEnvironment); + observer.Reports.Clear(); + Assert.True(Handle( + lifetime, + environmentOwner, + 4.1d, + Collisions(newlyEnvironment.Key!.Value.LocalEntityId))); + Assert.Equal( + RuntimeCollisionReportKind.EnvironmentCollision, + Assert.Single(observer.Reports).Kind); + + RuntimeEntityRecord ordinaryOwner = Entity( + lifetime, + 0x7000208Fu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord noLongerEnvironment = Entity( + lifetime, + 0x70002090u, + 1, + PhysicsStateFlags.Static + | PhysicsStateFlags.ReportAsEnvironment); + RegisterDynamicShadow(lifetime, noLongerEnvironment); + noLongerEnvironment.PhysicsBody!.State = PhysicsStateFlags.None; + lifetime.Entities.SetFinalPhysicsState( + noLongerEnvironment, + PhysicsStateFlags.None); + observer.Reports.Clear(); + Assert.True(Handle( + lifetime, + ordinaryOwner, + 4.2d, + Collisions(noLongerEnvironment.Key!.Value.LocalEntityId))); + Assert.Equal( + RuntimeCollisionReportKind.ObjectCollision, + Assert.Single(observer.Reports).Kind); + + RuntimeEntityRecord shadowOnlyOwner = Entity( + lifetime, + 0x70002091u, + 1, + PhysicsStateFlags.ReportCollisions); + const uint shadowOnlyId = 0x00F02091u; + RegisterShadow( + lifetime, + shadowOnlyId, + PhysicsStateFlags.ReportAsEnvironment, + isStatic: false); + observer.Reports.Clear(); + Assert.False(Handle( + lifetime, + shadowOnlyOwner, + 4.3d, + Collisions(shadowOnlyId))); + Assert.Empty(observer.Reports); + } + + [Fact] + public void RegisteredStaticAndUnobservedEnvironmentUseExactLatch() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002047u, + 1, + PhysicsStateFlags.ReportCollisions); + const uint rawStaticId = 0x00F00047u; + RegisterShadow( + lifetime, + rawStaticId, + PhysicsStateFlags.Static, + isStatic: true); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions(rawStaticId))); + Assert.Equal( + RuntimeCollisionReportKind.EnvironmentCollision, + Assert.Single(observer.Reports).Kind); + + RuntimeEntityRecord unobserved = Entity( + lifetime, 0x70002092u, 1, PhysicsStateFlags.None); + Assert.False(Handle( + lifetime, + unobserved, + 1.1d, + Collisions([], environmentCollision: true))); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .OwnerCount); + Assert.False(Handle( + lifetime, + unobserved, + 1.2d, + Collisions([], environmentCollision: true))); + Assert.False(Handle(lifetime, unobserved, 1.3d, Collisions())); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .OwnerCount); + } + + [Fact] + public void ReportAsEnvironmentSuppressesBothObjectEndCallbacks() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002048u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002049u, + 1, + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.ReportAsEnvironment); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions(target.Key!.Value.LocalEntityId))); + observer.Reports.Clear(); + Assert.False(Handle(lifetime, owner, 2.0001d, Collisions())); + Assert.Empty(observer.Reports); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void NestedReportsDrainFifoAndObserverFailureDoesNotCorruptQueue() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord firstOwner = Entity( + lifetime, + 0x7000204Au, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord firstTarget = Entity( + lifetime, 0x7000204Bu, 1, PhysicsStateFlags.None); + RuntimeEntityRecord secondOwner = Entity( + lifetime, + 0x7000204Cu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord secondTarget = Entity( + lifetime, 0x7000204Du, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, firstTarget); + RegisterDynamicShadow(lifetime, secondTarget); + bool nested = false; + var firstObserver = new CollisionObserver(_ => + { + if (nested) + return; + nested = true; + Assert.True(Handle( + lifetime, + secondOwner, + 2d, + Collisions(secondTarget.Key!.Value.LocalEntityId))); + }); + var secondObserver = new CollisionObserver(); + var failing = new ThrowingCollisionObserver(); + using IDisposable firstSubscription = lifetime.Physics.CollisionReports + .Subscribe(firstObserver); + using IDisposable failingSubscription = lifetime.Physics.CollisionReports + .Subscribe(failing); + using IDisposable secondSubscription = lifetime.Physics.CollisionReports + .Subscribe(secondObserver); + Assert.Throws(() => + lifetime.Physics.CollisionReports.Subscribe(secondObserver)); + + Assert.True(Handle( + lifetime, + firstOwner, + 1d, + Collisions(firstTarget.Key!.Value.LocalEntityId))); + Assert.Collection( + secondObserver.Reports, + report => Assert.Equal(firstOwner.Key, report.Recipient), + report => Assert.Equal(secondOwner.Key, report.Recipient)); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(2, ownership.DispatchFailureCount); + Assert.Equal(0, ownership.PendingReportCount); + Assert.False(ownership.IsDispatching); + } + + [Fact] + public void ReentrantResetDropsRemainingPriorEpochReports() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x7000204Eu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord first = Entity( + lifetime, 0x7000204Fu, 1, PhysicsStateFlags.None); + RuntimeEntityRecord second = Entity( + lifetime, 0x70002054u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, first); + RegisterDynamicShadow(lifetime, second); + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions( + first.Key!.Value.LocalEntityId, + second.Key!.Value.LocalEntityId))); + bool reset = false; + var observer = new CollisionObserver(_ => + { + if (reset) + return; + reset = true; + lifetime.Physics.CollisionReports.ResetSession(); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + lifetime.Physics.CollisionReports.LeaveWorld(owner); + + Assert.True(reset); + Assert.Single(observer.Reports); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.PendingReportCount); + Assert.False(ownership.IsDispatching); + } + + [Fact] + public void ReentrantTargetDeletionCannotDeliverSecondOrRetainStaleContact() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002050u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002051u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + bool deleted = false; + var observer = new CollisionObserver(report => + { + if (deleted + || report.Kind is not RuntimeCollisionReportKind.ObjectCollision + || report.Recipient != owner.Key) + { + return; + } + deleted = true; + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(target.ServerGuid, target.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(target)); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + time: 5d, + Collisions(target.Key!.Value.LocalEntityId))); + Assert.True(deleted); + Assert.Single(observer.Reports); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(1, ownership.OwnerCount); + Assert.Equal(1, ownership.TrackedObjectCount); + Assert.Equal(1, ownership.ReversePeerCount); + + Assert.False(Handle(lifetime, owner, time: 6.0001d, Collisions())); + Assert.Equal(2, observer.Reports.Count); + RuntimeCollisionReport ended = observer.Reports[1]; + Assert.Equal(RuntimeCollisionReportKind.ObjectCollisionEnd, ended.Kind); + Assert.Equal(target.ServerGuid, ended.OtherServerGuid); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void DeleteAndGuidReuseNeverTransferTrackedIncarnation() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002060u, + 1, + PhysicsStateFlags.ReportCollisions); + const uint reusedGuid = 0x70002061u; + RuntimeEntityRecord retired = Entity( + lifetime, + reusedGuid, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityKey retiredKey = retired.Key!.Value; + RegisterDynamicShadow(lifetime, retired); + Assert.True(Handle( + lifetime, + owner, + time: 6d, + Collisions(retired.Key!.Value.LocalEntityId))); + + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(reusedGuid, 1), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(retired)); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + + RuntimeEntityRecord replacement = Entity( + lifetime, + reusedGuid, + 2, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, replacement); + Assert.NotEqual(retiredKey, replacement.Key); + Assert.True(Handle( + lifetime, + owner, + time: 6.1d, + Collisions(replacement.Key!.Value.LocalEntityId))); + Assert.Equal(2, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + + Assert.False(Handle( + lifetime, + owner, + time: 7.0001d, + Collisions(replacement.Key.Value.LocalEntityId))); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void HiddenLeaveWorldRejectsReentrantContactReaddition() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002068u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002069u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, target); + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions(target.Key!.Value.LocalEntityId))); + bool readdResult = true; + var observer = new CollisionObserver(report => + { + if (report.Kind is RuntimeCollisionReportKind.ObjectCollisionEnd) + { + readdResult = Handle( + lifetime, + owner, + 1.1d, + Collisions(target.Key!.Value.LocalEntityId)); + } + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Hidden), + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out RetailPhysicsStateTransition transition)); + Assert.Equal( + RetailHiddenTransition.BecameHidden, + transition.HiddenTransition); + Assert.False(readdResult); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.TrackedObjectCount); + Assert.Equal(0, ownership.LeavingOwnerCount); + } + + [Fact] + public void FirstForceEndCallbackDeletingOwnerStopsRemainingSourceSuffix() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002082u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord first = Entity( + lifetime, 0x70002083u, 1, PhysicsStateFlags.None); + RuntimeEntityRecord second = Entity( + lifetime, 0x70002084u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, first); + RegisterDynamicShadow(lifetime, second); + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions( + first.Key!.Value.LocalEntityId, + second.Key!.Value.LocalEntityId))); + bool accepted = false; + var observer = new CollisionObserver(report => + { + if (accepted + || report.Kind is not RuntimeCollisionReportKind + .ObjectCollisionEnd + || report.Recipient != owner.Key) + { + return; + } + accepted = true; + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(owner.ServerGuid, owner.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out _)); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + lifetime.Physics.CollisionReports.LeaveWorld(owner); + + Assert.True(accepted); + RuntimeCollisionReport report = Assert.Single(observer.Reports); + Assert.Equal(first.Key, report.Other); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void SessionClearForceEndsBeforeTerminalCollisionReset() + { + var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x7000206Au, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x7000206Bu, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions(target.Key!.Value.LocalEntityId))); + observer.Reports.Clear(); + + IReadOnlyList retiring = lifetime.BeginSessionClear(); + Assert.Collection( + observer.Reports, + report => Assert.Equal( + RuntimeCollisionReportKind.ObjectCollisionEnd, + report.Kind), + report => Assert.Equal( + RuntimeCollisionReportKind.ObjectCollisionEnd, + report.Kind)); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + foreach (RuntimeEntityRecord record in retiring) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + lifetime.Dispose(); + } + + [Fact] + public void SessionClearBlocksCrossOwnerReadditionForWholeBatch() + { + var lifetime = Lifetime(); + RuntimeEntityRecord firstOwner = Entity( + lifetime, + 0x7000207Au, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord peer = Entity( + lifetime, 0x7000207Bu, 1, PhysicsStateFlags.None); + RuntimeEntityRecord laterOwner = Entity( + lifetime, + 0x7000207Cu, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, peer); + Assert.True(Handle( + lifetime, + firstOwner, + 1d, + Collisions(peer.Key!.Value.LocalEntityId))); + Assert.True(Handle( + lifetime, + laterOwner, + 1d, + Collisions(peer.Key.Value.LocalEntityId))); + bool attempted = false; + bool readded = true; + var observer = new CollisionObserver(report => + { + if (attempted || report.Recipient != laterOwner.Key) + return; + attempted = true; + readded = Handle( + lifetime, + firstOwner, + 1.1d, + Collisions(peer.Key.Value.LocalEntityId)); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + IReadOnlyList retiring = lifetime.BeginSessionClear(); + + Assert.True(attempted); + Assert.False(readded); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.AdmissionBlockedOwnerCount); + foreach (RuntimeEntityRecord record in retiring) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + lifetime.Dispose(); + } + + [Fact] + public void SessionClearCallbackDeletingLaterBlockedOwnerStillForceEndsIt() + { + var lifetime = Lifetime(); + RuntimeEntityRecord firstOwner = Entity( + lifetime, + 0x70002085u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord firstPeer = Entity( + lifetime, 0x70002086u, 1, PhysicsStateFlags.None); + RuntimeEntityRecord laterOwner = Entity( + lifetime, + 0x70002087u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord laterPeer = Entity( + lifetime, 0x70002088u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, firstPeer); + RegisterDynamicShadow(lifetime, laterPeer); + Assert.True(Handle( + lifetime, + firstOwner, + 1d, + Collisions(firstPeer.Key!.Value.LocalEntityId))); + Assert.True(Handle( + lifetime, + laterOwner, + 1d, + Collisions(laterPeer.Key!.Value.LocalEntityId))); + bool accepted = false; + var observer = new CollisionObserver(report => + { + if (accepted + || report.Kind is not RuntimeCollisionReportKind + .ObjectCollisionEnd + || report.Recipient != firstOwner.Key) + { + return; + } + accepted = true; + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed( + laterOwner.ServerGuid, + laterOwner.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out _)); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + IReadOnlyList retiring = lifetime.BeginSessionClear(); + + Assert.True(accepted); + Assert.Contains( + observer.Reports, + report => report.Kind + is RuntimeCollisionReportKind.ObjectCollisionEnd + && report.Recipient == laterOwner.Key + && report.Other == laterPeer.Key); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.AdmissionBlockedOwnerCount); + foreach (RuntimeEntityRecord record in retiring) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + lifetime.Dispose(); + } + + [Fact] + public void InvalidSetPositionMapsNewReportThenRepeatToRetailErrors() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x7000206Cu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x7000206Du, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + transition.CollisionInfo.LastCollidedObjectGuid = targetId; + } + return TransitionState.Collided; + }; + + RuntimeSetPositionCommand command = PlacementCommand( + owner, + new Vector3(13f, 12f, 7f)); + RuntimeSetPositionOutcome first = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + command); + Assert.Equal(RuntimeSetPositionStatus.Rejected, first.Status); + Assert.Equal(PhysicsSetPositionError.Collided, first.Error); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + owner, + out RuntimeEntityPlacementToken retry)); + + RuntimeSetPositionOutcome repeated = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(retry, command); + Assert.Equal(RuntimeSetPositionStatus.Rejected, repeated.Status); + Assert.Equal(PhysicsSetPositionError.NoValidPosition, repeated.Error); + } + + [Fact] + public void SuccessfulSetPositionReportsBeforeResponseAndShadowReflood() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x7000206Eu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x7000206Fu, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + Vector3 initial = owner.PhysicsBody!.Position; + Vector3 destination = new(14f, 12f, 7f); + owner.PhysicsBody.set_velocity(-Vector3.UnitX); + owner.PhysicsBody.FramesStationaryFall = 2; + owner.PhysicsBody.TransientState |= + TransientStateFlags.StationaryStop; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + transition.CollisionInfo.LastCollidedObjectGuid = targetId; + transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX); + transition.CollisionInfo.FramesStationaryFall = 1; + } + return TransitionState.OK; + }; + bool observed = false; + var observer = new CollisionObserver(report => + { + if (report.Kind is not RuntimeCollisionReportKind.ObjectCollision + || report.Recipient != owner.Key) + { + return; + } + observed = true; + Assert.Equal(destination, owner.PhysicsBody.Position); + Assert.Equal(-Vector3.UnitX, owner.PhysicsBody.Velocity); + Assert.Equal(2, owner.PhysicsBody.FramesStationaryFall); + Assert.NotEqual( + 0u, + (uint)(owner.PhysicsBody.TransientState + & TransientStateFlags.StationaryStop)); + Assert.Equal( + 0u, + (uint)(owner.PhysicsBody.TransientState + & TransientStateFlags.StationaryFall)); + ShadowEntry shadow = Assert.Single( + lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == owner.Key!.Value.LocalEntityId); + Assert.Equal(initial, shadow.Position); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + PlacementCommand(owner, destination)); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(observed); + ShadowEntry committedShadow = Assert.Single( + lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == owner.Key!.Value.LocalEntityId); + Assert.Equal(destination, committedShadow.Position); + Assert.True(owner.PhysicsBody.Velocity.X >= 0f); + Assert.Equal(1, owner.PhysicsBody.FramesStationaryFall); + Assert.NotEqual( + 0u, + (uint)(owner.PhysicsBody.TransientState + & TransientStateFlags.StationaryFall)); + Assert.Equal( + 0u, + (uint)(owner.PhysicsBody.TransientState + & TransientStateFlags.StationaryStop)); + } + + [Fact] + public void ReportSurvivesNewVectorWhileStalePhysicalResponseDoesNot() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002072u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002073u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX); + } + return TransitionState.OK; + }; + bool observed = false; + var observer = new CollisionObserver(report => + { + if (observed || report.Recipient != owner.Key) + return; + observed = true; + lifetime.Entities.AdvanceVectorAuthority(owner); + owner.PhysicsBody!.set_velocity(new Vector3(-7f, 3f, 0f)); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + RuntimeSetPositionCommand command = PlacementCommand( + owner, + new Vector3(15f, 12f, 7f)) with + { + ExpectedVelocityAuthorityVersion = owner.VelocityAuthorityVersion, + }; + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + command); + + Assert.True(observed); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(new Vector3(-7f, 3f, 0f), owner.PhysicsBody!.Velocity); + } + + [Fact] + public void HitGroundHiddenTransitionCannotResumeCollisionTracking() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002074u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002075u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + Cell); + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + } + return TransitionState.OK; + }; + bool hidden = false; + var remote = new ReentrantRemotePlacement(owner.PhysicsBody!) + { + CellId = Cell, + OnHitGround = () => + { + hidden = lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Hidden), + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _); + }, + }; + lifetime.Entities.SetRemoteMotion(owner, remote); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + PlacementCommand(owner, new Vector3(16f, 12f, 7f))); + + Assert.True(hidden); + Assert.Equal(RuntimeSetPositionStatus.Cancelled, outcome.Status); + Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)); + Assert.Empty(observer.Reports); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void HitGroundHiddenPeerCannotBecomeAStaleTrackedTarget() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002076u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002077u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + Cell); + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + } + return TransitionState.OK; + }; + var remote = new ReentrantRemotePlacement(owner.PhysicsBody!) + { + CellId = Cell, + OnHitGround = () => Assert.True(lifetime.TryApplyState( + new SetState.Parsed( + target.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Hidden), + target.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _)), + }; + lifetime.Entities.SetRemoteMotion(owner, remote); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + PlacementCommand(owner, new Vector3(17f, 12f, 7f))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(target.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)); + Assert.DoesNotContain( + observer.Reports, + report => report.Kind + is RuntimeCollisionReportKind.ObjectCollision); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void CollisionReportHiddenTransitionStopsResponseAndShadowReflood() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002078u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002079u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + Vector3 initial = owner.PhysicsBody!.Position; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX); + } + return TransitionState.OK; + }; + bool hidden = false; + var observer = new CollisionObserver(report => + { + if (hidden + || report.Kind is not RuntimeCollisionReportKind.ObjectCollision) + { + return; + } + hidden = lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Hidden), + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + PlacementCommand(owner, new Vector3(18f, 12f, 7f))); + + Assert.True(hidden); + Assert.Equal(RuntimeSetPositionStatus.Cancelled, outcome.Status); + Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + ShadowEntry shadow = Assert.Single( + lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == owner.Key!.Value.LocalEntityId); + Assert.Equal(initial, shadow.Position); + } + + [Fact] + public void SessionResetAndDisposalConvergeEveryCollisionOwner() + { + var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002070u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002071u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + Assert.True(Handle( + lifetime, + owner, + time: 7d, + Collisions(target.Key!.Value.LocalEntityId))); + + IReadOnlyList retiring = lifetime.BeginSessionClear(); + RuntimeCollisionReportingOwnershipSnapshot cleared = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, cleared.OwnerCount); + Assert.Equal(0, cleared.TrackedObjectCount); + foreach (RuntimeEntityRecord record in retiring) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + subscription.Dispose(); + lifetime.Dispose(); + Assert.True(lifetime.Physics.CollisionReports.CaptureOwnership() + .IsConverged); + Assert.True(lifetime.Physics.CaptureOwnership().IsConverged); + } + + [Fact] + public void IndependentRuntimeRootsProduceDeterministicReportsAndResult() + { + static (bool Result, RuntimeCollisionReport[] Reports) Run() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002080u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002081u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + bool result = Handle( + lifetime, + owner, + time: 8d, + Collisions(target.Key!.Value.LocalEntityId)); + return (result, observer.Reports.ToArray()); + } + + (bool graphicalResult, RuntimeCollisionReport[] graphical) = Run(); + (bool noWindowResult, RuntimeCollisionReport[] noWindow) = Run(); + Assert.Equal(graphicalResult, noWindowResult); + Assert.Equal(graphical, noWindow); + } + + [Fact] + public void WarmedSteadyContactRefreshDoesNotAllocate() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002089u, + 1, + PhysicsStateFlags.None); + RuntimeEntityRecord target = Entity( + lifetime, 0x7000208Au, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, target); + PhysicsSetPositionCollisionReport collision = + Collisions(target.Key!.Value.LocalEntityId); + Assert.False(Handle(lifetime, owner, 1d, collision)); + + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < 10_000; index++) + _ = Handle(lifetime, owner, 1.1d, collision); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + private static RuntimeEntityObjectLifetime Lifetime() + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return new RuntimeEntityObjectLifetime(engine); + } + + private static RuntimeEntityRecord Entity( + RuntimeEntityObjectLifetime lifetime, + uint guid, + ushort incarnation, + PhysicsStateFlags state) + { + RuntimeEntityRecord record = lifetime.RegisterEntity( + Spawn(guid, incarnation, state)).Canonical!; + lifetime.Entities.SetFullCell(record, Cell, Landblock | 0xFFFFu); + lifetime.Entities.SetFinalPhysicsState(record, state); + var body = new PhysicsBody + { + Position = new Vector3(12f, 12f, 7f), + Orientation = Quaternion.Identity, + State = state, + LastUpdateTime = 1d, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(Cell, body.Position, body.Position); + lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true); + return record; + } + + private static void RegisterDynamicShadow( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record) => RegisterShadow( + lifetime, + record.Key!.Value.LocalEntityId, + record.PhysicsBody!.State, + isStatic: false); + + private static void RegisterShadow( + RuntimeEntityObjectLifetime lifetime, + uint localId, + PhysicsStateFlags state, + bool isStatic) => lifetime.Physics.Engine.ShadowObjects.Register( + localId, + gfxObjId: 0u, + new Vector3(12f, 12f, 7f), + Quaternion.Identity, + radius: 0.4f, + worldOffsetX: 0f, + worldOffsetY: 0f, + Landblock, + ShadowCollisionType.Sphere, + state: (uint)state, + seedCellId: Cell, + isStatic: isStatic); + + private static bool Handle( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord owner, + double time, + in PhysicsSetPositionCollisionReport collision) => + lifetime.Physics.HandleSetPositionCollisions( + owner, + owner.PositionAuthorityVersion, + owner.SpatialAuthorityVersion, + owner.VelocityAuthorityVersion, + time, + owner.PhysicsBody!.InContact, + owner.PhysicsBody.OnWalkable, + collision); + + private static RuntimeSetPositionCommand PlacementCommand( + RuntimeEntityRecord owner, + Vector3 position) + { + var request = new PhysicsSetPositionRequest( + position, + Quaternion.Identity, + Cell, + position, + [new FlatCollisionSphere(Vector3.Zero, 0.4f)], + Scale: 1f, + StepUpHeight: 0.4f, + StepDownHeight: 0.4f, + MoverPhysicsState: owner.FinalPhysicsState, + MovingEntityId: owner.Key!.Value.LocalEntityId, + Flags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide, + CurrentCellId: Cell); + return new RuntimeSetPositionCommand( + request, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 10d, + ExpectedVelocityAuthorityVersion: + owner.VelocityAuthorityVersion, + ShadowWorldOffsetX: 0f, + ShadowWorldOffsetY: 0f); + } + + private static PhysicsSetPositionCollisionReport Collisions( + params uint[] objectIds) => Collisions( + objectIds, + environmentCollision: false); + + private static PhysicsSetPositionCollisionReport Collisions( + uint objectId, + bool environmentCollision) => Collisions( + [objectId], + environmentCollision); + + private static PhysicsSetPositionCollisionReport Collisions( + uint first, + uint second, + bool environmentCollision) => Collisions( + [first, second], + environmentCollision); + + private static PhysicsSetPositionCollisionReport Collisions( + uint[] objectIds, + bool environmentCollision) => new( + ContactPlaneValid: false, + ContactPlane: default, + ContactPlaneCellId: 0u, + ContactPlaneIsWater: false, + LastKnownContactPlaneValid: false, + LastKnownContactPlane: default, + LastKnownContactPlaneCellId: 0u, + LastKnownContactPlaneIsWater: false, + SlidingNormalValid: false, + SlidingNormal: default, + CollisionNormalValid: false, + CollisionNormal: default, + CollidedWithEnvironment: environmentCollision, + FramesStationaryFall: 0, + AdjustOffset: default, + LastCollidedObjectId: objectIds.Length == 0 ? null : objectIds[^1], + CollidedObjectIds: objectIds.ToImmutableArray()); + + private static void AssertReport( + RuntimeCollisionReport report, + RuntimeCollisionReportKind kind, + RuntimeEntityRecord recipient, + RuntimeEntityRecord? other) + { + Assert.Equal(kind, report.Kind); + Assert.Equal(recipient.Key, report.Recipient); + Assert.Equal(recipient.ServerGuid, report.RecipientServerGuid); + Assert.Equal(other?.Key, report.Other); + Assert.Equal(other?.ServerGuid, report.OtherServerGuid); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort instance, + PhysicsStateFlags state) + { + var position = new CreateObject.ServerPosition( + Cell, + 12f, + 12f, + 7f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: (uint)state, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "collision-report-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)state, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class CollisionObserver( + Action? onReport = null) + : IRuntimeCollisionReportObserver + { + internal List Reports { get; } = []; + + public void OnCollisionReport(in RuntimeCollisionReport report) + { + Reports.Add(report); + onReport?.Invoke(report); + } + } + + private sealed class ThrowingCollisionObserver + : IRuntimeCollisionReportObserver + { + public void OnCollisionReport(in RuntimeCollisionReport report) => + throw new InvalidOperationException( + $"fixture failure {report.Sequence}"); + } + + private sealed class ReentrantRemotePlacement(PhysicsBody body) + : IRuntimeRemotePlacement + { + private Func? _readCell; + private Action? _writeCell; + private uint _cellId; + + public PhysicsBody Body { get; } = body; + public uint CellId + { + get => _readCell?.Invoke() ?? _cellId; + set + { + _cellId = value; + _writeCell?.Invoke(value); + } + } + public bool Airborne { get; set; } + public Vector3 LastServerPosition { get; set; } + public double LastServerPositionTime { get; set; } + public Vector3 LastShadowSyncPosition { get; set; } + public Quaternion LastShadowSyncOrientation { get; set; } + internal Action? OnHitGround { get; init; } + internal Action? OnLeaveGround { get; init; } + + public void BindCanonicalCell(Func read, Action write) + { + _readCell = read; + _writeCell = write; + } + + public void HitGround() => OnHitGround?.Invoke(); + public void LeaveGround() => OnLeaveGround?.Invoke(); + } +} From 442cb8f97b4511c0b3f149c41150d33ad1ceb7f4 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 09:16:09 +0200 Subject: [PATCH 30/73] feat(runtime): prepare authored SetPosition movers --- ...et-position-collision-reporting-handoff.md | 14 +- ...set-position-authored-mover-preparation.md | 106 +++ .../Entities/RuntimeEntityDirectory.cs | 2 +- .../Entities/RuntimeEntityRecord.cs | 8 + .../RuntimeSetPositionMoverPreparation.cs | 156 ++++ .../Physics/RuntimeSetPositionState.cs | 316 +++++++- ...RuntimeSetPositionMoverPreparationTests.cs | 715 ++++++++++++++++++ .../Physics/RuntimeSetPositionStateTests.cs | 269 +++++-- 8 files changed, 1516 insertions(+), 70 deletions(-) create mode 100644 docs/research/2026-08-01-runtime-set-position-authored-mover-preparation.md create mode 100644 src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs diff --git a/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md b/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md index 5fdc05d3..fd8276d2 100644 --- a/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md +++ b/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md @@ -159,14 +159,16 @@ the pre-existing line-ending/stat-noise paths are deliberately excluded. ## Remaining work - required order -### 1. Exact authored mover preparation +### 1. Exact authored mover preparation - complete 2026-08-01 -Build every SetPosition request from Setup's ordered authored spheres, exact -scale/presence semantics, StepUp/StepDown heights, flags, cell-local frame and -orientation, and current position/vector/state authority versions. Do not -synthesize a cylinder from visual radius/height or pre-mutate canonical state. +The dormant preparation contract is implemented and independently reviewed. +It binds the Runtime-owned accepted frame and exact Setup DID, preserves the +ordered authored spheres and scale/step semantics, seals the returned command, +and forces stale deferred residents through exact re-preparation without +pre-mutating canonical state. See +[`2026-08-01-runtime-set-position-authored-mover-preparation.md`](2026-08-01-runtime-set-position-authored-mover-preparation.md). -### 2. Atomic local-controller/body publication +### 2. Atomic local-controller/body publication - next Prepare off-canonical, then perform one Runtime-validated atomic transaction which publishes the exact same body to graphical and no-window controllers. diff --git a/docs/research/2026-08-01-runtime-set-position-authored-mover-preparation.md b/docs/research/2026-08-01-runtime-set-position-authored-mover-preparation.md new file mode 100644 index 00000000..7e3193d6 --- /dev/null +++ b/docs/research/2026-08-01-runtime-set-position-authored-mover-preparation.md @@ -0,0 +1,106 @@ +# Runtime SetPosition authored mover preparation - 2026-08-01 + +## Scope + +This is placement Slice 4B2 checkpoint 3. It adds the dormant, +presentation-independent preparation contract used to turn an accepted Runtime +position into retail's exact `CPhysicsObj::SetPosition` mover input. No App or +Headless production route consumes the contract yet, so game behavior is +unchanged and AP-1/AD-1 remain open. + +## Retail oracle + +The implementation was checked against the named September 2013 client: + +- `PhysicsDesc::PhysicsDesc` `0x0051D4D0` +- `PhysicsDesc::UnPack` `0x0051DDD0` +- `CPhysicsObj::set_description` `0x00514F40` +- `CPhysicsObj::SetPosition` `0x005160C0` +- `SPHEREPATH::init_sphere` `0x0050C670` +- `CPartArray::GetNumSphere` `0x00518060` +- `CPartArray::GetSphere` `0x00518070` +- `CPartArray::GetStepUpHeight` `0x005180D0` +- `CPartArray::GetStepDownHeight` `0x005180F0` +- `CTransition::init_object` `0x00509E40` +- `OBJECTINFO::init` `0x0050CF30` + +SetPosition calls `CTransition::init_object(..., state = 0)` directly. It does +not use the ordinary-movement `CPhysicsObj::get_object_info` path. Consequently +the SetPosition state carries the player/PK/PKLite/impenetrable classifications +(plus acdream's pointer-free entry-restriction carrier), but it does not add +Contact, OnWalkable, PathClipped, FreeRotate, or EdgeSlide. Ethereal and +`step_down = !Missile` are separate `OBJECTINFO` fields derived from the current +physics state. + +## Exact preparation contract + +- Runtime captures the complete accepted server frame under the exact entity, + session, position, vector/velocity, wire-state, final-physics-state mutation, + object-description, and create-integration authorities. A host cannot + substitute a second position. +- Collision-world X/Y uses the target landblock's active live-centered offsets; + full cell ID, cell-local XYZ, and the complete quaternion remain unchanged. +- Setup resolution is bound to the canonical Setup DID. A known but unavailable + Setup remains retryable. Resolved-absent is valid only when the canonical + object has no Setup. An authored empty Setup remains distinct and still + contributes its scaled StepUp/StepDown heights. +- The complete ordered Setup sphere list is retained. Core later applies + retail's `min(count, 2)` traversal cap. The successfully resolved no-PartArray + or zero-sphere arm reaches Core with an empty list, where SetPosition supplies + the retail dummy sphere `(0,0,0.1)`, radius `0.1`, scale `1.0`. +- Scale precedence is `PhysicsDesc.Scale ?? EntitySpawn.ObjScale ?? 1.0`. + Present zero and finite negative values are preserved. Scale is not consumed + by a resolved-absent dummy mover. +- Every authored command is sealed to the exact preparation operation. Manual, + stale, replaced, or merely value-equivalent commands cannot bypass the seal. +- A wire-state, final-state mutation (including NoDraw and missile-stop), + vector/velocity, description, or create change during a deferred cell wait + returns the resident to `AwaitingPreparation`; the stale mover is never + replayed when the collision generation wakes. +- Preparation mutates no body, clock, FullCell, spatial/shadow registration, + bucket, camera, world entity, or presentation resource. + +Legacy direct SetPosition remains a distinct token mode so the dormant slice +does not change existing call sites or their warmed allocation ceiling. If a +legacy operation becomes deferred and later needs new authored data, the +presence of Runtime's preparation authority makes the exact seal mandatory. + +## Ownership and validation + +`RuntimeSetPositionState` owns one exact-key preparation-authority entry only +for operations which require authored preparation. The entry dies with the +operation on replacement, acknowledgement, cancellation, delete, session +reset, or disposal and participates in terminal convergence accounting. + +Preparation-only validation checks the exact cell frame, live-centered world +position, values consumed by the first two retail spheres, Setup-derived step +heights, line/scatter inputs, and bounded scatter attempts. The legacy direct +validator retains its prior behavior, including retail's dummy-sphere and +first-two-sphere semantics. + +## Gates and review + +- Focused authored-mover plus SetPosition residence tests: 80/80. +- Runtime Release build: zero warnings and zero errors. +- Complete Runtime project under invariant culture: 595/595. +- Complete Release solution with installed DAT/pak fixtures: 10,342 passed / + 4 intentional skips. +- Retail-conformance review: canonical frame, DID binding, scale/step/sphere + behavior, exact SetPosition flags, and deferred wake checked against the + named addresses above. +- Architecture/adversarial review: command sealing, legacy promotion, + replacement, deferred wake, direct compatibility, allocation, reset, GUID + reuse, and ownership convergence checked. + +The three ordinary current-culture Runtime failures are pre-existing Swedish- +locale formatting assumptions (`0,5` versus `0.5` and localized sky text); the +same complete project passes under invariant culture. + +## Next checkpoint + +Implement the dormant atomic local-player physics publication transaction: +prepare a private controller/body/clock without canonical mutation, evaluate +SetPosition against that candidate, then publish the exact same body relation +to `RuntimeEntityRecord` and `RuntimeLocalPlayerMovementState` in one callback- +free Runtime commit. App and Headless production activation remains a later +checkpoint. diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 578a558b..bb4a353f 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -320,7 +320,7 @@ public sealed class RuntimeEntityDirectory PhysicsStateFlags state) { EnsureKnown(record); - record.FinalPhysicsState = state; + record.SetFinalPhysicsState(state); } internal bool StopMissileAfterCollision( diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs index 55b66168..369c7861 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -164,6 +164,14 @@ public sealed class RuntimeEntityRecord PhysicsBody.State = FinalPhysicsState; } + internal void SetFinalPhysicsState(PhysicsStateFlags state) + { + if (state == FinalPhysicsState) + return; + PhysicsStateMutationVersion++; + FinalPhysicsState = state; + } + /// /// Retail collision reporting clears Missile, AlignPath, and PathClipped /// directly on the live CPhysicsObj. Keep the canonical record and its diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs new file mode 100644 index 00000000..e2f97302 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs @@ -0,0 +1,156 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Physics; + +internal enum RuntimeSetPositionMoverPreparationStatus +{ + Prepared, + RetrySetupUnavailable, + RejectedAuthority, + InvalidData, +} + +/// +/// Distinguishes a Setup payload which has not arrived yet from a completed +/// lookup whose result is absent. Retail supplies its dummy placement sphere +/// only inside CPhysicsObj::SetPosition; an unavailable lookup must not +/// manufacture that fallback while asynchronous preparation is still live. +/// +internal readonly record struct RuntimeSetPositionMoverSetup( + bool IsResolved, + uint SetupTableId, + FlatSetupCollision? Collision) +{ + internal static RuntimeSetPositionMoverSetup Unavailable => default; + + internal static RuntimeSetPositionMoverSetup ResolvedAbsent => + new(true, 0u, null); + + internal static RuntimeSetPositionMoverSetup Resolved( + uint setupTableId, + FlatSetupCollision collision) => + new( + true, + setupTableId != 0u + ? setupTableId + : throw new ArgumentOutOfRangeException(nameof(setupTableId)), + collision ?? throw new ArgumentNullException(nameof(collision))); +} + +/// +/// Explicit, immutable inputs surrounding retail's authored mover shape. +/// None of these values are inferred from presentation state. +/// +internal readonly record struct RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup Setup, + RuntimeSetPositionOperationKind Kind, + double GameTime, + PhysicsPlacementClass PlacementClass, + PhysicsSetPositionFlags Flags, + Vector3 Line = default, + float ScatterRadiusX = 0f, + float ScatterRadiusY = 0f, + uint ScatterAttempts = 0u, + float ShadowWorldOffsetX = 0f, + float ShadowWorldOffsetY = 0f, + RuntimePortalPlacementAuthority Portal = default); + +/// +/// Pure preparation port of the mover inputs consumed by retail +/// CPhysicsObj::SetPosition (0x005160C0). It preserves the complete DAT +/// sphere order; Core's SPHEREPATH port applies retail's two-sphere cap later. +/// +internal static class RuntimeSetPositionMoverPreparer +{ + internal static bool TryBuild( + RuntimeEntityRecord record, + in CreateObject.ServerPosition acceptedPosition, + uint canonicalSetupTableId, + RuntimeSetPositionOperationKind acceptedKind, + RuntimePortalPlacementAuthority acceptedPortal, + ulong velocityAuthorityVersion, + in RuntimeSetPositionMoverPreparation preparation, + out RuntimeSetPositionCommand command) + { + ArgumentNullException.ThrowIfNull(record); + command = default; + if (!preparation.Setup.IsResolved + || preparation.Kind != acceptedKind + || preparation.Portal != acceptedPortal + || (canonicalSetupTableId == 0u + ? preparation.Setup.SetupTableId != 0u + || preparation.Setup.Collision is not null + : preparation.Setup.SetupTableId != canonicalSetupTableId + || preparation.Setup.Collision is null)) + { + return false; + } + + CreateObject.ServerPosition position = acceptedPosition; + Vector3 cellLocal = new( + position.PositionX, + position.PositionY, + position.PositionZ); + Vector3 world = new( + cellLocal.X + preparation.ShadowWorldOffsetX, + cellLocal.Y + preparation.ShadowWorldOffsetY, + cellLocal.Z); + Quaternion orientation = new( + position.RotationX, + position.RotationY, + position.RotationZ, + position.RotationW); + + float scale = record.Snapshot.Physics?.Scale + ?? record.Snapshot.ObjScale + ?? 1f; + FlatSetupCollision? setup = preparation.Setup.Collision; + ImmutableArray spheres = setup?.Spheres + ?? ImmutableArray.Empty; + // CPartArray::GetStepUpHeight/GetStepDownHeight are Setup properties, + // not sphere properties. An authored Setup with no collision spheres + // still supplies both values; only a genuinely absent Setup takes the + // retail dummy path with exact zero steps and no scale multiplication. + float stepUp = setup is not null ? setup.StepUpHeight * scale : 0f; + float stepDown = setup is not null ? setup.StepDownHeight * scale : 0f; + + EntityCollisionFlags collisionFlags = + EntityCollisionFlagsExt.FromPwdBitfield( + record.Snapshot.ObjectDescriptionFlags ?? 0u); + ObjectInfoState moverFlags = collisionFlags.ToMoverState(); + if (collisionFlags.HasFlag(EntityCollisionFlags.IsPlayer)) + moverFlags |= ObjectInfoState.IsPlayer; + + var request = new PhysicsSetPositionRequest( + world, + orientation, + position.LandblockId, + cellLocal, + spheres, + scale, + stepUp, + stepDown, + record.FinalPhysicsState, + moverFlags, + record.Key?.LocalEntityId ?? 0u, + preparation.PlacementClass, + preparation.Flags, + preparation.Line, + preparation.ScatterRadiusX, + preparation.ScatterRadiusY, + preparation.ScatterAttempts); + command = new RuntimeSetPositionCommand( + request, + preparation.Kind, + preparation.GameTime, + velocityAuthorityVersion, + preparation.ShadowWorldOffsetX, + preparation.ShadowWorldOffsetY, + preparation.Portal); + return true; + } +} diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index b0692f5a..70c04794 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Numerics; +using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime.Entities; @@ -31,11 +32,18 @@ internal enum RuntimeEntityPlacementStage CancelledAwaitingAcknowledgement, } +internal enum RuntimeEntityPlacementPreparationKind : byte +{ + LegacyDirect, + AuthoredMover, +} + internal readonly record struct RuntimeEntityPlacementToken( ulong SessionLifetimeVersion, RuntimeEntityKey Entity, ulong PositionAuthorityVersion, - ulong OperationId) + ulong OperationId, + RuntimeEntityPlacementPreparationKind PreparationKind) { internal bool IsValid => OperationId != 0UL && Entity.LocalEntityId != 0u @@ -132,14 +140,16 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot( int DeferredBucketOrderCount, int UnboundDeferredCellCount, int UnboundDeferredCellOrderCount, - int PreparedMoverCount) + int PreparedMoverCount, + int MoverPreparationAuthorityCount) { internal bool IndexesConsistent => LostDeadlineCount == LostDeadlineNodeCount && LostDeadlineCount == LostDeadlineIndexCount && ExpiredLostCellCount == ExpiredLostCellIndexCount && DeferredBucketCount == DeferredBucketOrderCount - && UnboundDeferredCellCount == UnboundDeferredCellOrderCount; + && UnboundDeferredCellCount == UnboundDeferredCellOrderCount + && MoverPreparationAuthorityCount <= ActiveOperationCount; internal bool IsConverged => ActiveOperationCount == 0 && AwaitingPreparationCount == 0 @@ -154,7 +164,8 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot( && DeferredBucketOrderCount == 0 && UnboundDeferredCellCount == 0 && UnboundDeferredCellOrderCount == 0 - && PreparedMoverCount == 0; + && PreparedMoverCount == 0 + && MoverPreparationAuthorityCount == 0; } /// @@ -176,6 +187,20 @@ internal sealed class RuntimeSetPositionState : IDisposable uint CellId, ulong CollisionGeneration); + private readonly record struct MoverPreparationAuthority( + ulong OperationId, + CreateObject.ServerPosition AcceptedPosition, + uint SetupTableId, + ulong PositionAuthorityVersion, + ulong VelocityAuthorityVersion, + ulong StateAuthorityVersion, + ulong VectorAuthorityVersion, + ulong ObjDescAuthorityVersion, + ulong CreateIntegrationVersion, + ulong PhysicsStateMutationVersion, + bool Prepared, + RuntimeSetPositionCommand PreparedCommand); + private sealed class Operation { internal required RuntimeEntityRecord Record { get; init; } @@ -249,6 +274,8 @@ internal sealed class RuntimeSetPositionState : IDisposable _lostDeadlineNodeIndex = []; private readonly Dictionary _preparedMovers = []; + private readonly Dictionary + _moverPreparationAuthorities = []; private readonly LinkedList _expiredLostCells = []; private readonly Dictionary> _expiredLostCellNodes = []; @@ -299,7 +326,8 @@ internal sealed class RuntimeSetPositionState : IDisposable _deferredBucketOrder.Count, _unboundDeferredByCell.Count, _unboundDeferredCellOrder.Count, - _preparedMovers.Count); + _preparedMovers.Count, + _moverPreparationAuthorities.Count); } internal int PendingProjectionCount => _pendingProjection.Count; @@ -345,35 +373,71 @@ internal sealed class RuntimeSetPositionState : IDisposable { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(record); - RuntimeEntityPlacementToken token = BeginAcceptedPlacement( + RuntimeEntityPlacementToken token = BeginAcceptedPlacementCore( record, expectedPositionAuthorityVersion, command.Kind, - command.Portal); + command.Portal, + captureMoverPreparationAuthority: false); if (!token.IsValid) { return Rejected(command.Physics); } - return SubmitPreparedPlacement(token, command); + return SubmitPreparedPlacementCore( + token, + command, + allowDirectUnsealed: true); } internal RuntimeEntityPlacementToken BeginAcceptedPlacement( RuntimeEntityRecord record, ulong expectedPositionAuthorityVersion, RuntimeSetPositionOperationKind kind, - RuntimePortalPlacementAuthority portal = default) + RuntimePortalPlacementAuthority portal = default) => + BeginAcceptedPlacementCore( + record, + expectedPositionAuthorityVersion, + kind, + portal, + captureMoverPreparationAuthority: false); + + internal RuntimeEntityPlacementToken BeginAuthoredPlacement( + RuntimeEntityRecord record, + ulong expectedPositionAuthorityVersion, + RuntimeSetPositionOperationKind kind, + RuntimePortalPlacementAuthority portal = default) => + BeginAcceptedPlacementCore( + record, + expectedPositionAuthorityVersion, + kind, + portal, + captureMoverPreparationAuthority: true); + + private RuntimeEntityPlacementToken BeginAcceptedPlacementCore( + RuntimeEntityRecord record, + ulong expectedPositionAuthorityVersion, + RuntimeSetPositionOperationKind kind, + RuntimePortalPlacementAuthority portal, + bool captureMoverPreparationAuthority) { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(record); + CreateObject.ServerPosition? acceptedPosition = + record.Snapshot.Physics?.Position ?? record.Snapshot.Position; if (record.Key is not { } key || !_entities.IsCurrent(record) || record.PositionAuthorityVersion != expectedPositionAuthorityVersion + || (captureMoverPreparationAuthority + && acceptedPosition is null) || !(portal.IsEmpty || (portal.IsValid && kind is RuntimeSetPositionOperationKind - .LocalAuthoritative))) + .LocalAuthoritative + && acceptedPosition is { } portalPosition + && portal.Projection.DestinationCell + == portalPosition.LandblockId))) { return default; } @@ -382,7 +446,10 @@ internal sealed class RuntimeSetPositionState : IDisposable _entities.SessionLifetimeVersion, key, expectedPositionAuthorityVersion, - checked(++_nextOperationId)); + checked(++_nextOperationId), + captureMoverPreparationAuthority + ? RuntimeEntityPlacementPreparationKind.AuthoredMover + : RuntimeEntityPlacementPreparationKind.LegacyDirect); var replacement = new Operation { Record = record, @@ -442,20 +509,95 @@ internal sealed class RuntimeSetPositionState : IDisposable displaced.CollisionGeneration; } _operations[key] = replacement; + if (captureMoverPreparationAuthority) + { + _moverPreparationAuthorities[key] = CapturePreparationAuthority( + replacement, + acceptedPosition!.Value, + prepared: false); + } if (discard is { } cancelled) PublishPlacement(cancelled); return IsCurrent(replacement) ? token : default; } + internal RuntimeSetPositionMoverPreparationStatus PrepareMover( + in RuntimeEntityPlacementToken token, + in RuntimeSetPositionMoverPreparation preparation, + out RuntimeSetPositionCommand command) + { + EnsureNotDisposed(); + command = default; + if (!token.IsValid + || !_operations.TryGetValue(token.Entity, out Operation? operation) + || operation.Token != token + || operation.Stage + is not RuntimeEntityPlacementStage.AwaitingPreparation + || !IsCurrent(operation) + || !_moverPreparationAuthorities.TryGetValue( + token.Entity, + out MoverPreparationAuthority authority) + || authority.OperationId != token.OperationId + || !IsPreparationAuthorityCurrent(operation, authority)) + { + return RuntimeSetPositionMoverPreparationStatus.RejectedAuthority; + } + + if (!preparation.Setup.IsResolved) + { + return RuntimeSetPositionMoverPreparationStatus + .RetrySetupUnavailable; + } + + if (!RuntimeSetPositionMoverPreparer.TryBuild( + operation.Record, + authority.AcceptedPosition, + authority.SetupTableId, + operation.Kind, + operation.Portal, + authority.VelocityAuthorityVersion, + preparation, + out command) + || !IsStructurallyValid(command.Physics)) + { + command = default; + return RuntimeSetPositionMoverPreparationStatus.InvalidData; + } + + _moverPreparationAuthorities[token.Entity] = authority with + { + Prepared = true, + PreparedCommand = command, + }; + + return RuntimeSetPositionMoverPreparationStatus.Prepared; + } + internal RuntimeSetPositionOutcome SubmitPreparedPlacement( in RuntimeEntityPlacementToken token, - in RuntimeSetPositionCommand command) + in RuntimeSetPositionCommand command) => + SubmitPreparedPlacementCore( + token, + command, + allowDirectUnsealed: token.PreparationKind + is RuntimeEntityPlacementPreparationKind.LegacyDirect); + + private RuntimeSetPositionOutcome SubmitPreparedPlacementCore( + in RuntimeEntityPlacementToken token, + in RuntimeSetPositionCommand command, + bool allowDirectUnsealed) { EnsureNotDisposed(); Operation? operation = null; bool ownsToken = token.IsValid && _operations.TryGetValue(token.Entity, out operation) && operation.Token == token; + MoverPreparationAuthority exactAuthority = default; + bool hasPreparationAuthority = token.IsValid + && _moverPreparationAuthorities.TryGetValue( + token.Entity, + out exactAuthority) + && exactAuthority.OperationId == token.OperationId; if (!ownsToken || operation is null || operation.Stage @@ -465,6 +607,13 @@ internal sealed class RuntimeSetPositionState : IDisposable || !double.IsFinite(command.GameTime) || command.Kind != operation.Kind || command.Portal != operation.Portal + || (hasPreparationAuthority + ? !exactAuthority.Prepared + || exactAuthority.PreparedCommand != command + || !IsPreparationAuthorityCurrent( + operation, + exactAuthority) + : !allowDirectUnsealed) || (command.ExpectedVelocityAuthorityVersion != 0UL && operation.Record.VelocityAuthorityVersion != command.ExpectedVelocityAuthorityVersion)) @@ -511,6 +660,13 @@ internal sealed class RuntimeSetPositionState : IDisposable }; var canonicalCommand = command with { Physics = canonicalRequest }; operation.Command = canonicalCommand; + if (hasPreparationAuthority) + { + _moverPreparationAuthorities[token.Entity] = exactAuthority with + { + PreparedCommand = canonicalCommand, + }; + } if (operation.InheritedLostDeadline && !operation.WithdrawalAcknowledged @@ -657,11 +813,13 @@ internal sealed class RuntimeSetPositionState : IDisposable if (pending.Kind is RuntimePlacementProjectionKind.Place) { operation.Stage = RuntimeEntityPlacementStage.AwaitingCommitAcknowledgement; + _moverPreparationAuthorities.Remove(operation.Key); return _operations.Remove(operation.Key); } if (!operation.WakeableLostCell && !operation.InheritedLostDeadline) { + _moverPreparationAuthorities.Remove(operation.Key); return _operations.Remove(operation.Key); } @@ -934,7 +1092,8 @@ internal sealed class RuntimeSetPositionState : IDisposable _entities.SessionLifetimeVersion, key, record.PositionAuthorityVersion, - checked(++_nextOperationId)), + checked(++_nextOperationId), + RuntimeEntityPlacementPreparationKind.AuthoredMover), Key = key, PositionAuthorityVersion = record.PositionAuthorityVersion, SessionLifetimeVersion = _entities.SessionLifetimeVersion, @@ -953,6 +1112,14 @@ internal sealed class RuntimeSetPositionState : IDisposable RequiresPreparation = !hasPrepared, }; _operations.Add(key, operation); + _moverPreparationAuthorities[key] = CapturePreparationAuthority( + operation, + ServerPositionFrom( + cellId, + body.CellPosition.Frame.Origin, + body.Orientation), + prepared: hasPrepared, + command); RuntimeSetPositionOutcome parked = ParkDeferred( operation, result, @@ -1159,6 +1326,7 @@ internal sealed class RuntimeSetPositionState : IDisposable _lostDeadlineNodes.Clear(); _lostDeadlineNodeIndex.Clear(); _preparedMovers.Clear(); + _moverPreparationAuthorities.Clear(); _pendingProjection.Clear(); _expiredLostCells.Clear(); _expiredLostCellNodes.Clear(); @@ -1215,6 +1383,22 @@ internal sealed class RuntimeSetPositionState : IDisposable CurrentCellId = null, }, }; + if (_moverPreparationAuthorities.ContainsKey(operation.Key)) + { + RebindPreparedCommand(operation); + } + else + { + _moverPreparationAuthorities[operation.Key] = + CapturePreparationAuthority( + operation, + ServerPositionFrom( + result.CellId, + result.CellLocalPosition, + result.Orientation), + prepared: true, + operation.Command); + } IndexDeferred(operation); operation.Stage = operation.RequiresPreparation ? RuntimeEntityPlacementStage.AwaitingPreparation @@ -1243,12 +1427,16 @@ internal sealed class RuntimeSetPositionState : IDisposable return; } + if (!IsDeferredWakePreparationCurrent(operation)) + return; + UnindexDeferred(operation); operation.Command = operation.Command with { GameTime = _physics.PlacementSimulationTime( operation.Command.GameTime), }; + RebindPreparedCommand(operation); PhysicsSetPositionResult result = IsStructurallyValid( operation.Command.Physics) ? _physics.Engine.SetPosition( @@ -1626,7 +1814,8 @@ internal sealed class RuntimeSetPositionState : IDisposable _entities.SessionLifetimeVersion, key, record.PositionAuthorityVersion, - checked(++_nextOperationId)), + checked(++_nextOperationId), + RuntimeEntityPlacementPreparationKind.LegacyDirect), Key = key, PositionAuthorityVersion = record.PositionAuthorityVersion, SessionLifetimeVersion = _entities.SessionLifetimeVersion, @@ -1673,6 +1862,104 @@ internal sealed class RuntimeSetPositionState : IDisposable || operation.Record.VelocityAuthorityVersion == operation.SourceVelocityAuthorityVersion; + private static MoverPreparationAuthority CapturePreparationAuthority( + Operation operation, + in CreateObject.ServerPosition acceptedPosition, + bool prepared, + in RuntimeSetPositionCommand preparedCommand = default) => new( + operation.Token.OperationId, + acceptedPosition, + CanonicalSetupTableId(operation.Record), + operation.Record.PositionAuthorityVersion, + operation.Record.VelocityAuthorityVersion, + operation.Record.StateAuthorityVersion, + operation.Record.VectorAuthorityVersion, + operation.Record.ObjDescAuthorityVersion, + operation.Record.CreateIntegrationVersion, + operation.Record.PhysicsStateMutationVersion, + prepared, + preparedCommand); + + private static uint CanonicalSetupTableId(RuntimeEntityRecord record) => + record.Snapshot.Physics?.SetupTableId + ?? record.Snapshot.SetupTableId + ?? 0u; + + private static CreateObject.ServerPosition ServerPositionFrom( + uint cellId, + Vector3 cellLocal, + Quaternion orientation) => new( + cellId, + cellLocal.X, + cellLocal.Y, + cellLocal.Z, + orientation.W, + orientation.X, + orientation.Y, + orientation.Z); + + private static bool IsPreparationAuthorityCurrent( + Operation operation, + in MoverPreparationAuthority authority) => + operation.Record.PositionAuthorityVersion + == authority.PositionAuthorityVersion + && operation.Record.VelocityAuthorityVersion + == authority.VelocityAuthorityVersion + && operation.Record.StateAuthorityVersion + == authority.StateAuthorityVersion + && operation.Record.VectorAuthorityVersion + == authority.VectorAuthorityVersion + && operation.Record.ObjDescAuthorityVersion + == authority.ObjDescAuthorityVersion + && operation.Record.CreateIntegrationVersion + == authority.CreateIntegrationVersion + && operation.Record.PhysicsStateMutationVersion + == authority.PhysicsStateMutationVersion; + + private void RebindPreparedCommand(Operation operation) + { + if (_moverPreparationAuthorities.TryGetValue( + operation.Key, + out MoverPreparationAuthority authority) + && authority.OperationId == operation.Token.OperationId + && authority.Prepared) + { + _moverPreparationAuthorities[operation.Key] = authority with + { + PreparedCommand = operation.Command, + }; + } + } + + private bool IsDeferredWakePreparationCurrent(Operation operation) + { + if (_moverPreparationAuthorities.TryGetValue( + operation.Key, + out MoverPreparationAuthority authority) + && authority.OperationId == operation.Token.OperationId + && authority.Prepared + && authority.PreparedCommand == operation.Command + && IsPreparationAuthorityCurrent(operation, authority)) + { + return true; + } + + operation.SourceVelocityAuthorityVersion = + operation.Record.VelocityAuthorityVersion; + operation.RequiresPreparation = true; + operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation; + operation.PreparedCommandAwaitingWithdrawalAck = null; + _moverPreparationAuthorities[operation.Key] = + CapturePreparationAuthority( + operation, + ServerPositionFrom( + operation.ExactCellId, + operation.Result.CellLocalPosition, + operation.Result.Orientation), + prepared: false); + return false; + } + internal void PublishCancellation( in RuntimePlacementCancellationReceipt receipt) { @@ -1712,6 +1999,7 @@ internal sealed class RuntimeSetPositionState : IDisposable CancelExactLostKey(key); if (!_operations.Remove(key, out Operation? operation)) return false; + _moverPreparationAuthorities.Remove(key); UnindexDeferred(operation); if (!preserveLostFamily && cancelLostFamily) CancelLostFamilyDeadlines(operation); diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs new file mode 100644 index 00000000..808f699e --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs @@ -0,0 +1,715 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimeSetPositionMoverPreparationTests +{ + private const uint Cell = 0xA9B40021u; + private const uint SetupId = 0x02000001u; + + [Fact] + public void PreparedMoverPreservesCompleteAuthoredRetailInput() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + float half = MathF.Sqrt(0.5f); + var acceptedPosition = new CreateObject.ServerPosition( + Cell, + 12.5f, + 23.5f, + 34.5f, + half, + 0f, + half, + 0f); + RuntimeEntityRecord record = CreateRecord( + lifetime, + objScale: 3f, + physicsScale: 2f, + objectDescriptionFlags: 0x8u | 0x20u | 0x200000u + | 0x2000000u | 0x100000u | 0x400000u, + position: acceptedPosition); + const PhysicsStateFlags state = PhysicsStateFlags.Gravity + | PhysicsStateFlags.EdgeSlide + | PhysicsStateFlags.PathClipped; + lifetime.Entities.SetFinalPhysicsState(record, state); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + ImmutableArray spheres = + [ + new(new Vector3(1f, 2f, 3f), 0.1f), + new(new Vector3(4f, 5f, 6f), 0.2f), + new(new Vector3(7f, 8f, 9f), 0.3f), + ]; + FlatSetupCollision setup = Setup(spheres, 0.25f, -0.5f); + var portal = default(RuntimePortalPlacementAuthority); + var input = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.Resolved(SetupId, setup), + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 42.25d, + PhysicsPlacementClass.Corpse, + PhysicsSetPositionFlags.Line | PhysicsSetPositionFlags.Scatter, + Line: new Vector3(3f, 4f, 5f), + ScatterRadiusX: 6f, + ScatterRadiusY: 7f, + ScatterAttempts: 8u, + ShadowWorldOffsetX: 9f, + ShadowWorldOffsetY: 10f, + Portal: portal); + + RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics + .SetPosition.PrepareMover(token, input, out var command); + + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status); + Assert.Equal(spheres, command.Physics.Spheres); + Assert.Equal(2f, command.Physics.Scale); + Assert.Equal(0.5f, command.Physics.StepUpHeight); + Assert.Equal(-1f, command.Physics.StepDownHeight); + Assert.Equal(Cell, command.Physics.CellId); + Assert.Equal(new Vector3(12.5f, 23.5f, 34.5f), + command.Physics.CellLocalPosition); + Assert.Equal(new Vector3( + 12.5f + 9f, + 23.5f + 10f, + 34.5f), command.Physics.Position); + Assert.Equal(new Quaternion(0f, half, 0f, half), + command.Physics.Orientation); + Assert.Equal(state, command.Physics.MoverPhysicsState); + Assert.Equal( + ObjectInfoState.IsPlayer + | ObjectInfoState.IsPK + | ObjectInfoState.IsPKLite + | ObjectInfoState.IsImpenetrable + | ObjectInfoState.CanBypassMoveRestrictions, + command.Physics.MoverFlags); + Assert.False(command.Physics.MoverFlags.HasFlag( + ObjectInfoState.EdgeSlide)); + Assert.False(command.Physics.MoverFlags.HasFlag( + ObjectInfoState.PathClipped)); + Assert.False(command.Physics.MoverFlags.HasFlag( + ObjectInfoState.FreeRotate)); + Assert.False(command.Physics.MoverFlags.HasFlag( + ObjectInfoState.Contact)); + Assert.False(command.Physics.MoverFlags.HasFlag( + ObjectInfoState.OnWalkable)); + Assert.Equal(record.Key!.Value.LocalEntityId, + command.Physics.MovingEntityId); + Assert.Equal(PhysicsPlacementClass.Corpse, + command.Physics.PlacementClass); + Assert.Equal(input.Flags, command.Physics.Flags); + Assert.Equal(input.Line, command.Physics.Line); + Assert.Equal(6f, command.Physics.ScatterRadiusX); + Assert.Equal(7f, command.Physics.ScatterRadiusY); + Assert.Equal(8u, command.Physics.ScatterAttempts); + Assert.Equal(42.25d, command.GameTime); + Assert.Equal(9f, command.ShadowWorldOffsetX); + Assert.Equal(10f, command.ShadowWorldOffsetY); + Assert.Equal(portal, command.Portal); + Assert.Equal(record.VelocityAuthorityVersion, + command.ExpectedVelocityAuthorityVersion); + } + + [Fact] + public void LocalPortalKindAndAuthorityArePreservedWithoutInference() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime); + var portal = new RuntimePortalPlacementAuthority( + Present: true, + RevealGeneration: 17, + TeleportSequence: 3, + new RuntimeWorldHostProjectionToken(17, Cell)); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative, + portal); + var input = Input(ResolvedEmptySetup()) with + { + Kind = RuntimeSetPositionOperationKind.LocalAuthoritative, + Portal = portal, + }; + + RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics + .SetPosition.PrepareMover(token, input, out var command); + + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status); + Assert.Equal(RuntimeSetPositionOperationKind.LocalAuthoritative, + command.Kind); + Assert.Equal(portal, command.Portal); + } + + [Fact] + public void UnavailableSetupRetriesWithoutInventingDummyWhileAbsentAndEmptyResolve() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime, setupTableId: null); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + + RuntimeSetPositionMoverPreparationStatus unavailable = lifetime.Physics + .SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.Unavailable), + out RuntimeSetPositionCommand unavailableCommand); + + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable, + unavailable); + Assert.Equal(default, unavailableCommand); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.ResolvedAbsent), + out RuntimeSetPositionCommand absent)); + Assert.Empty(absent.Physics.Spheres); + + record = CreateRecord(lifetime, guid: 0x70002002u); + token = Begin(lifetime, record); + FlatSetupCollision authoredEmpty = Setup( + ImmutableArray.Empty, + 0f, + 0f); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.Resolved( + SetupId, + authoredEmpty)), + out RuntimeSetPositionCommand empty)); + Assert.Empty(empty.Physics.Spheres); + } + + [Fact] + public void KnownSetupMustResolveTheExactCanonicalDid() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.Unavailable), + out _)); + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.InvalidData, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.ResolvedAbsent), + out _)); + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.InvalidData, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.Resolved( + SetupId + 1u, + Setup(ImmutableArray.Empty, 0f, 0f))), + out _)); + + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(ResolvedEmptySetup()), + out _)); + } + + [Fact] + public void AbsentSetupRejectsInventedCollisionPayload() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord( + lifetime, + setupTableId: null); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.InvalidData, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.Resolved( + SetupId, + Setup(ImmutableArray.Empty, 0f, 0f))), + out _)); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.ResolvedAbsent), + out _)); + } + + [Fact] + public void AuthoredEmptySetupStillSuppliesScaledStepHeights() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord( + lifetime, + objScale: 2f); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + FlatSetupCollision authoredEmpty = Setup( + ImmutableArray.Empty, + 0.25f, + -0.5f); + + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.Resolved( + SetupId, + authoredEmpty)), + out RuntimeSetPositionCommand command)); + Assert.Empty(command.Physics.Spheres); + Assert.Equal(0.5f, command.Physics.StepUpHeight); + Assert.Equal(-1f, command.Physics.StepDownHeight); + } + + [Fact] + public void AuthoredTokenRejectsManualUnsealedSubmission() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime); + AttachBody(lifetime, record); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + RuntimeSetPositionCommand invented = new( + new PhysicsSetPositionRequest( + new Vector3(1f, 2f, 3f), + Quaternion.Identity, + Cell, + new Vector3(1f, 2f, 3f), + ImmutableArray.Empty, + 1f, + 0f, + 0f, + record.FinalPhysicsState, + ObjectInfoState.None, + record.Key!.Value.LocalEntityId), + RuntimeSetPositionOperationKind.RemoteAuthoritative, + 1d, + record.VelocityAuthorityVersion); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(token, invented); + + Assert.Equal(RuntimeSetPositionStatus.Rejected, outcome.Status); + Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + } + + [Fact] + public void RepreparationReplacesTheExactSealedCommand() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime); + AttachBody(lifetime, record); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(ResolvedEmptySetup()) with { GameTime = 1d }, + out RuntimeSetPositionCommand first)); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(ResolvedEmptySetup()) with { GameTime = 2d }, + out RuntimeSetPositionCommand second)); + Assert.NotEqual(first, second); + + RuntimeSetPositionOutcome stale = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(token, first); + Assert.Equal(RuntimeSetPositionStatus.Rejected, stale.Status); + + RuntimeSetPositionOutcome current = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(token, second); + Assert.NotEqual(RuntimeSetPositionStatus.Rejected, current.Status); + } + + [Theory] + [InlineData(null, null, 1f)] + [InlineData(0f, null, 0f)] + [InlineData(-2f, null, -2f)] + [InlineData(3f, 0f, 0f)] + [InlineData(3f, -4f, -4f)] + public void ScalePreservesAbsentZeroNegativeAndPhysicsPrecedence( + float? objScale, + float? physicsScale, + float expected) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord( + lifetime, + objScale, + physicsScale); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + + RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics + .SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.Resolved(SetupId, Setup( + [new FlatCollisionSphere(Vector3.Zero, 0.5f)], + 0.25f, + -0.5f))), + out RuntimeSetPositionCommand command); + + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status); + Assert.Equal(expected, command.Physics.Scale); + Assert.Equal(0.25f * expected, command.Physics.StepUpHeight); + Assert.Equal(-0.5f * expected, command.Physics.StepDownHeight); + } + + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void NonFiniteScaleRejectsWhenAuthoredSphereConsumesIt(float scale) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime, scale, null); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + + RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics + .SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.Resolved( + SetupId, + Setup( + [new FlatCollisionSphere(Vector3.Zero, 0.5f)], + 0f, + 0f))), + out RuntimeSetPositionCommand command); + + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.InvalidData, status); + Assert.Equal(default, command); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + } + + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void NonFiniteScaleIsNotConsumedByResolvedAbsentDummy(float scale) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord( + lifetime, + objScale: scale, + setupTableId: null); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + + RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics + .SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.ResolvedAbsent), + out RuntimeSetPositionCommand command); + + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status); + Assert.Equal(scale, command.Physics.Scale); + Assert.Equal(0f, command.Physics.StepUpHeight); + Assert.Equal(0f, command.Physics.StepDownHeight); + Assert.Empty(command.Physics.Spheres); + } + + [Fact] + public void PreparationDoesNotMutateCanonicalOrPresentationState() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime); + var body = new PhysicsBody + { + Position = new Vector3(1f, 2f, 3f), + Orientation = Quaternion.Identity, + State = PhysicsStateFlags.Gravity, + InWorld = true, + }; + body.SnapToCell(0xA8B40001u, body.Position, body.Position); + lifetime.Entities.SetPhysicsBody(record, body); + lifetime.Entities.SetFullCell(record, 0xA8B40001u, 0xA8B4FFFFu); + Vector3 priorPosition = body.Position; + uint priorCell = record.FullCellId; + ulong priorSpatial = record.SpatialAuthorityVersion; + RuntimeEntityPlacementToken token = Begin(lifetime, record); + + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(ResolvedEmptySetup()), + out _)); + + Assert.Same(body, record.PhysicsBody); + Assert.Equal(priorPosition, body.Position); + Assert.Equal(priorCell, record.FullCellId); + Assert.Equal(priorSpatial, record.SpatialAuthorityVersion); + Assert.True(body.InWorld); + Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + Assert.False(lifetime.Physics.SetPosition + .TryGetPreparedMoverSphereCount(record, out _)); + } + + [Theory] + [InlineData(AuthorityReplacement.Position)] + [InlineData(AuthorityReplacement.Velocity)] + [InlineData(AuthorityReplacement.Vector)] + [InlineData(AuthorityReplacement.State)] + [InlineData(AuthorityReplacement.ObjectDescription)] + [InlineData(AuthorityReplacement.Create)] + public void AnyAuthorityReplacementRejectsPreparedSubmissionWithoutMutation( + AuthorityReplacement replacement) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime); + var body = new PhysicsBody + { + Position = new Vector3(1f, 2f, 3f), + Orientation = Quaternion.Identity, + State = record.FinalPhysicsState, + InWorld = true, + }; + body.SnapToCell(0xA8B40001u, body.Position, body.Position); + lifetime.Entities.SetPhysicsBody(record, body); + lifetime.Entities.SetFullCell(record, 0xA8B40001u, 0xA8B4FFFFu); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + Input(ResolvedEmptySetup()), + out RuntimeSetPositionCommand command)); + Vector3 priorPosition = body.Position; + uint priorCell = record.FullCellId; + + ReplaceAuthority(lifetime, record, replacement); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(token, command); + + Assert.Equal(RuntimeSetPositionStatus.Rejected, outcome.Status); + Assert.Equal(priorPosition, body.Position); + Assert.Equal(priorCell, record.FullCellId); + Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount); + Assert.False(lifetime.Physics.SetPosition + .TryGetPreparedMoverSphereCount(record, out _)); + + RuntimeEntityPlacementToken replacementToken = Begin(lifetime, record); + Assert.True(replacementToken.IsValid); + Assert.NotEqual(token, replacementToken); + } + + [Fact] + public void ThirdAuthoredSphereIsPreservedButNotConsumedByCoreValidation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + FlatSetupCollision setup = Setup( + [ + new FlatCollisionSphere(Vector3.Zero, 0.1f), + new FlatCollisionSphere(Vector3.UnitZ, 0.2f), + new FlatCollisionSphere( + new Vector3(float.NaN, 0f, 0f), + 0.3f), + ], 0f, 0f); + + RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics + .SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.Resolved(SetupId, setup)), + out _); + + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status); + } + + [Fact] + public void SessionReplacementInvalidatesExactTokenAndClearsPreparationAuthority() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime); + RuntimeEntityPlacementToken token = Begin(lifetime, record); + + _ = lifetime.BeginSessionClear(); + RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics + .SetPosition.PrepareMover( + token, + Input(RuntimeSetPositionMoverSetup.ResolvedAbsent), + out RuntimeSetPositionCommand command); + + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.RejectedAuthority, + status); + Assert.Equal(default, command); + RuntimeSetPositionOwnershipSnapshot ownership = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.Equal(0, ownership.MoverPreparationAuthorityCount); + Assert.Equal(0, ownership.ActiveOperationCount); + } + + private static RuntimeEntityPlacementToken Begin( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record) => lifetime.Physics.SetPosition + .BeginAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + + private static RuntimeSetPositionMoverPreparation Input( + RuntimeSetPositionMoverSetup setup) => new( + setup, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement); + + private static RuntimeSetPositionMoverSetup ResolvedEmptySetup() => + RuntimeSetPositionMoverSetup.Resolved( + SetupId, + Setup(ImmutableArray.Empty, 0f, 0f)); + + private static FlatSetupCollision Setup( + ImmutableArray spheres, + float stepUp, + float stepDown) => new( + ImmutableArray.Empty, + spheres, + height: 0f, + radius: 0f, + stepUp, + stepDown); + + private static RuntimeEntityRecord CreateRecord( + RuntimeEntityObjectLifetime lifetime, + float? objScale = null, + float? physicsScale = null, + uint? objectDescriptionFlags = null, + uint guid = 0x70002001u, + uint? setupTableId = SetupId, + CreateObject.ServerPosition? position = null) + { + CreateObject.ServerPosition acceptedPosition = position ?? new( + Cell, + 1f, + 2f, + 3f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Gravity, + Position: acceptedPosition, + Movement: null, + AnimationFrame: null, + SetupTableId: setupTableId, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: physicsScale, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + var spawn = new WorldSession.EntitySpawn( + Guid: guid, + Position: acceptedPosition, + SetupTableId: setupTableId, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: objScale, + Name: "mover-preparation-fixture", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.Gravity, + ObjectDescriptionFlags: objectDescriptionFlags, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + return lifetime.RegisterEntity(spawn).Canonical!; + } + + private static void AttachBody( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record) + { + var body = new PhysicsBody + { + Position = new Vector3(1f, 2f, 3f), + Orientation = Quaternion.Identity, + State = record.FinalPhysicsState, + InWorld = true, + }; + body.SnapToCell(Cell, body.Position, body.Position); + lifetime.Entities.SetPhysicsBody(record, body); + lifetime.Entities.SetFullCell(record, Cell, Cell & 0xFFFF0000u); + } + + private static void ReplaceAuthority( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record, + AuthorityReplacement replacement) + { + switch (replacement) + { + case AuthorityReplacement.Position: + lifetime.Entities.AdvancePositionAuthority(record); + break; + case AuthorityReplacement.Velocity: + lifetime.Entities.AdvanceMovementAuthority(record); + break; + case AuthorityReplacement.Vector: + lifetime.Entities.AdvanceVectorAuthority(record); + break; + case AuthorityReplacement.State: + _ = lifetime.Entities.ApplyRawPhysicsState( + record, + (uint)PhysicsStateFlags.Frozen); + break; + case AuthorityReplacement.ObjectDescription: + lifetime.Entities.AdvanceObjDescAuthority(record); + break; + case AuthorityReplacement.Create: + lifetime.Entities.AdvanceCreateAuthority(record); + break; + default: + throw new ArgumentOutOfRangeException(nameof(replacement)); + } + } + + public enum AuthorityReplacement + { + Position, + Velocity, + Vector, + State, + ObjectDescription, + Create, + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index 6a2b14a3..92368bdd 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -57,7 +57,17 @@ public sealed class RuntimeSetPositionStateTests record.PositionAuthorityVersion, RuntimeSetPositionOperationKind.LocalAuthoritative, invalidPortal); - Assert.True(portalToken.IsValid); + Assert.False(portalToken.IsValid); + + var matchingPortal = invalidPortal with + { + Projection = new RuntimeWorldHostProjectionToken(7, SourceCell), + }; + Assert.True(lifetime.Physics.SetPosition.BeginAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative, + matchingPortal).IsValid); } [Fact] @@ -445,6 +455,146 @@ public sealed class RuntimeSetPositionStateTests placed.Token)); } + [Fact] + public void LegacyDeferredWakeCannotBypassNewAuthoredSealAfterAuthorityStales() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000112Bu, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement( + observer); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + // The direct legacy command was valid when parked, but any accepted + // authority replacement requires a new authored mover preparation. + lifetime.Entities.AdvanceObjDescAuthority(record); + AddFlatLandblock(engine, DestinationLandblock, 192f); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + + Assert.False(body.InWorld); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken token)); + Assert.Equal(RuntimeEntityPlacementPreparationKind.LegacyDirect, + token.PreparationKind); + RuntimeSetPositionOutcome bypass = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(token, Command(CrossLandblockRequest())); + Assert.Equal(RuntimeSetPositionStatus.Rejected, bypass.Status); + Assert.False(body.InWorld); + + RuntimeSetPositionCommand prepared = PrepareAuthoredCommand( + lifetime, + token, + [new FlatCollisionSphere(Vector3.Zero, 0.4f)]); + RuntimeSetPositionOutcome resumed = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(token, prepared); + Assert.Equal(PhysicsResidenceDisposition.Committed, resumed.Residence); + RuntimePlacementProjectionSnapshot placed = observer.Deltas[^1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.True(body.InWorld); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + [Theory] + [InlineData(DeferredPhysicsStateMutation.ChildNoDraw)] + [InlineData(DeferredPhysicsStateMutation.StopMissile)] + [InlineData(DeferredPhysicsStateMutation.DirectFinalState)] + public void DeferredWakeRequiresRepreparationAfterFinalPhysicsStateMutation( + DeferredPhysicsStateMutation mutation) + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000112Cu, 1); + PhysicsStateFlags initial = PhysicsStateFlags.Gravity; + if (mutation is DeferredPhysicsStateMutation.StopMissile) + { + initial |= PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + } + PhysicsBody body = AttachBody(lifetime, record, SourceCell, initial); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + ulong priorMutation = record.PhysicsStateMutationVersion; + + switch (mutation) + { + case DeferredPhysicsStateMutation.ChildNoDraw: + lifetime.Entities.SetChildNoDraw(record, noDraw: true); + break; + case DeferredPhysicsStateMutation.StopMissile: + Assert.True(lifetime.Entities.StopMissileAfterCollision( + record, + requireCurrentMissile: true)); + break; + case DeferredPhysicsStateMutation.DirectFinalState: + lifetime.Entities.SetFinalPhysicsState( + record, + record.FinalPhysicsState | PhysicsStateFlags.Frozen); + break; + default: + throw new ArgumentOutOfRangeException(nameof(mutation)); + } + Assert.Equal(priorMutation + 1UL, + record.PhysicsStateMutationVersion); + if (mutation is DeferredPhysicsStateMutation.DirectFinalState) + Assert.NotEqual(record.FinalPhysicsState, body.State); + else + Assert.Equal(record.FinalPhysicsState, body.State); + + AddFlatLandblock(engine, DestinationLandblock, 192f); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + + Assert.False(body.InWorld); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out _)); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + } + + [Fact] + public void DirectFinalPhysicsStateMutationAdvancesOnlyOnChangeAndLeavesBodyToItsOwner() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000112Du, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + PhysicsStateFlags original = record.FinalPhysicsState; + ulong version = record.PhysicsStateMutationVersion; + + lifetime.Entities.SetFinalPhysicsState(record, original); + Assert.Equal(version, record.PhysicsStateMutationVersion); + Assert.Equal(original, body.State); + + PhysicsStateFlags changed = original | PhysicsStateFlags.Frozen; + lifetime.Entities.SetFinalPhysicsState(record, changed); + Assert.Equal(version + 1UL, record.PhysicsStateMutationVersion); + Assert.Equal(changed, record.FinalPhysicsState); + Assert.Equal(original, body.State); + + lifetime.Entities.SetFinalPhysicsState(record, changed); + Assert.Equal(version + 1UL, record.PhysicsStateMutationVersion); + Assert.Equal(original, body.State); + } + [Fact] public void RejectedPreparationKeepsValidatedMoverAndExactTokenRetryable() { @@ -1943,15 +2093,12 @@ public sealed class RuntimeSetPositionStateTests new FlatCollisionSphere(new Vector3(0.4f, 0f, 0f), 0.35f), new FlatCollisionSphere(new Vector3(-0.3f, 0f, 0.5f), 0.2f), ]; + RuntimeSetPositionCommand preparedCommand = PrepareAuthoredCommand( + lifetime, + token, + authored); RuntimeSetPositionOutcome supplied = lifetime.Physics.SetPosition - .SubmitPreparedPlacement( - token, - Command(Request( - landblock | 0x0101u, - new Vector3(999f, 999f, 999f)) with - { - Spheres = authored, - })); + .SubmitPreparedPlacement(token, preparedCommand); Assert.Equal(RuntimeSetPositionStatus.DeferredCell, supplied.Status); Assert.Equal(0, lifetime.Physics.CaptureOwnership() @@ -1998,21 +2145,29 @@ public sealed class RuntimeSetPositionStateTests Assert.Single(observer.Deltas).Placement.Token)); observer.Deltas.Clear(); - RuntimeSetPositionCommand malformed = Command(Request( - exactCell, - new Vector3(10f, 18f, 7f)) with - { - Spheres = - [ - new FlatCollisionSphere( - new Vector3(float.NaN, 0f, 0f), - 0.4f), - ], - }); - RuntimeSetPositionOutcome acceptedMalformed = lifetime.Physics - .SetPosition.SubmitPreparedPlacement(token, malformed); - Assert.Equal(RuntimeSetPositionStatus.DeferredCell, - acceptedMalformed.Status); + var malformedSetup = new FlatSetupCollision( + ImmutableArray.Empty, + [new FlatCollisionSphere( + new Vector3(float.NaN, 0f, 0f), + 0.4f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f); + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.InvalidData, + lifetime.Physics.SetPosition.PrepareMover( + token, + new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.Resolved( + 0x02000001u, + malformedSetup), + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 10d, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide), + out _)); RuntimeCollisionAdmission first = lifetime.Physics .BeginCollisionAdmission(landblock); @@ -2032,44 +2187,25 @@ public sealed class RuntimeSetPositionStateTests RuntimeSetPositionOwnershipSnapshot invalidWake = lifetime.Physics .SetPosition.CaptureOwnership(); Assert.Equal(1, invalidWake.AwaitingPreparationCount); - Assert.Equal(1, invalidWake.DeferredBucketCount); + Assert.Equal(0, invalidWake.DeferredBucketCount); Assert.Equal(1, invalidWake.LostDeadlineCount); Assert.Empty(observer.Deltas); Assert.False(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( record, out _)); - RuntimeSetPositionCommand corrected = Command(Request( - exactCell, - new Vector3(10f, 18f, 7f)) with - { - Spheres = - [ - new FlatCollisionSphere(Vector3.Zero, 0.4f), - ], - }); + RuntimeSetPositionCommand corrected = PrepareAuthoredCommand( + lifetime, + token, + [new FlatCollisionSphere(Vector3.Zero, 0.4f)]); RuntimeSetPositionOutcome correctedPending = lifetime.Physics .SetPosition.SubmitPreparedPlacement(token, corrected); Assert.Equal(RuntimeSetPositionStatus.DeferredCell, correctedPending.Status); - Assert.Equal(PhysicsResidenceDisposition.DeferredCell, + Assert.Equal(PhysicsResidenceDisposition.Committed, correctedPending.Residence); Assert.Equal(exactCell, correctedPending.ExactCellId); - RuntimeCollisionAdmission second = lifetime.Physics - .BeginCollisionAdmission(landblock); - using PreparedLandblockCollisionGeneration preparedSecond = lifetime - .Physics.PrepareCollisionGeneration(second); - lifetime.Physics.StageCollisionAssets( - second, - preparedSecond, - CollisionAssets(landblock)); - AddSyntheticCell(preparedSecond.DataCache, exactCell); - Assert.True(CommitPrepared( - lifetime.Physics, - second, - preparedSecond).Committed); - RuntimePlacementProjectionSnapshot placed = Assert.Single(observer.Deltas) .Placement; Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); @@ -2178,6 +2314,34 @@ public sealed class RuntimeSetPositionStateTests ShadowWorldOffsetX: request.CellId == SourceCell ? 0f : 192f, ShadowWorldOffsetY: 0f); + private static RuntimeSetPositionCommand PrepareAuthoredCommand( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityPlacementToken token, + ImmutableArray spheres) + { + var setup = new FlatSetupCollision( + ImmutableArray.Empty, + spheres, + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f); + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.Resolved(0x02000001u, setup), + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 10d, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide); + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + preparation, + out RuntimeSetPositionCommand command)); + return command; + } + private static PhysicsSetPositionRequest CrossLandblockRequest() => Request( SourceCell, @@ -2480,6 +2644,13 @@ public sealed class RuntimeSetPositionStateTests internal void JumpUtc(TimeSpan duration) => _utcNow += duration; } + public enum DeferredPhysicsStateMutation + { + ChildNoDraw, + StopMissile, + DirectFinalState, + } + private enum CancellationChannel : uint { Position, From 22651c823d8266be5f4e43fb37afffeb06aaa7d7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 10:01:30 +0200 Subject: [PATCH 31/73] feat(runtime): publish dormant local physics ownership --- ...untime-local-player-physics-publication.md | 126 ++++ .../Entities/RuntimeEntityDirectory.cs | 2 +- .../Entities/RuntimeEntityRecord.cs | 11 +- src/AcDream.Runtime/GameRuntime.cs | 9 + .../Gameplay/PlayerMovementController.cs | 310 +++++++- .../RuntimeLocalPlayerMovementState.cs | 63 +- ...ntimeLocalPlayerPhysicsPublicationState.cs | 316 ++++++++ .../Physics/RuntimeSetPositionState.cs | 24 + .../Gameplay/PlayerMovementControllerTests.cs | 68 ++ ...LocalPlayerPhysicsPublicationStateTests.cs | 713 ++++++++++++++++++ 10 files changed, 1617 insertions(+), 25 deletions(-) create mode 100644 docs/research/2026-08-01-runtime-local-player-physics-publication.md create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs diff --git a/docs/research/2026-08-01-runtime-local-player-physics-publication.md b/docs/research/2026-08-01-runtime-local-player-physics-publication.md new file mode 100644 index 00000000..d49be713 --- /dev/null +++ b/docs/research/2026-08-01-runtime-local-player-physics-publication.md @@ -0,0 +1,126 @@ +# Runtime local-player physics publication - 2026-08-01 + +## Scope + +This is placement Slice 4B2 checkpoint 4. It adds the dormant, +presentation-independent transaction which prepares and assigns ownership of +one local-player `PhysicsBody` and `PlayerMovementController`. No App or +Headless production route invokes this transaction, so graphical and no-window +game behavior is unchanged and AP-1/AD-1 remain open. + +The checkpoint deliberately stops before canonical SetPosition activation. +It does not consume the prepared placement operation, enter the body into the +physics engine, publish FullCell/world/host/shadow/workset state, or project a +presentation entity. Those effects belong to the next transaction and must all +use the same Runtime-owned dormant body. + +## Ownership contract + +`RuntimeLocalPlayerPhysicsPublicationState` is the sole owner of unpublished +local-player body/controller candidates. Each candidate is bound to a token +containing: + +- The exact `RuntimeEntityKey` and authored SetPosition placement token. +- A monotonic publication ID. +- The nonzero canonical local-player server GUID and exact identity revision. +- The record's physics-body and object-clock ownership epochs. +- The movement state's controller ownership epoch. +- The entity directory's session-lifetime authority. + +Preparation constructs a private controller, body, and object clock. It applies +the exact authored cell frame, orientation, Setup sphere list, scale, step +heights, and accepted final physics state without mutating the canonical entity, +shared object clock, engine/worksets, shadow registry, FullCell, host state, or +presentation. The candidate remains explicitly out of world and inactive. No +method exposes its controller, body, clock, or another mutable reference while +it is owned by the publication transaction. + +This checkpoint accepts only a pristine initial graph: no canonical body, +movement controller, physics host, remote motion, projectile, acquisition or +binding operation, or remote-placement contract may exist. It cannot replace or +upgrade a live graph. The local-player identity must be live, nonzero, and name +the same server GUID as the exact entity incarnation. + +Unpublished candidates and ownership-committed dormant controllers reject live +movement operations: update, public SetPosition, blip, outbound-position +capture, movement/position send tracking, and shared-engine position commit. +Only the subsequent activation transaction may promote `RuntimeOwnedDormant` +to `RuntimePublished`; this checkpoint never invokes that transition. Once a +Runtime-owned dormant or published controller is replaced, reset, or disposed, +its terminal retirement state rejects the same operations plus +body/configuration mutation and manager acquisition. Publicly constructed +legacy controllers keep their existing standalone behavior. + +## Failure-atomic commit + +Commit revalidates every authority after preparation: + +- The entity record is the current incarnation and is not accepted for delete. +- The local-player identity still has the token's exact GUID and revision and + has not been disposed. +- The exact authored SetPosition operation and sealed command remain current. +- Session, body, object-clock, and controller ownership epochs still match. +- The body/controller/host/remote/projectile graph remains completely pristine, + with no acquisition, binding, or remote-placement operation in progress. + +Only after validation completes does the callback-free update-thread tail: + +1. Rebind the candidate controller from its private clock to the record's exact + canonical object clock and mark it Runtime-owned but dormant. +2. Store the candidate's exact body on the canonical record, advancing the + physics ownership epoch once. +3. Store the same controller in `RuntimeLocalPlayerMovementState`, advancing the + controller ownership epoch once. + +The dormant controller rejects every live/configuration operation after these +stores; ownership commit alone cannot tick physics, mutate the canonical clock, +or publish an outbound frame. These stores allocate no new gameplay owner, +invoke no host or presentation callback, and cannot replay an older +incarnation. Replacing SetPosition, +changing any accepted physics authority, binding remote/projectile state, +replacing body/clock/controller ownership, delete plus GUID reuse, reset, or +disposal causes the token to reject. An identity switch away and back also +rejects because its revision changed. A rejected or superseded candidate is +discarded and cannot perform a later live operation. Reset and disposal converge +the publication ledger to zero candidates. + +Repeated stores of the same body/controller do not advance their epochs; real +bind, replacement, and unbind edges do. This makes ABA-shaped reference changes +observable even if a later value happens to equal an earlier reference. + +## Gates + +- Candidate privacy and live-operation rejection. +- Pristine-only admission for body, controller, host, remote/projectile, + acquisition/binding, and remote-placement ownership. +- Exact local-player identity, identity-switch, and disposed-identity rejection. +- Exact same-body ownership in entity record and dormant movement controller. +- Dormant rejection after ownership commit plus the isolated controller-level + `dormant -> activated -> live` lifecycle contract for the next checkpoint. +- No mutation of SetPosition, FullCell, spatial roots, host projections, + shadows, worksets, world residence, or presentation during this checkpoint. +- Replacement by position, vector, final physics state, object description, + CreateObject, remote/projectile/body/clock/controller ownership, and explicit + placement cancellation. +- Delete plus same-GUID reincarnation. +- Candidate replacement, reset, disposal, and ownership convergence. +- Terminal stale-controller rejection after replacement, reset, and disposal. +- Body/controller epochs advance only on actual ownership changes. + +Focused publication, controller, movement, and SetPosition tests pass 167/167. +The complete Runtime project passes 627/627 under invariant culture. The App +Runtime-ownership guard passes 4/4, the complete Release solution builds with +zero errors, and the complete Release solution test gate passes 10,374 tests +with 4 intentional skips. Under the machine's Swedish current culture, the +three previously known formatting assertions still fail (`0,5` versus `0.5` +and localized sky text); they are unrelated to this checkpoint. + +## Next checkpoint + +Add the canonical Runtime SetPosition activation transaction. It must evaluate +and commit the already-owned dormant body, consume the exact prepared placement, +and atomically establish physics-engine/workset/shadow/FullCell/world/host +ownership before presentation receives an acknowledgement, then invoke the sole +`ActivateRuntimePublication` transition. The activation must +roll back or leave the operation retryable on every pre-commit failure and must +not construct a second body or controller. diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index bb4a353f..e9b028ae 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -342,7 +342,7 @@ public sealed class RuntimeEntityDirectory AcDream.Core.Physics.PhysicsBody? body) { EnsureKnown(record); - record.PhysicsBody = body; + record.SetPhysicsBody(body); } public void SetPhysicsBodyAcquisitionInProgress( diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs index 369c7861..1d8def85 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -51,6 +51,7 @@ public sealed class RuntimeEntityRecord public uint CanonicalLandblockId { get; internal set; } public uint RawPhysicsState { get; internal set; } public PhysicsStateFlags FinalPhysicsState { get; internal set; } + public ulong PhysicsOwnershipEpoch { get; private set; } public ulong SpatialAuthorityVersion { get; private set; } public ulong PlacementCommitVersion { get; private set; } public ulong PhysicsStateMutationVersion { get; private set; } @@ -68,7 +69,7 @@ public sealed class RuntimeEntityRecord /// object reference. /// public bool HasPartArray { get; internal set; } - public PhysicsBody? PhysicsBody { get; internal set; } + public PhysicsBody? PhysicsBody { get; private set; } public bool PhysicsBodyAcquisitionInProgress { get; internal set; } public IRuntimeRemoteMotion? RemoteMotion { get; internal set; } public bool RemoteMotionBindingInProgress { get; internal set; } @@ -172,6 +173,14 @@ public sealed class RuntimeEntityRecord FinalPhysicsState = state; } + internal void SetPhysicsBody(PhysicsBody? body) + { + if (ReferenceEquals(PhysicsBody, body)) + return; + PhysicsBody = body; + PhysicsOwnershipEpoch++; + } + /// /// Retail collision reporting clears Missile, AlignPath, and PathClipped /// directly on the live CPhysicsObj. Keep the canonical record and its diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 4a15341a..08898a69 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -256,6 +256,13 @@ public sealed class GameRuntime context.Character, context.PlayerIdentity); + context.Movement.AttachPhysicsPublication( + new RuntimeLocalPlayerPhysicsPublicationState( + context.EntityObjects.Entities, + context.EntityObjects.Physics, + context.Movement, + context.PlayerIdentity)); + context.EntityObjects.BindEventContext( () => generationReset.ActiveRetiringGeneration ?? context.Session.Generation, @@ -301,6 +308,8 @@ public sealed class GameRuntime public RuntimeCommunicationState CommunicationOwner { get; } public RuntimeActionState ActionOwner { get; } public RuntimeLocalPlayerMovementState MovementOwner { get; } + internal RuntimeLocalPlayerPhysicsPublicationState + LocalPlayerPhysicsPublication => MovementOwner.PhysicsPublication; public RuntimeWorldEnvironmentState EnvironmentOwner { get; } public RuntimeWorldTransitState TransitOwner { get; } public RuntimeGenerationReset GenerationReset { get; } diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index a3d8182b..3089a972 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -121,6 +121,17 @@ public readonly record struct MovementResult( /// public enum PlayerState { InWorld, PortalSpace } +internal enum PlayerMovementControllerPublicationLifecycle +{ + StandalonePublished, + CandidatePreparing, + CandidateSealed, + RuntimeOwnedDormant, + RuntimePublished, + RuntimeRetired, + Discarded, +} + /// /// Per-frame player movement controller. Reads input, drives the /// ported PhysicsBody + MotionInterpreter, tracks motion state for @@ -140,6 +151,17 @@ public sealed class PlayerMovementController private readonly PhysicsBody _body; private readonly MotionInterpreter _motion; private readonly PlayerWeenie _weenie; + private readonly AcDream.Core.Physics.Motion.MovementManager + _movementManager; + private float _stepUpHeight = 0.4f; + private float _stepDownHeight = 0.4f; + private ObjectInfoState _ownPvpFlags = ObjectInfoState.None; + private System.Collections.Immutable.ImmutableArray + _sphereList; + private float _objectScale = 1f; + private PlayerState _state = PlayerState.InWorld; + private uint _localEntityId; + private AcDream.Core.Physics.Motion.PositionManager? _positionManager; /// /// Maximum Z increase per movement step before the move is rejected. @@ -151,7 +173,15 @@ public sealed class PlayerMovementController /// Authoritative source is the player's Setup.StepUpHeight set /// in GameWindow.cs at world-entry time. /// - public float StepUpHeight { get; set; } = 0.4f; + public float StepUpHeight + { + get => _stepUpHeight; + set + { + EnsureConfigurationMutable(); + _stepUpHeight = value; + } + } /// /// L.2.3a (2026-04-29): how far below the foot the step-down probe @@ -161,7 +191,15 @@ public sealed class PlayerMovementController /// the ground 25 cm below produced a one-frame contact-plane gap — the /// animation system briefly flickered to falling. /// - public float StepDownHeight { get; set; } = 0.4f; + public float StepDownHeight + { + get => _stepDownHeight; + set + { + EnsureConfigurationMutable(); + _stepDownHeight = value; + } + } /// /// TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own @@ -176,7 +214,15 @@ public sealed class PlayerMovementController /// hardcoded IsPlayer | EdgeSlide — the non-PK invariant this /// port must not break. /// - public ObjectInfoState OwnPvpFlags { get; set; } = ObjectInfoState.None; + public ObjectInfoState OwnPvpFlags + { + get => _ownPvpFlags; + set + { + EnsureConfigurationMutable(); + _ownPvpFlags = value; + } + } /// /// TS-46 (2026-07-30): the player's own Setup ≤2-sphere list (dat @@ -191,14 +237,31 @@ public sealed class PlayerMovementController /// (0,0,1.350) r=.48, a 5 mm improvement over the reconstruction's /// (0,0,0.48) + (0,0,1.355). /// - public System.Collections.Immutable.ImmutableArray SphereList { get; set; } + public System.Collections.Immutable.ImmutableArray + SphereList + { + get => _sphereList; + set + { + EnsureConfigurationMutable(); + _sphereList = value; + } + } /// /// Retail CPhysicsObj::m_scale. Grounded CSequence root /// displacement is multiplied by this value before PositionManager /// composition (UpdatePositionInternal @ 0x00512C30). /// - public float ObjectScale { get; set; } = 1f; + public float ObjectScale + { + get => _objectScale; + set + { + EnsureConfigurationMutable(); + _objectScale = value; + } + } /// /// Current portal-space state. Set to PortalSpace when the server sends @@ -207,7 +270,15 @@ public sealed class PlayerMovementController /// While in PortalSpace, Update returns immediately with a zero-movement /// result so no WASD input or physics is processed. /// - public PlayerState State { get; set; } = PlayerState.InWorld; + public PlayerState State + { + get => _state; + set + { + EnsurePublishedForRuntimeOperation(); + _state = value; + } + } /// /// Horizontal projection of the authoritative body quaternion. Assigning @@ -221,6 +292,7 @@ public sealed class PlayerMovementController AcDream.Core.Physics.Motion.MoveToMath.GetHeading(_body.Orientation)); set { + EnsurePublishedForRuntimeOperation(); float wrapped = value; while (wrapped > MathF.PI) wrapped -= 2f * MathF.PI; while (wrapped < -MathF.PI) wrapped += 2f * MathF.PI; @@ -253,6 +325,7 @@ public sealed class PlayerMovementController internal bool TryGetOutboundPosition( out AcDream.Core.Physics.Position outboundPosition) { + EnsurePublishedForRuntimeOperation(); AcDream.Core.Physics.Position canonical = _body.CellPosition; outboundPosition = new AcDream.Core.Physics.Position( canonical.ObjCellId, @@ -273,7 +346,15 @@ public sealed class PlayerMovementController /// sweep collides with its own ShadowEntry registered at /// GameWindow.cs:2545 — see #42. /// - public uint LocalEntityId { get; set; } + public uint LocalEntityId + { + get => _localEntityId; + set + { + EnsureConfigurationMutable(); + _localEntityId = value; + } + } /// /// Applies the canonical server PhysicsState to the local body. Retail's @@ -282,6 +363,7 @@ public sealed class PlayerMovementController /// public void ApplyPhysicsState(PhysicsStateFlags state) { + EnsureConfigurationMutable(); _body.State = state; _body.calc_acceleration(); } @@ -431,7 +513,8 @@ public sealed class PlayerMovementController // // ACE: PhysicsObj.UpdateObject (Physics.cs). // Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950). - private readonly RetailObjectQuantumClock _objectClock; + private RetailObjectQuantumClock _objectClock; + private PlayerMovementControllerPublicationLifecycle _publicationLifecycle; private Vector3 _prevPhysicsPos; private Vector3 _currPhysicsPos; private Action? @@ -463,7 +546,14 @@ public sealed class PlayerMovementController /// DriveServerAutoWalk occupied and relays HitGround() /// (0x00524300, minterp first then moveto). /// - public AcDream.Core.Physics.Motion.MovementManager Movement { get; } + public AcDream.Core.Physics.Motion.MovementManager Movement + { + get + { + EnsureConfigurationMutable(); + return _movementManager; + } + } /// /// R4-V5: the local player's verbatim retail MoveToManager @@ -486,6 +576,7 @@ public sealed class PlayerMovementController get => Movement.MoveTo; set { + EnsureConfigurationMutable(); var mtm = value ?? throw new ArgumentNullException(nameof(value)); Movement.MoveToFactory = () => mtm; Movement.MakeMoveToManager(); @@ -509,12 +600,28 @@ public sealed class PlayerMovementController /// arms the leash without tearing it down first /// (retail SmartBox::BlipPlayer survives motion/velocity/stick). /// - public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get; set; } + public AcDream.Core.Physics.Motion.PositionManager? PositionManager + { + get + { + EnsureConfigurationMutable(); + return _positionManager; + } + set + { + EnsureConfigurationMutable(); + _positionManager = value; + } + } public PlayerMovementController( PhysicsEngine physics, RetailObjectQuantumClock? objectClock = null) - : this(physics, objectClock, PlayerMovementConstructionOptions.Fallback) + : this( + physics, + objectClock, + PlayerMovementConstructionOptions.Fallback, + PlayerMovementControllerPublicationLifecycle.StandalonePublished) { } @@ -522,9 +629,23 @@ public sealed class PlayerMovementController PhysicsEngine physics, RetailObjectQuantumClock? objectClock, PlayerMovementConstructionOptions options) + : this( + physics, + objectClock, + options, + PlayerMovementControllerPublicationLifecycle.StandalonePublished) + { + } + + private PlayerMovementController( + PhysicsEngine physics, + RetailObjectQuantumClock? objectClock, + PlayerMovementConstructionOptions options, + PlayerMovementControllerPublicationLifecycle publicationLifecycle) { _physics = physics; _objectClock = objectClock ?? new RetailObjectQuantumClock(); + _publicationLifecycle = publicationLifecycle; _body = new PhysicsBody { @@ -551,8 +672,8 @@ public sealed class PlayerMovementController // R5-V5: the MovementManager facade owns the interp from birth // (retail CPhysicsObj::movement_manager); the moveto child binds // later via MoveToFactory (EnterPlayerModeNow / the test rigs). - Movement = new AcDream.Core.Physics.Motion.MovementManager(_motion); - Movement.ActivatePhysicsObject = ActivateFromMovement; + _movementManager = new AcDream.Core.Physics.Motion.MovementManager(_motion); + _movementManager.ActivatePhysicsObject = ActivateFromMovement; // R3-W4 (A3): the local player's movement is input-driven — // movement_is_autonomous true so apply_current_movement's dual // dispatch routes apply_raw_movement (IsThePlayer && autonomous). @@ -560,6 +681,117 @@ public sealed class PlayerMovementController _body.LastMoveWasAutonomous = true; } + internal static PlayerMovementController CreatePublicationCandidate( + PhysicsEngine physics, + PlayerMovementConstructionOptions options) => new( + physics, + new RetailObjectQuantumClock(), + options, + PlayerMovementControllerPublicationLifecycle.CandidatePreparing); + + internal PhysicsBody PhysicsBody + { + get + { + EnsureConfigurationMutable(); + return _body; + } + } + + internal bool OwnsPhysicsBody(PhysicsBody body) => + ReferenceEquals(_body, body); + + internal bool IsSealedPublicationCandidate => _publicationLifecycle + is PlayerMovementControllerPublicationLifecycle.CandidateSealed; + + internal void SealPublicationCandidate() + { + if (_publicationLifecycle + is not PlayerMovementControllerPublicationLifecycle + .CandidatePreparing) + { + throw new InvalidOperationException( + "Only a preparing Runtime movement candidate can be sealed."); + } + _publicationLifecycle = PlayerMovementControllerPublicationLifecycle + .CandidateSealed; + } + + internal void CommitRuntimeOwnership(RetailObjectQuantumClock objectClock) + { + ArgumentNullException.ThrowIfNull(objectClock); + if (_publicationLifecycle + is not PlayerMovementControllerPublicationLifecycle.CandidateSealed) + { + throw new InvalidOperationException( + "Only a sealed Runtime movement candidate can be published."); + } + _objectClock = objectClock; + _publicationLifecycle = PlayerMovementControllerPublicationLifecycle + .RuntimeOwnedDormant; + } + + internal void ActivateRuntimePublication() + { + if (_publicationLifecycle + is not PlayerMovementControllerPublicationLifecycle + .RuntimeOwnedDormant) + { + throw new InvalidOperationException( + "Only a dormant Runtime-owned movement controller can be activated."); + } + _publicationLifecycle = PlayerMovementControllerPublicationLifecycle + .RuntimePublished; + } + + internal void DiscardRuntimeCandidate() + { + if (_publicationLifecycle + is PlayerMovementControllerPublicationLifecycle.CandidatePreparing + or PlayerMovementControllerPublicationLifecycle.CandidateSealed) + { + _publicationLifecycle = PlayerMovementControllerPublicationLifecycle + .Discarded; + } + } + + internal void RetireRuntimePublication() + { + if (_publicationLifecycle + is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant + or PlayerMovementControllerPublicationLifecycle.RuntimePublished) + { + _publicationLifecycle = PlayerMovementControllerPublicationLifecycle + .RuntimeRetired; + } + } + + private void EnsureConfigurationMutable() + { + if (_publicationLifecycle + is PlayerMovementControllerPublicationLifecycle.StandalonePublished + or PlayerMovementControllerPublicationLifecycle + .CandidatePreparing + or PlayerMovementControllerPublicationLifecycle.RuntimePublished) + { + return; + } + throw new InvalidOperationException( + "A sealed, retired, or discarded Runtime movement controller cannot be mutated."); + } + + private void EnsurePublishedForRuntimeOperation() + { + if (_publicationLifecycle + is PlayerMovementControllerPublicationLifecycle.StandalonePublished + or PlayerMovementControllerPublicationLifecycle.RuntimePublished) + { + return; + } + throw new InvalidOperationException( + "An unpublished or retired Runtime movement controller cannot execute live movement operations."); + } + /// /// Host half of retail MovementManager::PerformMovement's /// unconditional CPhysicsObj::set_active(1) head. Static objects @@ -568,6 +800,7 @@ public sealed class PlayerMovementController /// private void ActivateFromMovement() { + EnsureConfigurationMutable(); if ((_body.State & PhysicsStateFlags.Static) != 0) return; @@ -582,6 +815,7 @@ public sealed class PlayerMovementController /// internal void SuspendObjectUpdate(float elapsedSeconds) { + EnsurePublishedForRuntimeOperation(); AdvancedObjectQuantumLastTick = false; if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f) _simTimeSeconds += elapsedSeconds; @@ -596,6 +830,7 @@ public sealed class PlayerMovementController /// public bool BeginMouseLook(MovementInput input) { + EnsurePublishedForRuntimeOperation(); if (_mouseLookActive || State != PlayerState.InWorld) return false; @@ -620,6 +855,7 @@ public sealed class PlayerMovementController float signedAdjustment, MovementInput input) { + EnsurePublishedForRuntimeOperation(); if (!_mouseLookActive || !float.IsFinite(signedAdjustment)) return; @@ -637,6 +873,7 @@ public sealed class PlayerMovementController /// public void StopMouseDrift(MovementInput input) { + EnsurePublishedForRuntimeOperation(); if (!_mouseLookActive) return; @@ -662,6 +899,7 @@ public sealed class PlayerMovementController /// public bool EndMouseLook(MovementInput input) { + EnsurePublishedForRuntimeOperation(); if (!_mouseLookActive && !_activeInputTurnFromMouse) return false; @@ -874,6 +1112,7 @@ public sealed class PlayerMovementController /// public bool PrepareForAttackRequest() { + EnsurePublishedForRuntimeOperation(); // CommandInterpreter::MaybeStopCompletely @ 0x006B3B90 is a no-op // while an authoritative server movement owns the player. if (_controlledByServer) @@ -898,6 +1137,7 @@ public sealed class PlayerMovementController /// public bool RequestPosture(uint motion) { + EnsurePublishedForRuntimeOperation(); if (motion is not ( MotionCommand.Ready or MotionCommand.Crouch @@ -922,6 +1162,7 @@ public sealed class PlayerMovementController public void SetCharacterSkills(int runSkill, int jumpSkill) { + EnsureConfigurationMutable(); _weenie.SetSkills(runSkill, jumpSkill); } @@ -934,6 +1175,7 @@ public sealed class PlayerMovementController /// public void SetCharacterBurden(float burden) { + EnsureConfigurationMutable(); _weenie.SetBurden(burden); } @@ -946,6 +1188,7 @@ public sealed class PlayerMovementController /// public void SetCharacterStamina(int currentStamina) { + EnsureConfigurationMutable(); _weenie.SetStamina(currentStamina < 0 ? null : (uint)currentStamina); } @@ -960,6 +1203,7 @@ public sealed class PlayerMovementController /// public void SetCharacterPkStatus(int playerKillerStatus, float? lastPkAttackTimestamp) { + EnsureConfigurationMutable(); _weenie.SetPlayerKillerStatus( playerKillerStatus < 0 ? null : playerKillerStatus, lastPkAttackTimestamp); @@ -972,7 +1216,14 @@ public sealed class PlayerMovementController /// path remotes use. R3-W6 widens this into the full local-player /// unification. /// - internal MotionInterpreter Motion => _motion; + internal MotionInterpreter Motion + { + get + { + EnsureConfigurationMutable(); + return _motion; + } + } /// /// Retail CPhysicsObj::StopCompletely (0x00510180) enters through @@ -981,11 +1232,14 @@ public sealed class PlayerMovementController /// performs the required zero-duration animation completion sweep after /// the interpreter has queued its matching pending-motion node. /// - internal WeenieError StopCompletelyAtPhysicsObjectBoundary() => - Movement.PerformMovement(new MovementStruct + internal WeenieError StopCompletelyAtPhysicsObjectBoundary() + { + EnsureConfigurationMutable(); + return _movementManager.PerformMovement(new MovementStruct { Type = MovementType.StopCompletely, }); + } /// /// Retail CPhysicsObj::DoMotion (0x00510020) constructs a type-1 @@ -999,6 +1253,7 @@ public sealed class PlayerMovementController uint motion, AcDream.Core.Physics.Motion.MovementParameters parameters) { + EnsureConfigurationMutable(); _body.LastMoveWasAutonomous = true; return Movement.PerformMovement(new MovementStruct { @@ -1018,6 +1273,7 @@ public sealed class PlayerMovementController uint motion, AcDream.Core.Physics.Motion.MovementParameters parameters) { + EnsureConfigurationMutable(); _body.LastMoveWasAutonomous = true; return Movement.PerformMovement(new MovementStruct { @@ -1048,6 +1304,7 @@ public sealed class PlayerMovementController /// public void SetBodyOrientation(Quaternion orientation) { + EnsureConfigurationMutable(); _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( _body.Position, _body.Orientation, @@ -1066,6 +1323,7 @@ public sealed class PlayerMovementController /// dispatched motions with the idle raw state. internal void SetLastMoveWasAutonomous(bool autonomous) { + EnsureConfigurationMutable(); _body.LastMoveWasAutonomous = autonomous; _controlledByServer = !autonomous; } @@ -1093,6 +1351,7 @@ public sealed class PlayerMovementController /// public void AttachCycleVelocityAccessor(Func accessor) { + EnsureConfigurationMutable(); if (accessor is null) throw new ArgumentNullException(nameof(accessor)); _motion.GetCycleVelocity = accessor; } @@ -1109,6 +1368,7 @@ public sealed class PlayerMovementController Action advance, Action? processHooks = null) { + EnsureConfigurationMutable(); _advanceAnimationRootMotion = advance ?? throw new ArgumentNullException(nameof(advance)); _processAnimationHooks = processHooks; @@ -1122,6 +1382,7 @@ public sealed class PlayerMovementController /// public void NoteMovementSent(float nowSeconds, bool mouseLookEvent = false) { + EnsurePublishedForRuntimeOperation(); _lastSentTime = nowSeconds; if (mouseLookEvent) { @@ -1137,6 +1398,7 @@ public sealed class PlayerMovementController /// public MovementResult CaptureMovementResult(bool mouseLookEvent) { + EnsurePublishedForRuntimeOperation(); var raw = _motion.RawState; uint? forward = raw.ForwardCommand == RawMotionState.Default.ForwardCommand ? null : raw.ForwardCommand; @@ -1189,6 +1451,7 @@ public sealed class PlayerMovementController System.Numerics.Plane contactPlane, float nowSeconds) { + EnsurePublishedForRuntimeOperation(); _lastSentPosition = position; _lastSentContactPlane = contactPlane; _lastSentTime = nowSeconds; @@ -1207,6 +1470,7 @@ public sealed class PlayerMovementController System.Numerics.Plane currentContactPlane, float nowSeconds) { + EnsurePublishedForRuntimeOperation(); if (!_lastSentInitialized) return true; @@ -1266,11 +1530,14 @@ public sealed class PlayerMovementController /// directly via SnapToCell rather than delta-syncing through the setter. /// public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal) - => SetPositionCore( + { + EnsurePublishedForRuntimeOperation(); + SetPositionCore( pos, cellId, cellLocal, publishSharedState: true); + } /// /// Builds a new local controller's canonical body pose without publishing @@ -1281,15 +1548,19 @@ public sealed class PlayerMovementController internal void PreparePositionForCommit( Vector3 pos, uint cellId, - Vector3 cellLocal) => + Vector3 cellLocal) + { + EnsureConfigurationMutable(); SetPositionCore( pos, cellId, cellLocal, publishSharedState: false); + } internal void CommitPreparedPosition() { + EnsurePublishedForRuntimeOperation(); _physics.UpdatePlayerCurrCell(CellId); PositionManager?.UnStick(); // #167 (Campaign P P5): mirrors the SetPositionCore teleport_hook @@ -1404,6 +1675,7 @@ public sealed class PlayerMovementController /// public void BlipPosition(Vector3 pos, uint cellId, Vector3 cellLocal) { + EnsurePublishedForRuntimeOperation(); _body.SnapToCell(cellId, pos, cellLocal); _prevPhysicsPos = pos; _currPhysicsPos = pos; @@ -1435,6 +1707,7 @@ public sealed class PlayerMovementController /// public MovementResult TickHidden(float dt, Action? handleTargeting = null) { + EnsurePublishedForRuntimeOperation(); AdvancedObjectQuantumLastTick = false; if (!float.IsFinite(dt) || dt <= 0f) { @@ -1554,6 +1827,7 @@ public sealed class PlayerMovementController MovementInput input, Action? handleTargeting = null) { + EnsurePublishedForRuntimeOperation(); AdvancedObjectQuantumLastTick = false; // Reject a malformed host-frame duration at the controller boundary. // The retail object clock cannot sanitize state that input/jump/yaw diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs index c173b93d..942e7249 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs @@ -27,6 +27,7 @@ public sealed class RuntimeLocalPlayerMovementState { private PlayerMovementController? _controller; private PlayerMovementController? _preparingMotionOwner; + private RuntimeLocalPlayerPhysicsPublicationState? _physicsPublication; private bool _autoRunActive; private bool _hasCommandInput; private MovementInput _commandInput; @@ -41,7 +42,9 @@ public sealed class RuntimeLocalPlayerMovementState ObjectDisposedException.ThrowIf(_disposed, this); if (ReferenceEquals(_controller, value)) return; + _controller?.RetireRuntimePublication(); _controller = value; + ControllerOwnershipEpoch++; Interlocked.Increment(ref _revision); } } @@ -50,8 +53,13 @@ public sealed class RuntimeLocalPlayerMovementState public bool HasCommandInput => _hasCommandInput; public MovementInput CommandInput => _commandInput; public long Revision => Interlocked.Read(ref _revision); + public ulong ControllerOwnershipEpoch { get; private set; } public IRuntimeMovementView View => this; + internal RuntimeLocalPlayerPhysicsPublicationState PhysicsPublication => + _physicsPublication ?? throw new InvalidOperationException( + "The Runtime local-player physics publication owner is not bound."); + MotionInterpreter? IRuntimeLocalPlayerMotionSource.Motion => _preparingMotionOwner?.Motion ?? _controller?.Motion; @@ -236,6 +244,7 @@ public sealed class RuntimeLocalPlayerMovementState public void ResetSession() { ObjectDisposedException.ThrowIf(_disposed, this); + _physicsPublication?.ResetSession(); bool changed = _autoRunActive || _hasCommandInput @@ -244,7 +253,12 @@ public sealed class RuntimeLocalPlayerMovementState _autoRunActive = false; _hasCommandInput = false; _commandInput = default; - _controller = null; + if (_controller is not null) + { + _controller.RetireRuntimePublication(); + _controller = null; + ControllerOwnershipEpoch++; + } _preparingMotionOwner = null; if (changed) Interlocked.Increment(ref _revision); @@ -257,7 +271,9 @@ public sealed class RuntimeLocalPlayerMovementState _preparingMotionOwner is not null, _autoRunActive, _hasCommandInput, - Revision); + Revision, + ControllerOwnershipEpoch, + _physicsPublication?.CaptureOwnership() ?? default); public void Dispose() { @@ -266,12 +282,46 @@ public sealed class RuntimeLocalPlayerMovementState _autoRunActive = false; _hasCommandInput = false; _commandInput = default; - _controller = null; + _physicsPublication?.Dispose(); + if (_controller is not null) + { + _controller.RetireRuntimePublication(); + _controller = null; + ControllerOwnershipEpoch++; + } _preparingMotionOwner = null; Interlocked.Increment(ref _revision); _disposed = true; } + internal void AttachPhysicsPublication( + RuntimeLocalPlayerPhysicsPublicationState publication) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(publication); + if (_physicsPublication is not null) + { + throw new InvalidOperationException( + "The Runtime local-player physics publication owner is already bound."); + } + _physicsPublication = publication; + } + + internal bool CanCommitRuntimeOwnedController( + ulong expectedEpoch, + PlayerMovementController? expectedController) => + !_disposed + && ControllerOwnershipEpoch == expectedEpoch + && ReferenceEquals(_controller, expectedController); + + internal void CommitRuntimeOwnedController(PlayerMovementController controller) + { + _controller?.RetireRuntimePublication(); + _controller = controller; + ControllerOwnershipEpoch++; + Interlocked.Increment(ref _revision); + } + private void EndMotionPreparation(PlayerMovementController controller) { // Terminal disposal clears the unpublished construction seam. A @@ -310,12 +360,15 @@ public readonly record struct RuntimeLocalMovementOwnershipSnapshot( bool HasPreparingMotionOwner, bool AutoRunActive, bool HasCommandInput, - long Revision) + long Revision, + ulong ControllerOwnershipEpoch, + RuntimeLocalPlayerPhysicsPublicationOwnershipSnapshot PhysicsPublication) { public bool IsConverged => IsDisposed && !HasController && !HasPreparingMotionOwner && !AutoRunActive - && !HasCommandInput; + && !HasCommandInput + && PhysicsPublication.IsConverged; } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs new file mode 100644 index 00000000..823cfcb6 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -0,0 +1,316 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Gameplay; + +internal enum RuntimeLocalPlayerPhysicsPublicationStatus +{ + Prepared, + Committed, + RejectedAuthority, + RejectedToken, + Discarded, +} + +internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken( + RuntimeEntityKey Entity, + RuntimeEntityPlacementToken Placement, + ulong PublicationId, + uint LocalPlayerServerGuid, + long LocalPlayerIdentityRevision, + ulong PhysicsOwnershipEpoch, + ulong ObjectClockEpoch, + ulong ControllerOwnershipEpoch, + ulong SessionGenerationAuthority) +{ + internal bool IsValid => PublicationId != 0UL + && LocalPlayerServerGuid != 0u + && Placement.IsValid + && Entity == Placement.Entity; +} + +internal readonly record struct RuntimeLocalPlayerPhysicsCandidateSnapshot( + Vector3 Position, + Quaternion Orientation, + uint CellId, + Vector3 CellLocalPosition, + PhysicsStateFlags State, + TransientStateFlags TransientState, + bool InWorld); + +public readonly record struct + RuntimeLocalPlayerPhysicsPublicationOwnershipSnapshot( + bool IsBound, + bool IsDisposed, + int CandidateCount, + ulong LastPublicationId) +{ + internal bool IsConverged => !IsBound + || (IsDisposed && CandidateCount == 0); +} + +/// +/// Dormant, presentation-independent owner of local-player body/controller +/// candidates. Preparation owns a private body and clock. Commit is one +/// callback-free update-thread transaction which assigns that exact body to +/// the canonical entity and a dormant movement controller. It deliberately +/// does not activate the controller or +/// consume SetPosition or publish world residence, host, shadow, ordinary +/// workset, FullCell, or presentation state; those belong to the subsequent +/// activation transaction. +/// +internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable +{ + private sealed class Candidate + { + internal required RuntimeLocalPlayerPhysicsPublicationToken Token + { get; init; } + internal required RuntimeEntityRecord Record { get; init; } + internal required RuntimeSetPositionCommand PlacementCommand + { get; init; } + internal required PlayerMovementController Controller { get; init; } + internal required PhysicsBody Body { get; init; } + } + + private readonly RuntimeEntityDirectory _entities; + private readonly RuntimePhysicsState _physics; + private readonly RuntimeLocalPlayerMovementState _movement; + private readonly RuntimeLocalPlayerIdentityState _identity; + private Candidate? _candidate; + private ulong _nextPublicationId; + private bool _disposed; + + internal RuntimeLocalPlayerPhysicsPublicationState( + RuntimeEntityDirectory entities, + RuntimePhysicsState physics, + RuntimeLocalPlayerMovementState movement, + RuntimeLocalPlayerIdentityState identity) + { + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _movement = movement ?? throw new ArgumentNullException(nameof(movement)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + } + + internal RuntimeLocalPlayerPhysicsPublicationStatus Prepare( + RuntimeEntityRecord record, + in RuntimeEntityPlacementToken placement, + in RuntimeSetPositionCommand command, + PlayerMovementConstructionOptions options, + out RuntimeLocalPlayerPhysicsPublicationToken token) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(record); + token = default; + if (!CanPrepare(record, placement, command)) + return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority; + + var controller = PlayerMovementController.CreatePublicationCandidate( + _physics.Engine, + options); + controller.LocalEntityId = record.Key!.Value.LocalEntityId; + controller.StepUpHeight = command.Physics.StepUpHeight; + controller.StepDownHeight = command.Physics.StepDownHeight; + controller.SphereList = command.Physics.Spheres; + controller.ObjectScale = command.Physics.Scale; + controller.PreparePositionForCommit( + command.Physics.Position, + command.Physics.CellId, + command.Physics.CellLocalPosition); + controller.SetBodyOrientation(command.Physics.Orientation); + controller.ApplyPhysicsState(record.FinalPhysicsState); + PhysicsBody body = controller.PhysicsBody; + // This checkpoint publishes ownership only. The subsequent canonical + // SetPosition transaction is the sole authority which may enter the + // body into world simulation and activate its ordinary workset. + body.InWorld = false; + body.TransientState &= ~TransientStateFlags.Active; + controller.SealPublicationCandidate(); + + // Candidate construction is intentionally private, but every accepted + // authority is rechecked after it so future content/configuration work + // cannot accidentally create a callback-shaped stale publication. + if (!CanPrepare(record, placement, command)) + { + controller.DiscardRuntimeCandidate(); + return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority; + } + + DiscardCurrent(); + token = new RuntimeLocalPlayerPhysicsPublicationToken( + record.Key.Value, + placement, + checked(++_nextPublicationId), + _identity.ServerGuid, + _identity.Revision, + record.PhysicsOwnershipEpoch, + record.ObjectClockEpoch, + _movement.ControllerOwnershipEpoch, + _entities.SessionLifetimeVersion); + _candidate = new Candidate + { + Token = token, + Record = record, + PlacementCommand = command, + Controller = controller, + Body = body, + }; + return RuntimeLocalPlayerPhysicsPublicationStatus.Prepared; + } + + internal RuntimeLocalPlayerPhysicsPublicationStatus Commit( + in RuntimeLocalPlayerPhysicsPublicationToken token) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!token.IsValid + || _candidate is not { } candidate + || candidate.Token != token) + { + return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedToken; + } + if (!IsCurrent(candidate)) + { + DiscardCurrent(); + return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority; + } + + // All validation is complete. The remaining stores are callback-free, + // non-allocating, and cannot fail on this single Runtime update thread. + // The controller remains RuntimeOwnedDormant; the subsequent world + // activation transaction is the only authority allowed to make it live. + candidate.Controller.CommitRuntimeOwnership( + candidate.Record.ObjectClock); + candidate.Record.SetPhysicsBody(candidate.Body); + _movement.CommitRuntimeOwnedController(candidate.Controller); + _candidate = null; + return RuntimeLocalPlayerPhysicsPublicationStatus.Committed; + } + + internal RuntimeLocalPlayerPhysicsPublicationStatus Discard( + in RuntimeLocalPlayerPhysicsPublicationToken token) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!token.IsValid + || _candidate is not { } candidate + || candidate.Token != token) + { + return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedToken; + } + DiscardCurrent(); + return RuntimeLocalPlayerPhysicsPublicationStatus.Discarded; + } + + internal bool TryCaptureCandidateSnapshot( + in RuntimeLocalPlayerPhysicsPublicationToken token, + out RuntimeLocalPlayerPhysicsCandidateSnapshot snapshot) + { + if (!_disposed + && token.IsValid + && _candidate is { } candidate + && candidate.Token == token) + { + PhysicsBody body = candidate.Body; + snapshot = new RuntimeLocalPlayerPhysicsCandidateSnapshot( + body.Position, + body.Orientation, + body.CellPosition.ObjCellId, + body.CellPosition.Frame.Origin, + body.State, + body.TransientState, + body.InWorld); + return true; + } + snapshot = default; + return false; + } + + internal RuntimeLocalPlayerPhysicsPublicationOwnershipSnapshot + CaptureOwnership() => new( + IsBound: true, + _disposed, + _candidate is null ? 0 : 1, + _nextPublicationId); + + internal void ResetSession() + { + ObjectDisposedException.ThrowIf(_disposed, this); + DiscardCurrent(); + } + + public void Dispose() + { + if (_disposed) + return; + DiscardCurrent(); + _disposed = true; + } + + private bool CanPrepare( + RuntimeEntityRecord record, + in RuntimeEntityPlacementToken placement, + in RuntimeSetPositionCommand command) => + record.Key is { } key + && key == placement.Entity + && _entities.IsCurrent(record) + && !record.DeleteAcceptedForTeardown + && command.Kind is RuntimeSetPositionOperationKind.InitialLogin + or RuntimeSetPositionOperationKind.LocalAuthoritative + && command.Physics.MovingEntityId == key.LocalEntityId + && !_identity.IsDisposed + && _identity.ServerGuid != 0u + && _identity.ServerGuid == record.ServerGuid + && record.PhysicsBody is null + && _movement.Controller is null + && record.PhysicsHost is null + && record.RemoteMotion is null + && record.Projectile is null + && !record.PhysicsBodyAcquisitionInProgress + && !record.RemoteMotionBindingInProgress + && !record.ProjectileBindingInProgress + && !record.RequiresRemotePlacementRuntime + && _physics.SetPosition.IsExactPreparedPlacementCurrent( + record, + placement, + command); + + private bool IsCurrent(Candidate candidate) => + candidate.Controller.IsSealedPublicationCandidate + && candidate.Controller.OwnsPhysicsBody(candidate.Body) + && _entities.SessionLifetimeVersion + == candidate.Token.SessionGenerationAuthority + && _entities.IsCurrent(candidate.Record) + && candidate.Record.Key == candidate.Token.Entity + && !_identity.IsDisposed + && _identity.ServerGuid == candidate.Token.LocalPlayerServerGuid + && _identity.ServerGuid == candidate.Record.ServerGuid + && _identity.Revision == candidate.Token.LocalPlayerIdentityRevision + && candidate.Record.PhysicsOwnershipEpoch + == candidate.Token.PhysicsOwnershipEpoch + && candidate.Record.ObjectClockEpoch + == candidate.Token.ObjectClockEpoch + && _movement.CanCommitRuntimeOwnedController( + candidate.Token.ControllerOwnershipEpoch, + expectedController: null) + && candidate.Record.PhysicsBody is null + && candidate.Record.PhysicsHost is null + && candidate.Record.RemoteMotion is null + && candidate.Record.Projectile is null + && !candidate.Record.PhysicsBodyAcquisitionInProgress + && !candidate.Record.RemoteMotionBindingInProgress + && !candidate.Record.ProjectileBindingInProgress + && !candidate.Record.RequiresRemotePlacementRuntime + && !candidate.Record.DeleteAcceptedForTeardown + && _physics.SetPosition.IsExactPreparedPlacementCurrent( + candidate.Record, + candidate.Token.Placement, + candidate.PlacementCommand); + + private void DiscardCurrent() + { + Candidate? candidate = _candidate; + _candidate = null; + candidate?.Controller.DiscardRuntimeCandidate(); + } +} diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 70c04794..a58bd2a1 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -573,6 +573,30 @@ internal sealed class RuntimeSetPositionState : IDisposable return RuntimeSetPositionMoverPreparationStatus.Prepared; } + internal bool IsExactPreparedPlacementCurrent( + RuntimeEntityRecord record, + in RuntimeEntityPlacementToken token, + in RuntimeSetPositionCommand command) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + return token.IsValid + && token.Entity == record.Key + && _operations.TryGetValue(token.Entity, out Operation? operation) + && ReferenceEquals(operation.Record, record) + && operation.Token == token + && operation.Stage + is RuntimeEntityPlacementStage.AwaitingPreparation + && IsCurrent(operation) + && _moverPreparationAuthorities.TryGetValue( + token.Entity, + out MoverPreparationAuthority authority) + && authority.OperationId == token.OperationId + && authority.Prepared + && authority.PreparedCommand == command + && IsPreparationAuthorityCurrent(operation, authority); + } + internal RuntimeSetPositionOutcome SubmitPreparedPlacement( in RuntimeEntityPlacementToken token, in RuntimeSetPositionCommand command) => diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs index 176e5233..d9ba7ffe 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs @@ -1100,6 +1100,74 @@ public class PlayerMovementControllerTests public bool StopMotion(uint motion) => true; } + [Fact] + public void PublicationLifecycleRequiresExplicitActivationAfterOwnershipCommit() + { + var candidate = PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + candidate.LocalEntityId = 0x70004001u; + candidate.StepUpHeight = 0.4f; + candidate.StepDownHeight = 0.4f; + candidate.ObjectScale = 1f; + candidate.PreparePositionForCommit( + new Vector3(1f, 2f, 3f), + 0xA9B40021u, + new Vector3(1f, 2f, 3f)); + candidate.SetBodyOrientation(Quaternion.Identity); + candidate.ApplyPhysicsState(PhysicsStateFlags.Gravity); + PhysicsBody body = candidate.PhysicsBody; + body.InWorld = false; + body.TransientState &= ~TransientStateFlags.Active; + Assert.False(body.InWorld); + + candidate.SealPublicationCandidate(); + + Assert.True(candidate.IsSealedPublicationCandidate); + Assert.Throws(() => candidate.Update( + 1f / 60f, + default)); + Assert.Throws(() => candidate.TickHidden( + 1f / 60f)); + Assert.Throws(() => candidate.SetPosition( + Vector3.One, + 0xA9B40021u, + Vector3.One)); + Assert.Throws(() => candidate.BlipPosition( + Vector3.One, + 0xA9B40021u, + Vector3.One)); + Assert.Throws(() => + candidate.CaptureMovementResult(mouseLookEvent: false)); + Assert.Throws(() => + candidate.NoteMovementSent(1f)); + Assert.Throws(candidate.CommitPreparedPosition); + Assert.Throws(() => + candidate.ApplyPhysicsState(PhysicsStateFlags.Frozen)); + Assert.Throws(() => candidate.LocalEntityId = 2u); + Assert.Throws(() => _ = candidate.Movement); + + var canonicalClock = new RetailObjectQuantumClock(); + candidate.CommitRuntimeOwnership(canonicalClock); + Assert.Throws(() => candidate.Update( + 1f / 60f, + default)); + Assert.Throws(() => + candidate.CaptureMovementResult(mouseLookEvent: false)); + Assert.Throws(() => _ = candidate.PhysicsBody); + + candidate.ActivateRuntimePublication(); + Assert.Same(body, candidate.PhysicsBody); + _ = candidate.CaptureMovementResult(mouseLookEvent: false); + _ = candidate.Update(0f, default); + candidate.ApplyPhysicsState(PhysicsStateFlags.Gravity); + + candidate.RetireRuntimePublication(); + Assert.Throws(() => candidate.Update( + 1f / 60f, + default)); + } + [Fact] public void Update_RunningJumpLandsOnFlatGround_ResidualVelocitySurvivesAndDecays_NotFrozen() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs new file mode 100644 index 00000000..6ab1e808 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -0,0 +1,713 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Gameplay; + +public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests +{ + private const uint Cell = 0xA9B40021u; + private const uint SetupId = 0x02000001u; + + [Fact] + public void PreparationOwnsPrivateBodyAndMutatesNoCanonicalOrSharedState() + { + using var fixture = new Fixture(); + uint fullCell = fixture.Record.FullCellId; + ulong spatial = fixture.Record.SpatialAuthorityVersion; + ulong physicsEpoch = fixture.Record.PhysicsOwnershipEpoch; + ulong clockEpoch = fixture.Record.ObjectClockEpoch; + RuntimePhysicsOwnershipSnapshot physics = fixture.Lifetime.Physics + .CaptureOwnership(); + + RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare(); + + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + Assert.Equal(fullCell, fixture.Record.FullCellId); + Assert.Equal(spatial, fixture.Record.SpatialAuthorityVersion); + Assert.Equal(physicsEpoch, fixture.Record.PhysicsOwnershipEpoch); + Assert.Equal(clockEpoch, fixture.Record.ObjectClockEpoch); + Assert.Null(fixture.Record.PhysicsHost); + Assert.Equal(physics.SpatialRootCount, + fixture.Lifetime.Physics.CaptureOwnership().SpatialRootCount); + Assert.Equal(physics.RetainedShadowRegistrationCount, + fixture.Lifetime.Physics.CaptureOwnership() + .RetainedShadowRegistrationCount); + Assert.Equal(1, fixture.Owner.CaptureOwnership().CandidateCount); + Assert.True(token.IsValid); + } + + [Fact] + public void CommitOwnsExactDormantBodyAndControllerWithoutWorldEdges() + { + using var fixture = new Fixture(); + RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare(); + Assert.True(fixture.Owner.TryCaptureCandidateSnapshot( + token, + out RuntimeLocalPlayerPhysicsCandidateSnapshot prepared)); + uint fullCell = fixture.Record.FullCellId; + ulong spatial = fixture.Record.SpatialAuthorityVersion; + ulong bodyEpoch = fixture.Record.PhysicsOwnershipEpoch; + ulong controllerEpoch = fixture.Movement.ControllerOwnershipEpoch; + double clockPending = fixture.Record.ObjectClock.PendingSeconds; + bool clockActive = fixture.Record.ObjectClock.IsActive; + object? currentCell = fixture.Lifetime.Physics.DataCache.CellGraph.CurrCell; + + RuntimeLocalPlayerPhysicsPublicationStatus status = fixture.Owner + .Commit(token); + + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, status); + PlayerMovementController candidate = Assert.IsType( + fixture.Movement.Controller); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.Same(body, fixture.Record.PhysicsBody); + Assert.Same(candidate, fixture.Movement.Controller); + Assert.True(candidate.OwnsPhysicsBody(body)); + Assert.Equal(bodyEpoch + 1UL, fixture.Record.PhysicsOwnershipEpoch); + Assert.Equal(controllerEpoch + 1UL, + fixture.Movement.ControllerOwnershipEpoch); + Assert.Equal(fullCell, fixture.Record.FullCellId); + Assert.Equal(spatial, fixture.Record.SpatialAuthorityVersion); + Assert.Same(currentCell, + fixture.Lifetime.Physics.DataCache.CellGraph.CurrCell); + Assert.Equal(clockPending, fixture.Record.ObjectClock.PendingSeconds); + Assert.Equal(clockActive, fixture.Record.ObjectClock.IsActive); + Assert.Equal(prepared.Position, body.Position); + Assert.Equal(prepared.Orientation, body.Orientation); + Assert.Equal(prepared.CellId, body.CellPosition.ObjCellId); + Assert.Equal(prepared.CellLocalPosition, + body.CellPosition.Frame.Origin); + Assert.Equal(prepared.State, body.State); + Assert.Equal(prepared.TransientState, body.TransientState); + Assert.Equal(prepared.InWorld, body.InWorld); + Assert.False(body.InWorld); + Assert.False((body.TransientState & TransientStateFlags.Active) != 0); + Assert.Null(fixture.Record.PhysicsHost); + Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership() + .SpatialRootCount); + Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership() + .RetainedShadowRegistrationCount); + Assert.True(fixture.Lifetime.Physics.SetPosition + .IsExactPreparedPlacementCurrent( + fixture.Record, + fixture.Placement, + fixture.Command)); + Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); + AssertNotLive(candidate); + Assert.Equal(prepared.Position, body.Position); + Assert.Equal(prepared.Orientation, body.Orientation); + Assert.Equal(prepared.State, body.State); + Assert.Equal(prepared.TransientState, body.TransientState); + Assert.Equal(clockPending, fixture.Record.ObjectClock.PendingSeconds); + Assert.Equal(clockActive, fixture.Record.ObjectClock.IsActive); + Assert.Same(currentCell, + fixture.Lifetime.Physics.DataCache.CellGraph.CurrCell); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.RejectedToken, + fixture.Owner.Commit(token)); + } + + [Theory] + [InlineData(PublicationInvalidation.SetPositionReplacement)] + [InlineData(PublicationInvalidation.Vector)] + [InlineData(PublicationInvalidation.FinalState)] + [InlineData(PublicationInvalidation.ObjectDescription)] + [InlineData(PublicationInvalidation.Create)] + [InlineData(PublicationInvalidation.Remote)] + [InlineData(PublicationInvalidation.Projectile)] + [InlineData(PublicationInvalidation.Body)] + [InlineData(PublicationInvalidation.Clock)] + [InlineData(PublicationInvalidation.Controller)] + [InlineData(PublicationInvalidation.CancelPlacement)] + public void EveryNestedAuthorityReplacementDiscardsCandidateWithoutRollback( + PublicationInvalidation invalidation) + { + using var fixture = new Fixture(); + RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare(); + PhysicsBody? replacementBody = null; + PlayerMovementController? replacementController = null; + + switch (invalidation) + { + case PublicationInvalidation.SetPositionReplacement: + Assert.True(fixture.Lifetime.Physics.SetPosition + .BeginAuthoredPlacement( + fixture.Record, + fixture.Record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative) + .IsValid); + break; + case PublicationInvalidation.Vector: + fixture.Lifetime.Entities.AdvanceVectorAuthority(fixture.Record); + break; + case PublicationInvalidation.FinalState: + fixture.Lifetime.Entities.SetFinalPhysicsState( + fixture.Record, + fixture.Record.FinalPhysicsState | PhysicsStateFlags.Frozen); + break; + case PublicationInvalidation.ObjectDescription: + fixture.Lifetime.Entities.AdvanceObjDescAuthority(fixture.Record); + break; + case PublicationInvalidation.Create: + fixture.Lifetime.Entities.AdvanceCreateAuthority(fixture.Record); + break; + case PublicationInvalidation.Remote: + fixture.Lifetime.Entities.SetRemoteMotion( + fixture.Record, + new RemoteMotion()); + break; + case PublicationInvalidation.Projectile: + fixture.Lifetime.Entities.SetProjectile( + fixture.Record, + new RuntimeProjectile( + new PhysicsBody(), + new ProjectileCollisionSphere(Vector3.Zero, 0.1f))); + break; + case PublicationInvalidation.Body: + replacementBody = new PhysicsBody(); + fixture.Lifetime.Entities.SetPhysicsBody( + fixture.Record, + replacementBody); + break; + case PublicationInvalidation.Clock: + fixture.Lifetime.Entities.SuspendObjectClock(fixture.Record); + break; + case PublicationInvalidation.Controller: + replacementController = new PlayerMovementController( + new PhysicsEngine()); + fixture.Movement.Controller = replacementController; + break; + case PublicationInvalidation.CancelPlacement: + Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( + fixture.Record, + publishWithdrawal: false)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(invalidation)); + } + + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, + fixture.Owner.Commit(token)); + Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); + if (replacementBody is not null) + Assert.Same(replacementBody, fixture.Record.PhysicsBody); + if (replacementController is not null) + Assert.Same(replacementController, fixture.Movement.Controller); + } + + [Fact] + public void DeleteAndGuidReuseCannotPublishRetiredCandidate() + { + using var fixture = new Fixture(); + RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare(); + uint guid = fixture.Record.ServerGuid; + Assert.True(fixture.Lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, fixture.Record.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + fixture.Lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(fixture.Lifetime.RetireCanonicalOnly(fixture.Record)); + RuntimeEntityRecord replacement = fixture.Lifetime.RegisterEntity( + Spawn(guid, incarnation: 2)).Canonical!; + + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, + fixture.Owner.Commit(token)); + Assert.Null(replacement.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); + } + + [Fact] + public void ResetAndDisposeDiscardCandidatesAndConvergeOwnership() + { + var fixture = new Fixture(); + _ = fixture.Prepare(); + fixture.Movement.ResetSession(); + Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); + + fixture.RepreparePlacement(); + _ = fixture.Prepare(); + fixture.Movement.Dispose(); + Assert.True(fixture.Movement.CaptureOwnership().IsConverged); + fixture.DisposeLifetimeOnly(); + } + + [Theory] + [InlineData(PristineViolation.Body)] + [InlineData(PristineViolation.Controller)] + [InlineData(PristineViolation.Host)] + [InlineData(PristineViolation.BodyAcquisition)] + [InlineData(PristineViolation.Remote)] + [InlineData(PristineViolation.RemoteBinding)] + [InlineData(PristineViolation.Projectile)] + [InlineData(PristineViolation.ProjectileBinding)] + [InlineData(PristineViolation.RemotePlacement)] + public void PreparationRejectsEveryNonPristineOwnershipGraph( + PristineViolation violation) + { + using var fixture = new Fixture(); + switch (violation) + { + case PristineViolation.Body: + { + var activeBody = new PhysicsBody + { + TransientState = TransientStateFlags.Active, + }; + fixture.Lifetime.Entities.SetPhysicsBody( + fixture.Record, + activeBody); + break; + } + case PristineViolation.Controller: + fixture.Movement.Controller = new PlayerMovementController( + new PhysicsEngine()); + break; + case PristineViolation.Host: + fixture.Lifetime.Entities.SetPhysicsHost( + fixture.Record, + CreatePhysicsHost(fixture.Record.ServerGuid)); + break; + case PristineViolation.BodyAcquisition: + fixture.Lifetime.Entities.SetPhysicsBodyAcquisitionInProgress( + fixture.Record, + true); + break; + case PristineViolation.Remote: + fixture.Lifetime.Entities.SetRemoteMotion( + fixture.Record, + new RemoteMotion()); + break; + case PristineViolation.RemoteBinding: + fixture.Lifetime.Entities.SetRemoteMotionBindingInProgress( + fixture.Record, + true); + break; + case PristineViolation.Projectile: + fixture.Lifetime.Entities.SetProjectile( + fixture.Record, + new RuntimeProjectile( + new PhysicsBody(), + new ProjectileCollisionSphere(Vector3.Zero, 0.1f))); + break; + case PristineViolation.ProjectileBinding: + fixture.Lifetime.Entities.SetProjectileBindingInProgress( + fixture.Record, + true); + break; + case PristineViolation.RemotePlacement: + fixture.Lifetime.Entities.SetRequiresRemotePlacementRuntime( + fixture.Record, + true); + break; + default: + throw new ArgumentOutOfRangeException(nameof(violation)); + } + + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, + fixture.Owner.Prepare( + fixture.Record, + fixture.Placement, + fixture.Command, + PlayerMovementConstructionOptions.Fallback, + out RuntimeLocalPlayerPhysicsPublicationToken token)); + Assert.False(token.IsValid); + Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); + } + + [Fact] + public void PreparationRejectsRecordWhichIsNotTheExactLocalIdentity() + { + using var fixture = new Fixture(); + fixture.Identity.ServerGuid = 0x70003002u; + + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, + fixture.Owner.Prepare( + fixture.Record, + fixture.Placement, + fixture.Command, + PlayerMovementConstructionOptions.Fallback, + out _)); + } + + [Fact] + public void PreparationRejectsDisposedLocalIdentity() + { + using var fixture = new Fixture(); + fixture.Identity.Dispose(); + + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, + fixture.Owner.Prepare( + fixture.Record, + fixture.Placement, + fixture.Command, + PlayerMovementConstructionOptions.Fallback, + out _)); + } + + [Fact] + public void IdentityRevisionSwitchRejectsPreparedCandidate() + { + using var fixture = new Fixture(); + RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare(); + uint guid = fixture.Identity.ServerGuid; + fixture.Identity.ServerGuid = guid + 1u; + fixture.Identity.ServerGuid = guid; + + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, + fixture.Owner.Commit(token)); + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + } + + [Theory] + [InlineData(PublishedRetirement.Replacement)] + [InlineData(PublishedRetirement.Reset)] + [InlineData(PublishedRetirement.Dispose)] + public void RuntimeOwnedControllerRejectsStaleOperationsAfterRetirement( + PublishedRetirement retirement) + { + using var fixture = new Fixture(); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare())); + PlayerMovementController stale = fixture.Movement.Controller!; + + switch (retirement) + { + case PublishedRetirement.Replacement: + fixture.Movement.Controller = new PlayerMovementController( + new PhysicsEngine()); + break; + case PublishedRetirement.Reset: + fixture.Movement.ResetSession(); + break; + case PublishedRetirement.Dispose: + fixture.Movement.Dispose(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(retirement)); + } + + AssertNotLive(stale); + } + + [Fact] + public void BodyAndControllerEpochsAdvanceOnlyOnOwnershipChanges() + { + using var fixture = new Fixture(preparePlacement: false); + var firstBody = new PhysicsBody(); + var secondBody = new PhysicsBody(); + ulong bodyEpoch = fixture.Record.PhysicsOwnershipEpoch; + fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, firstBody); + Assert.Equal(bodyEpoch + 1UL, fixture.Record.PhysicsOwnershipEpoch); + fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, firstBody); + Assert.Equal(bodyEpoch + 1UL, fixture.Record.PhysicsOwnershipEpoch); + fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, secondBody); + Assert.Equal(bodyEpoch + 2UL, fixture.Record.PhysicsOwnershipEpoch); + fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, null); + Assert.Equal(bodyEpoch + 3UL, fixture.Record.PhysicsOwnershipEpoch); + + var first = new PlayerMovementController(new PhysicsEngine()); + var second = new PlayerMovementController(new PhysicsEngine()); + ulong controllerEpoch = fixture.Movement.ControllerOwnershipEpoch; + fixture.Movement.Controller = first; + Assert.Equal(controllerEpoch + 1UL, + fixture.Movement.ControllerOwnershipEpoch); + fixture.Movement.Controller = first; + Assert.Equal(controllerEpoch + 1UL, + fixture.Movement.ControllerOwnershipEpoch); + fixture.Movement.Controller = second; + Assert.Equal(controllerEpoch + 2UL, + fixture.Movement.ControllerOwnershipEpoch); + fixture.Movement.Controller = null; + Assert.Equal(controllerEpoch + 3UL, + fixture.Movement.ControllerOwnershipEpoch); + } + + private sealed class Fixture : IDisposable + { + private bool _lifetimeDisposed; + + internal Fixture(bool preparePlacement = true) + { + Lifetime = new RuntimeEntityObjectLifetime(); + Movement = new RuntimeLocalPlayerMovementState(); + Identity = new RuntimeLocalPlayerIdentityState(); + Owner = new RuntimeLocalPlayerPhysicsPublicationState( + Lifetime.Entities, + Lifetime.Physics, + Movement, + Identity); + Movement.AttachPhysicsPublication(Owner); + Record = Lifetime.RegisterEntity( + Spawn(0x70003001u, incarnation: 1)).Canonical!; + Identity.ServerGuid = Record.ServerGuid; + if (preparePlacement) + RepreparePlacement(); + } + + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal RuntimeLocalPlayerMovementState Movement { get; } + internal RuntimeLocalPlayerIdentityState Identity { get; } + internal RuntimeLocalPlayerPhysicsPublicationState Owner { get; } + internal RuntimeEntityRecord Record { get; } + internal RuntimeEntityPlacementToken Placement { get; private set; } + internal RuntimeSetPositionCommand Command { get; private set; } + + internal void RepreparePlacement() + { + Placement = Lifetime.Physics.SetPosition.BeginAuthoredPlacement( + Record, + Record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative); + Assert.True(Placement.IsValid); + var setup = new FlatSetupCollision( + ImmutableArray.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + Lifetime.Physics.SetPosition.PrepareMover( + Placement, + new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.Resolved(SetupId, setup), + RuntimeSetPositionOperationKind.LocalAuthoritative, + GameTime: 10d, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide), + out RuntimeSetPositionCommand command)); + Command = command; + } + + internal RuntimeLocalPlayerPhysicsPublicationToken Prepare() + { + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Prepared, + Owner.Prepare( + Record, + Placement, + Command, + PlayerMovementConstructionOptions.Fallback, + out RuntimeLocalPlayerPhysicsPublicationToken token)); + Assert.Equal(Record.Key, token.Entity); + Assert.Equal(Placement, token.Placement); + Assert.Equal(Identity.ServerGuid, token.LocalPlayerServerGuid); + Assert.Equal(Identity.Revision, token.LocalPlayerIdentityRevision); + return token; + } + + internal void DisposeLifetimeOnly() + { + if (_lifetimeDisposed) + return; + Lifetime.Dispose(); + _lifetimeDisposed = true; + } + + public void Dispose() + { + Movement.Dispose(); + Identity.Dispose(); + DisposeLifetimeOnly(); + } + } + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort incarnation) + { + var position = new CreateObject.ServerPosition( + Cell, + 1f, + 2f, + 3f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + RawState: (uint)(PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions), + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: SetupId, + 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); + return new WorldSession.EntitySpawn( + Guid: guid, + Position: position, + SetupTableId: SetupId, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "local-publication-fixture", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: physics.RawState, + ObjectDescriptionFlags: 0x8u, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + public enum PublicationInvalidation + { + SetPositionReplacement, + Vector, + FinalState, + ObjectDescription, + Create, + Remote, + Projectile, + Body, + Clock, + Controller, + CancelPlacement, + } + + public enum PristineViolation + { + Body, + Controller, + Host, + BodyAcquisition, + Remote, + RemoteBinding, + Projectile, + ProjectileBinding, + RemotePlacement, + } + + public enum PublishedRetirement + { + Replacement, + Reset, + Dispose, + } + + private static EntityPhysicsHost CreatePhysicsHost(uint id) => new( + id, + getPosition: static () => default, + getVelocity: static () => Vector3.Zero, + getRadius: static () => 0.48f, + inContact: static () => true, + minterpMaxSpeed: static () => null, + curTime: static () => 0d, + physicsTimerTime: static () => 0d, + getObjectA: static _ => null, + handleUpdateTarget: static _ => { }, + interruptCurrentMovement: static () => { }); + + private static void AssertNotLive(PlayerMovementController controller) + { + Assert.Throws(() => controller.Update( + 1f / 60f, + default)); + Assert.Throws(() => controller.TickHidden( + 1f / 60f)); + Assert.Throws(() => + controller.SuspendObjectUpdate(1f / 60f)); + Assert.Throws(() => controller.SetPosition( + Vector3.One, + Cell, + Vector3.One)); + Assert.Throws(() => controller.BlipPosition( + Vector3.One, + Cell, + Vector3.One)); + Assert.Throws(() => + controller.ApplyPhysicsState(PhysicsStateFlags.Frozen)); + Assert.Throws(() => controller.Yaw = 1f); + Assert.Throws(() => + controller.CaptureMovementResult(mouseLookEvent: false)); + Assert.Throws(() => + controller.CapturePresentationResult()); + Assert.Throws(() => + controller.TryGetOutboundPosition(out _)); + Assert.Throws(() => + controller.NoteMovementSent(1f)); + Assert.Throws(() => + controller.NotePositionSent(default, default, 1f)); + Assert.Throws(() => + controller.ShouldSendPositionEvent(default, default, 1f)); + Assert.Throws(() => + controller.BeginMouseLook(default)); + Assert.Throws(() => + controller.SubmitMouseTurnAdjustment(1f, default)); + Assert.Throws(() => + controller.StopMouseDrift(default)); + Assert.Throws(() => + controller.EndMouseLook(default)); + Assert.Throws(() => + controller.PrepareForAttackRequest()); + Assert.Throws(() => + controller.RequestPosture(MotionCommand.Ready)); + Assert.Throws(() => + controller.CommitPreparedPosition()); + Assert.Throws(() => + controller.PreparePositionForCommit(Vector3.One, Cell, Vector3.One)); + Assert.Throws(() => + controller.SetCharacterSkills(1, 1)); + Assert.Throws(() => + controller.SetBodyOrientation(Quaternion.Identity)); + Assert.Throws(() => + controller.SetLastMoveWasAutonomous(true)); + Assert.Throws(() => + controller.State = PlayerState.PortalSpace); + Assert.Throws(() => + controller.StepUpHeight = 1f); + Assert.Throws(() => + controller.AttachCycleVelocityAccessor(static () => Vector3.One)); + Assert.Throws(() => + controller.AttachAnimationRootMotionSource(static (_, _) => { })); + Assert.Throws(() => _ = controller.Movement); + Assert.Throws(() => _ = controller.MoveTo); + Assert.Throws(() => _ = controller.PositionManager); + Assert.Throws(() => _ = controller.Motion); + Assert.Throws(() => _ = controller.PhysicsBody); + } +} From 99f867f053b0ebbf0451716b4c30164143b5b680 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 11:31:58 +0200 Subject: [PATCH 32/73] feat(runtime): seal dormant SetPosition evaluations --- ...untime-local-player-physics-publication.md | 132 +- src/AcDream.Core/Items/ClientObject.cs | 44 +- src/AcDream.Core/Items/ClientObjectTable.cs | 77 +- src/AcDream.Core/Items/HouseRestrictions.cs | 27 +- src/AcDream.Core/Physics/CellArray.cs | 13 + src/AcDream.Core/Physics/CellTransit.cs | 29 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 86 +- .../Physics/PhysicsSetPosition.cs | 3 +- .../Physics/ShadowObjectRegistry.cs | 22 + src/AcDream.Core/Physics/TransitionTypes.cs | 3 + .../Gameplay/PlayerMovementController.cs | 3 + ...ntimeLocalPlayerPhysicsPublicationState.cs | 219 +++- .../Physics/RuntimePhysicsState.cs | 131 ++ .../Physics/RuntimeSetPositionState.cs | 182 +++ .../Physics/PhysicsSetPositionTests.cs | 319 ++++- ...LocalPlayerPhysicsPublicationStateTests.cs | 1070 ++++++++++++++++- 16 files changed, 2298 insertions(+), 62 deletions(-) diff --git a/docs/research/2026-08-01-runtime-local-player-physics-publication.md b/docs/research/2026-08-01-runtime-local-player-physics-publication.md index d49be713..6146dc93 100644 --- a/docs/research/2026-08-01-runtime-local-player-physics-publication.md +++ b/docs/research/2026-08-01-runtime-local-player-physics-publication.md @@ -2,14 +2,16 @@ ## Scope -This is placement Slice 4B2 checkpoint 4. It adds the dormant, +This is placement Slice 4B2 checkpoints 4-5. It adds the dormant, presentation-independent transaction which prepares and assigns ownership of -one local-player `PhysicsBody` and `PlayerMovementController`. No App or +one local-player `PhysicsBody` and `PlayerMovementController`, then retains an +exact post-ownership activation lease which can evaluate retail SetPosition +without publishing it. No App or Headless production route invokes this transaction, so graphical and no-window game behavior is unchanged and AP-1/AD-1 remain open. -The checkpoint deliberately stops before canonical SetPosition activation. -It does not consume the prepared placement operation, enter the body into the +The checkpoints deliberately stop before canonical SetPosition activation. +They do not consume the prepared placement operation, enter the body into the physics engine, publish FullCell/world/host/shadow/workset state, or project a presentation entity. Those effects belong to the next transaction and must all use the same Runtime-owned dormant body. @@ -88,6 +90,70 @@ Repeated stores of the same body/controller do not advance their epochs; real bind, replacement, and unbind edges do. This makes ABA-shaped reference changes observable even if a later value happens to equal an earlier reference. +## Dormant SetPosition evaluation lease + +Ownership commit now returns one private activation token captured only after +the canonical body and controller stores. It binds the exact entity key, +authored placement token and sealed command, local identity GUID/revision, +session lifetime, and the post-store physics-body, object-clock, and controller +ownership epochs. The owner retains the same record, body, controller, and +command behind that token; no caller can substitute an equivalent-looking +body or rebuild the mover. + +`EvaluateActivation` revalidates that complete lease and calls Core +`PhysicsEngine.SetPosition` synchronously with an immutable request. Core's +transaction is pure: it returns committed, deferred-cell, or rejected +placement data without writing the canonical body, FullCell, clock, spatial +worksets, shadows, collision-report owners, host, operation stage, or Place +projection. A missing cell therefore leaves the exact body dormant and the +authored operation retryable. A valid result likewise remains only an +immutable evaluation receipt; this checkpoint has no activation/commit API. + +Each evaluation carries an append-only, stable-order union of every cell read +by the complete Core transaction: the AdjustPosition seed and adjusted cell, +visible-child probes (including rejected lateral siblings), rejected +portal/building containment probes, transition/compass retries, and every +normal or scatter attempt. Rejected probes enter only the authority union and +never the final successful shadow/CrossCell footprint. Scatter keeps that union +in retained scratch and materializes its +immutable receipt exactly once after the final attempt, avoiding quadratic +copy/allocation growth at the 64-attempt retail ceiling. The final +`CrossCellIds` remains the successful placement's authored +shadow footprint; failed scatter probes cannot leak into that commit payload. +Runtime seals every distinct queried landblock against the exact collision +generation, the global collision-world authority, and the dynamic-shadow +mutation revision. An active replacement admission rejects evaluation even +before it commits, while begin/cancel, a re-entrant generation commit, or any +owner insert/remove/move/state/suspend/reflood mutation invalidates an older +receipt. + +Entry restrictions also consult the live `ClientObjectTable` for the resolved +house object, owner and complete restriction record, plus the mover's monarch. +The receipt therefore seals the exact object-table reference, the engine's +monotonic binding epoch, and the table's synchronous mutation revision. Object +creation/removal, owner-property, guest-list, or mover-monarch updates invalidate +the receipt; a null/fresh replacement and an equal-revision A-B-A binding cycle +cannot resurrect it. Retained `ClientObject` owner, monarch, and restriction +setters synchronously advance every exact owning table even when callers mutate +the object directly rather than re-submit it through `AddOrUpdate`. Replacement, +removal, and clear detach that observer exactly, and every +`HouseRestrictionRecord` freezes a defensive snapshot of its input guest map so +no caller-owned dictionary or mutable downcast can alter entry authority behind +the revision. + +`IsEvaluationCurrent` accepts only the newest receipt for the exact activation +lease and rejects it after a position/vector/state/object-description/Create +authority change, identity revision, body/controller replacement, session or +incarnation change, or any sealed collision/shadow authority change. +Re-evaluation supersedes the older receipt without mutating world state. +Re-entrant reset or delete-plus-GUID-reuse during Core evaluation immediately +retires the invalid lease instead of leaving an orphaned dormant graph. An +existing activation lease also blocks candidate preparation even if an +external owner has already cleared the body/controller references; explicit +discard is required before a new candidate can be prepared. Reset and disposal +retire the lease, body, and dormant controller and include the pending +activation in the ownership convergence ledger. + ## Gates - Candidate privacy and live-operation rejection. @@ -104,23 +170,53 @@ observable even if a later value happens to equal an earlier reference. placement cancellation. - Delete plus same-GUID reincarnation. - Candidate replacement, reset, disposal, and ownership convergence. +- Pure committed/deferred/rejected SetPosition evaluation with bit-exact + body-state snapshots and no canonical, collision-report, projection, + clock, FullCell, host, shadow, workset, or operation-stage mutation. +- Complete stable-order queried-cell capture across AdjustPosition, + visible-child lookup, normal/scatter retries, map-edge/deferred, rejected, + committed, and defensive NoCell outcomes. Scatter deliberately retains + retail's RNG consumption; only its authority footprint and commit payload + are deterministic for a fixed draw sequence. +- Newest-receipt selection, active-admission rejection, collision-generation + replacement, re-entrant begin/cancel and commit invalidation, plus dynamic + shadow insert/move/state/suspend/remove invalidation. +- Exact object-table reference/revision/binding authority, including + post-evaluation and re-entrant house-object, owner, guest-list, and mover- + monarch mutations plus null/fresh/equal-revision ABA replacement. Direct + retained-object setters, replacement/removal/clear observer lifetime, shared + multi-table ownership, and frozen guest-map input are covered explicitly. +- Re-entrant reset and delete/GUID-reuse convergence plus activation-lease + overwrite prevention after an external body/controller clear. +- Post-ownership position, vector, object-description, Create, identity, + body, and controller authority replacement. - Terminal stale-controller rejection after replacement, reset, and disposal. - Body/controller epochs advance only on actual ownership changes. -Focused publication, controller, movement, and SetPosition tests pass 167/167. -The complete Runtime project passes 627/627 under invariant culture. The App -Runtime-ownership guard passes 4/4, the complete Release solution builds with -zero errors, and the complete Release solution test gate passes 10,374 tests -with 4 intentional skips. Under the machine's Swedish current culture, the -three previously known formatting assertions still fail (`0,5` versus `0.5` -and localized sky text); they are unrelated to this checkpoint. +The checkpoint-5 focused publication suite passes 70/70 and the focused Core +SetPosition suite passes 59/59. The complete Runtime project passes 666/666; +the complete Core project passes 4,230 tests / 1 skip; and the App Runtime +physics/movement ownership guard passes 6/6. The complete Release solution +builds with zero errors (three pre-existing App-test nullability warnings are +outside this checkpoint), and its invariant-culture test gate passes 10,419 +tests / 4 intentional skips. The installed prepared-package gate +uses the exact local `acdream.pak`. Under the machine's Swedish current culture, +the same three previously known formatting assertions remain unrelated (`0,5` +versus `0.5` and localized sky text), so the canonical gate runs under +invariant culture. ## Next checkpoint -Add the canonical Runtime SetPosition activation transaction. It must evaluate -and commit the already-owned dormant body, consume the exact prepared placement, -and atomically establish physics-engine/workset/shadow/FullCell/world/host -ownership before presentation receives an acknowledgement, then invoke the sole -`ActivateRuntimePublication` transition. The activation must -roll back or leave the operation retryable on every pre-commit failure and must -not construct a second body or controller. +Add the canonical Runtime SetPosition activation transaction. Before its +callback-free tail, it must prepare a presentation-independent Runtime +`PhysicsHost`/`PositionManager`/`MoveToManager` graph, the existing authoritative +authored shadow payload (without rebuilding AP-22 shapes), and an exact staged +collision-report batch. Then it may commit the already-owned dormant body, +contact/water/walkable/response state, object clock, FullCell, exact shadow, +spatial/ordinary workset, host, and SetPosition operation versions; seal the +ordered Place receipt; and invoke the sole `ActivateRuntimePublication` +transition as the final tickable edge. Collision reports and the Place observer +publish only afterward and must tolerate delete/reset re-entry without replaying +physics. Every pre-commit failure leaves the dormant lease and authored +operation retryable or rejects them; no rollback mutation and no second body or +controller are allowed. diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index f89e2e17..c7ee7060 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -179,6 +179,17 @@ public sealed class PropertyBundle /// public sealed class ClientObject { + private uint? _houseOwnerId; + private uint? _monarchId; + private HouseRestrictionRecord? _restrictions; + + /// + /// Synchronous table-internal notification for the qualities consulted by + /// retail's house-entry restriction gate. Multiple tables may retain the + /// same object during isolated evaluation fixtures; each receives the edge. + /// + internal event Action? RestrictionAuthorityChanged; + public uint ObjectId { get; init; } public uint WeenieClassId { get; set; } // "blueprint" public string Name { get; set; } = ""; @@ -273,7 +284,16 @@ public sealed class ClientObject /// object (wire WeenieHeaderFlag.Owner, 0x02000000). Zero or a /// match against the mover's own id admits regardless of the guest list. /// - public uint? HouseOwnerId { get; set; } + public uint? HouseOwnerId + { + get => _houseOwnerId; + set + { + if (_houseOwnerId == value) return; + _houseOwnerId = value; + RestrictionAuthorityChanged?.Invoke(this); + } + } /// /// AP-129 (Campaign P Slice P4 review fix): retail PublicWeenieDesc /// ._monarch_iid (wire WeenieHeaderFlag.Monarch, 0x40) — this @@ -281,7 +301,16 @@ public sealed class ClientObject /// objects; a player's own value is what RestrictionDB::IsAllowedIn /// compares against a house's . /// - public uint? MonarchId { get; set; } + public uint? MonarchId + { + get => _monarchId; + set + { + if (_monarchId == value) return; + _monarchId = value; + RestrictionAuthorityChanged?.Invoke(this); + } + } /// /// AP-129 (Campaign P Slice P4 review fix): retail PublicWeenieDesc /// ._db (RestrictionDB*) — the house's own guest/ban list. @@ -291,7 +320,16 @@ public sealed class ClientObject /// object — acdream cannot distinguish the two, and both resolve to /// the same retail-faithful "allow" default. /// - public HouseRestrictionRecord? Restrictions { get; set; } + public HouseRestrictionRecord? Restrictions + { + get => _restrictions; + set + { + if (ReferenceEquals(_restrictions, value)) return; + _restrictions = value; + RestrictionAuthorityChanged?.Invoke(this); + } + } public PropertyBundle Properties { get; } = new(); /// diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 6b83085b..97058b2a 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -116,11 +116,71 @@ public sealed class ClientObjectTable private readonly ConcurrentDictionary _containers = new(); private readonly Dictionary> _containerIndex = new(); private readonly Dictionary> _equipmentIndex = new(); + private readonly HashSet _restrictionObservedObjects = + new(ReferenceEqualityComparer.Instance); // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. private readonly Dictionary _pendingMoves = new(); + private ulong _mutationRevision; + + public ClientObjectTable() + { + // Keep one conservative authority over every object mutation that can + // affect physics entry restrictions. These handlers are registered + // before any consumer can subscribe, so re-entrant observers see the + // advanced revision before they can evaluate or publish a receipt. + ObjectAdded += _ => AdvanceMutationRevision(); + ObjectMoved += _ => AdvanceMutationRevision(); + ObjectRemoved += _ => AdvanceMutationRevision(); + ObjectUpdated += _ => AdvanceMutationRevision(); + Cleared += AdvanceMutationRevision; + } + + /// + /// Monotonic authority for live object-table qualities used by physics, + /// including house ownership/restrictions and the mover's monarch. + /// + internal ulong MutationRevision => _mutationRevision; + + private void AdvanceMutationRevision() => + _mutationRevision = checked(_mutationRevision + 1UL); + + private void RetainObject(ClientObject item) + { + if (_objects.TryGetValue(item.ObjectId, out ClientObject? prior) + && !ReferenceEquals(prior, item)) + { + UnbindRestrictionAuthority(prior); + } + + _objects[item.ObjectId] = item; + BindRestrictionAuthority(item); + } + + private void BindRestrictionAuthority(ClientObject item) + { + if (!_restrictionObservedObjects.Add(item)) return; + item.RestrictionAuthorityChanged += OnRestrictionAuthorityChanged; + } + + private void UnbindRestrictionAuthority(ClientObject item) + { + if (!_restrictionObservedObjects.Remove(item)) return; + item.RestrictionAuthorityChanged -= OnRestrictionAuthorityChanged; + } + + private void OnRestrictionAuthorityChanged(ClientObject item) + { + // Unbinding is synchronous, but keep the exact-reference check as a + // defensive lifetime gate against a stale/replaced object callback. + if (_objects.TryGetValue(item.ObjectId, out ClientObject? retained) + && ReferenceEquals(retained, item)) + { + AdvanceMutationRevision(); + } + } /// Fires when an object is first added to the session. public event Action? ObjectAdded; @@ -270,7 +330,7 @@ public sealed class ClientObjectTable ClientObjectPlacement previous = prior is null ? default : ClientObjectPlacement.From(prior); - _objects[item.ObjectId] = item; + RetainObject(item); UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(item)); if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item); @@ -615,6 +675,7 @@ public sealed class ClientObjectTable bool notifyObjectRemoved) { if (!_objects.TryRemove(itemId, out var item)) return false; + UnbindRestrictionAuthority(item); List? changedContainers = RemoveFromOtherContainerIndexes( itemId, exceptContainerId: 0u); @@ -690,7 +751,7 @@ public sealed class ClientObjectTable if (!existed || item is null) { item = new ClientObject { ObjectId = guid }; - _objects[guid] = item; + RetainObject(item); } foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; @@ -800,7 +861,7 @@ public sealed class ClientObjectTable if (!existed || obj is null) // keep: satisfies nullable flow analysis { obj = new ClientObject { ObjectId = d.Guid }; - _objects[d.Guid] = obj; + RetainObject(obj); } uint oldContainer = obj.ContainerId; ClientObjectPlacement previous = ClientObjectPlacement.From(obj); @@ -876,7 +937,7 @@ public sealed class ClientObjectTable if (!existed || obj is null) // keep: satisfies nullable flow analysis { obj = new ClientObject { ObjectId = guid }; - _objects[guid] = obj; + RetainObject(obj); } uint oldContainer = obj.ContainerId; ClientObjectPlacement previous = ClientObjectPlacement.From(obj); @@ -955,7 +1016,7 @@ public sealed class ClientObjectTable if (!existed || obj is null) { obj = new ClientObject { ObjectId = entry.Guid }; - _objects[entry.Guid] = obj; + RetainObject(obj); } ClientObjectPlacement previous = ClientObjectPlacement.From(obj); @@ -1249,7 +1310,7 @@ public sealed class ClientObjectTable if (!existed || obj is null) { obj = new ClientObject { ObjectId = entry.Guid }; - _objects[entry.Guid] = obj; + RetainObject(obj); } obj.ContainerTypeHint = entry.ContainerType; if (!existed) added.Add(obj); @@ -1361,7 +1422,7 @@ public sealed class ClientObjectTable if (!existed || obj is null) { obj = new ClientObject { ObjectId = entry.Guid }; - _objects[entry.Guid] = obj; + RetainObject(obj); } ClientObjectPlacement previous = ClientObjectPlacement.From(obj); @@ -1426,6 +1487,8 @@ public sealed class ClientObjectTable /// public void Clear() { + foreach (ClientObject item in _restrictionObservedObjects.ToArray()) + UnbindRestrictionAuthority(item); _objects.Clear(); _containers.Clear(); _containerIndex.Clear(); diff --git a/src/AcDream.Core/Items/HouseRestrictions.cs b/src/AcDream.Core/Items/HouseRestrictions.cs index 2a68fe02..84ec6321 100644 --- a/src/AcDream.Core/Items/HouseRestrictions.cs +++ b/src/AcDream.Core/Items/HouseRestrictions.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using System.Collections.Generic; namespace AcDream.Core.Items; @@ -22,11 +23,29 @@ namespace AcDream.Core.Items; /// (0 = dwelling access only, 1 = storage access also). Retail's /// IsAllowedIn only consults key membership for entry; the permission /// value is preserved for wire fidelity but not consulted here. -public sealed record HouseRestrictionRecord( - bool OpenToPublic, - uint AllegianceMonarchId, - IReadOnlyDictionary Guests) +public sealed record HouseRestrictionRecord { + public HouseRestrictionRecord( + bool OpenToPublic, + uint AllegianceMonarchId, + IReadOnlyDictionary Guests) + { + ArgumentNullException.ThrowIfNull(Guests); + this.OpenToPublic = OpenToPublic; + this.AllegianceMonarchId = AllegianceMonarchId; + this.Guests = Guests.ToFrozenDictionary(); + } + + public bool OpenToPublic { get; } + public uint AllegianceMonarchId { get; } + + /// + /// Immutable snapshot of the wire permission table. The parser's mutable + /// dictionary must never remain an untracked mutation path into live + /// collision-entry authority. + /// + public IReadOnlyDictionary Guests { get; } + /// /// Verbatim port of retail RestrictionDB::IsAllowedIn /// (named-retail pc:444493-444516, 0x005ae8f0): diff --git a/src/AcDream.Core/Physics/CellArray.cs b/src/AcDream.Core/Physics/CellArray.cs index 50a6e094..359e40fb 100644 --- a/src/AcDream.Core/Physics/CellArray.cs +++ b/src/AcDream.Core/Physics/CellArray.cs @@ -25,6 +25,14 @@ public sealed class CellArray : ICollection, IReadOnlyCollection private readonly List _order = new(); private readonly HashSet _seen = new(); + /// + /// Optional append-only union target used by one retained SetPosition + /// transaction. Clearing this CELLARRAY must not clear the target: retail + /// can rebuild the working array repeatedly while every probed cell still + /// contributes to the transaction's collision-world authority footprint. + /// + internal CellArray? UnionTarget { get; set; } + public int Count => _order.Count; public bool IsReadOnly => false; @@ -34,6 +42,11 @@ public sealed class CellArray : ICollection, IReadOnlyCollection /// Append iff not already present (retail add_cell dedup). public void Add(uint id) { + if (UnionTarget is { } target + && !ReferenceEquals(target, this)) + { + target.Add(id); + } if (_seen.Add(id)) _order.Add(id); } diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 71ded556..74042142 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -169,6 +169,7 @@ public static class CellTransit // Retail CEnvCell::find_transit_cells first asks the loaded // neighbour cell whether the sphere intersects its CellBSP. // The portal-plane side test is only the unloaded-cell load hint. + RecordUnionOnlyProbe(candidates, otherId); var otherCell = cache.GetCellStruct(otherId); if (otherCell is not null && CollisionTraversal.HasCellContainment(cache, otherCell)) @@ -446,6 +447,7 @@ public static class CellTransit if (portal.OtherPortalId < 0) continue; + RecordUnionOnlyProbe(candidates, portal.OtherCellId); var otherCell = cache.GetCellStruct(portal.OtherCellId); if (otherCell is null || !CollisionTraversal.HasCellContainment(cache, otherCell)) @@ -679,8 +681,13 @@ public static class CellTransit /// /// public static uint FindVisibleChildCell( - PhysicsDataCache cache, uint startCellId, Vector3 worldPoint, bool useStabList) + PhysicsDataCache cache, + uint startCellId, + Vector3 worldPoint, + bool useStabList, + ICollection? probedCells = null) { + probedCells?.Add(startCellId); var start = cache.GetCellStruct(startCellId); if (start is null) return 0u; @@ -691,12 +698,17 @@ public static class CellTransit { // arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465) foreach (uint id in start.VisibleCellIds) + { + probedCells?.Add(id); if (PointInCell(cache, cache.GetCellStruct(id), worldPoint)) return id; + } } else { // arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434) foreach (var portal in start.Portals) + { + probedCells?.Add(portal.OtherCellId); if (PointInCell( cache, cache.GetCellStruct(portal.OtherCellId), @@ -704,6 +716,7 @@ public static class CellTransit { return portal.OtherCellId; } + } } return 0u; @@ -1068,7 +1081,11 @@ public static class CellTransit sphereRadius)) { uint recovered = FindVisibleChildCell( - cache, currentCellId, worldSphereCenter, useStabList: true); + cache, + currentCellId, + worldSphereCenter, + useStabList: true, + (candidates as CellArray)?.UnionTarget); if (recovered != 0u && recovered != currentCellId) return recovered; } @@ -1078,6 +1095,14 @@ public static class CellTransit return currentCellId; } + private static void RecordUnionOnlyProbe( + ICollection candidates, + uint cellId) + { + if (candidates is CellArray { UnionTarget: { } queryFootprint }) + queryFootprint.Add(cellId); + } + private static int EffectiveSphereCount(IReadOnlyList worldSpheres, int numSpheres) { if (numSpheres <= 0 || worldSpheres.Count == 0) return 0; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 597bed30..7af81615 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -224,7 +224,22 @@ public sealed class PhysicsEngine /// when the restriction weenie can't be resolved — so production MUST /// wire this to the live table for the fix to actually admit anyone. /// - public ClientObjectTable? Objects { get; set; } + private ClientObjectTable? _objects; + private ulong _objectsBindingRevision; + + public ClientObjectTable? Objects + { + get => _objects; + set + { + if (ReferenceEquals(_objects, value)) + return; + _objects = value; + _objectsBindingRevision = checked(_objectsBindingRevision + 1UL); + } + } + + internal ulong ObjectsBindingRevision => _objectsBindingRevision; internal sealed record LandblockPhysics( TerrainSurface Terrain, @@ -1553,8 +1568,10 @@ public sealed class PhysicsEngine private AdjustedSetPosition AdjustSetPosition( uint seedCellId, Vector3 cellLocalPosition, - Vector3 firstWorldSphereCenter) + Vector3 firstWorldSphereCenter, + CellArray queryFootprint) { + queryFootprint.Add(seedCellId); uint low = seedCellId & 0xFFFFu; bool lowInRange = low is (>= 1u and <= 0x40u) or (>= 0x0100u and <= 0xFFFDu) @@ -1582,7 +1599,8 @@ public sealed class PhysicsEngine cache, seedCellId, firstWorldSphereCenter, - useStabList: true); + useStabList: true, + queryFootprint); if (child != 0u) { return new AdjustedSetPosition( @@ -1606,6 +1624,7 @@ public sealed class PhysicsEngine bool adjusted = LandDefs.AdjustToOutside( ref adjustedCell, ref adjustedLocal); + queryFootprint.Add(adjustedCell); bool resident = adjusted && IsLandblockTerrainResident(adjustedCell); return new AdjustedSetPosition( @@ -1635,33 +1654,54 @@ public sealed class PhysicsEngine } Transition transition = RentTransition(); + CellArray queryFootprint = + transition.SpherePath.SetPositionQueryFootprint; try { + queryFootprint.Clear(); + transition.SpherePath.CellCandidates.UnionTarget = queryFootprint; InitializeSetPositionTransition(transition, request); bool randomOnly = request.Flags.HasFlag( PhysicsSetPositionFlags.RandomScatter); + PhysicsSetPositionResult result; if (randomOnly) { - return SetScatterPositionInternal( + result = SetScatterPositionInternal( transition, request, - handleCollisions); + handleCollisions, + queryFootprint); + } + else + { + result = SetPositionInternal( + transition, + request, + handleCollisions, + queryFootprint); + if (result.Error != PhysicsSetPositionError.Ok + && request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter)) + { + result = SetScatterPositionInternal( + transition, + request, + handleCollisions, + queryFootprint); + } } - PhysicsSetPositionResult result = - SetPositionInternal(transition, request, handleCollisions); - if (result.Error != PhysicsSetPositionError.Ok - && request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter)) + // Scatter retains one append-only query union across every inner + // attempt. Materialize it exactly once at the public transaction + // boundary; copying it per attempt is quadratic at retail's + // maximum retry count. + return result with { - return SetScatterPositionInternal( - transition, - request, - handleCollisions); - } - return result; + QueriedCellIds = queryFootprint.OrderedIds.ToImmutableArray(), + }; } finally { + transition.SpherePath.CellCandidates.UnionTarget = null; ReturnTransition(transition); } } @@ -1686,7 +1726,8 @@ public sealed class PhysicsEngine private PhysicsSetPositionResult SetScatterPositionInternal( Transition transition, in PhysicsSetPositionRequest request, - Func? handleCollisions) + Func? handleCollisions, + CellArray queryFootprint) { PhysicsSetPositionResult result = ErrorResult( request, @@ -1706,7 +1747,8 @@ public sealed class PhysicsEngine result = SetPositionInternal( transition, scattered, - handleCollisions); + handleCollisions, + queryFootprint); if (result.Error == PhysicsSetPositionError.Ok) break; } @@ -1716,7 +1758,8 @@ public sealed class PhysicsEngine private PhysicsSetPositionResult SetPositionInternal( Transition transition, in PhysicsSetPositionRequest request, - Func? handleCollisions) + Func? handleCollisions, + CellArray queryFootprint) { transition.SpherePath.CellCandidates.Clear(); transition.SpherePath.ClearWalkable(); @@ -1731,7 +1774,8 @@ public sealed class PhysicsEngine AdjustedSetPosition adjusted = AdjustSetPosition( request.CellId, request.CellLocalPosition, - firstWorldCenter); + firstWorldCenter, + queryFootprint); if (!adjusted.Resident) { return new PhysicsSetPositionResult( @@ -1849,7 +1893,9 @@ public sealed class PhysicsEngine } if (spherePath.CurCellId == 0u) { - return ErrorResult(request, PhysicsSetPositionError.NoCell); + return ErrorResult( + request, + PhysicsSetPositionError.NoCell); } bool inContact = collision.ContactPlaneValid; diff --git a/src/AcDream.Core/Physics/PhysicsSetPosition.cs b/src/AcDream.Core/Physics/PhysicsSetPosition.cs index 1ac3de2c..32e8c250 100644 --- a/src/AcDream.Core/Physics/PhysicsSetPosition.cs +++ b/src/AcDream.Core/Physics/PhysicsSetPosition.cs @@ -145,7 +145,8 @@ internal readonly record struct PhysicsSetPositionResult( bool CellChanged = false, PhysicsShadowCommitAction ShadowAction = PhysicsShadowCommitAction.None, ImmutableArray CrossCellIds = default, - ImmutableArray CollidedObjectIds = default) + ImmutableArray CollidedObjectIds = default, + ImmutableArray QueriedCellIds = default) { internal bool IsSuccessful => Error == PhysicsSetPositionError.Ok; internal bool IsCommitted => diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 845f98b2..c16d100a 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -78,6 +78,7 @@ public sealed class ShadowObjectRegistry _collisionWorld.Current.ShadowOwnerFreeSlots; private readonly HashSet _prefixScratch = new(); private readonly List _removedPrefixScratch = new(); + private ulong _mutationRevision; internal event Action? OwnerMutated; internal event Action? OwnerPrefixMembershipChanged; @@ -101,6 +102,7 @@ public sealed class ShadowObjectRegistry "A populated shadow registry cannot change collision roots."); } _collisionWorld = collisionWorld; + AdvanceMutationRevision(); } internal sealed record RegistrationRecord( @@ -123,8 +125,20 @@ public sealed class ShadowObjectRegistry ? version : 0UL; + /// + /// Monotonic authority for every logical mutation of the active shadow + /// collision world. SetPosition evaluation receipts seal this value so a + /// later owner insert, removal, move, state change, suspension, reflood, + /// or cell-row replacement cannot be committed against a different world. + /// + internal ulong MutationRevision => _mutationRevision; + + private void AdvanceMutationRevision() => + _mutationRevision = checked(_mutationRevision + 1UL); + private void BumpOwnerVersion(uint entityId) { + AdvanceMutationRevision(); ulong version = checked(GetOwnerVersion(entityId) + 1UL); _ownerVersions[entityId] = version; RefreshOwnerPrefixIndex(entityId); @@ -1308,6 +1322,7 @@ public sealed class ShadowObjectRegistry DeregisterCore(entityId, publishMutation: false); RemoveOwnerPrefixMembership(entityId); _ownerVersions.Remove(entityId); + AdvanceMutationRevision(); return; } if (!_entityToCells.TryGetValue(entityId, out List? cells)) @@ -1877,6 +1892,13 @@ public sealed class ShadowObjectRegistry /// public void Clear() { + bool mutated = _cells.Count != 0 + || _entityToCells.Count != 0 + || _entityReg.Count != 0 + || _suspendedEntities.Count != 0 + || _suspendedEntityCells.Count != 0; + if (mutated) + AdvanceMutationRevision(); _cells.Clear(); _entityToCells.Clear(); _suspendedEntities.Clear(); diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 4fcac576..aace5aab 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -719,6 +719,7 @@ public sealed class SpherePath private Vector3[]? _walkableVertexStorage; private Vector3[]? _lastWalkableVertexStorage; internal readonly CellArray CellCandidates = new(); + internal readonly CellArray SetPositionQueryFootprint = new(); internal readonly CellOrderScratchArena OrderedCellScratch = new(); internal Vector3[]? RetainedWalkableVertexStorage => _walkableVertexStorage; @@ -938,6 +939,8 @@ public sealed class SpherePath if (_lastWalkableVertexStorage is not null) System.Array.Clear(_lastWalkableVertexStorage); CellCandidates.Clear(); + CellCandidates.UnionTarget = null; + SetPositionQueryFootprint.Clear(); OrderedCellScratch.ResetForReuse(); } diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 3089a972..6c6c2f24 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -704,6 +704,9 @@ public sealed class PlayerMovementController internal bool IsSealedPublicationCandidate => _publicationLifecycle is PlayerMovementControllerPublicationLifecycle.CandidateSealed; + internal bool IsRuntimeOwnedDormant => _publicationLifecycle + is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant; + internal void SealPublicationCandidate() { if (_publicationLifecycle diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs index 823cfcb6..17510ac7 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -14,6 +14,15 @@ internal enum RuntimeLocalPlayerPhysicsPublicationStatus Discarded, } +internal enum RuntimeLocalPlayerPhysicsActivationStatus +{ + Evaluated, + DeferredCell, + RejectedPlacement, + RejectedAuthority, + RejectedToken, +} + internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken( RuntimeEntityKey Entity, RuntimeEntityPlacementToken Placement, @@ -31,6 +40,33 @@ internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken( && Entity == Placement.Entity; } +internal readonly record struct RuntimeLocalPlayerPhysicsActivationToken( + RuntimeEntityKey Entity, + RuntimeEntityPlacementToken Placement, + ulong ActivationId, + uint LocalPlayerServerGuid, + long LocalPlayerIdentityRevision, + ulong PhysicsOwnershipEpoch, + ulong ObjectClockEpoch, + ulong ControllerOwnershipEpoch, + ulong SessionGenerationAuthority) +{ + internal bool IsValid => ActivationId != 0UL + && LocalPlayerServerGuid != 0u + && Placement.IsValid + && Entity == Placement.Entity; +} + +internal readonly record struct RuntimeLocalPlayerPhysicsActivationReceipt( + RuntimeLocalPlayerPhysicsActivationToken Token, + ulong EvaluationId, + RuntimeDormantSetPositionEvaluation Placement) +{ + internal bool IsValid => Token.IsValid + && EvaluationId != 0UL + && Placement.Placement == Token.Placement; +} + internal readonly record struct RuntimeLocalPlayerPhysicsCandidateSnapshot( Vector3 Position, Quaternion Orientation, @@ -45,10 +81,13 @@ public readonly record struct bool IsBound, bool IsDisposed, int CandidateCount, + int PendingActivationCount, ulong LastPublicationId) { internal bool IsConverged => !IsBound - || (IsDisposed && CandidateCount == 0); + || (IsDisposed + && CandidateCount == 0 + && PendingActivationCount == 0); } /// @@ -74,12 +113,28 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable internal required PhysicsBody Body { get; init; } } + private sealed class Activation + { + internal required RuntimeLocalPlayerPhysicsActivationToken Token + { get; init; } + internal required RuntimeEntityRecord Record { get; init; } + internal required RuntimeSetPositionCommand PlacementCommand + { get; init; } + internal required PlayerMovementController Controller { get; init; } + internal required PhysicsBody Body { get; init; } + internal RuntimeLocalPlayerPhysicsActivationReceipt Receipt + { get; set; } + } + private readonly RuntimeEntityDirectory _entities; private readonly RuntimePhysicsState _physics; private readonly RuntimeLocalPlayerMovementState _movement; private readonly RuntimeLocalPlayerIdentityState _identity; private Candidate? _candidate; + private Activation? _activation; private ulong _nextPublicationId; + private ulong _nextActivationId; + private ulong _nextEvaluationId; private bool _disposed; internal RuntimeLocalPlayerPhysicsPublicationState( @@ -161,9 +216,15 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable } internal RuntimeLocalPlayerPhysicsPublicationStatus Commit( - in RuntimeLocalPlayerPhysicsPublicationToken token) + in RuntimeLocalPlayerPhysicsPublicationToken token) => + Commit(token, out _); + + internal RuntimeLocalPlayerPhysicsPublicationStatus Commit( + in RuntimeLocalPlayerPhysicsPublicationToken token, + out RuntimeLocalPlayerPhysicsActivationToken activationToken) { ObjectDisposedException.ThrowIf(_disposed, this); + activationToken = default; if (!token.IsValid || _candidate is not { } candidate || candidate.Token != token) @@ -184,10 +245,104 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable candidate.Record.ObjectClock); candidate.Record.SetPhysicsBody(candidate.Body); _movement.CommitRuntimeOwnedController(candidate.Controller); + activationToken = new RuntimeLocalPlayerPhysicsActivationToken( + candidate.Token.Entity, + candidate.Token.Placement, + checked(++_nextActivationId), + candidate.Token.LocalPlayerServerGuid, + candidate.Token.LocalPlayerIdentityRevision, + candidate.Record.PhysicsOwnershipEpoch, + candidate.Record.ObjectClockEpoch, + _movement.ControllerOwnershipEpoch, + _entities.SessionLifetimeVersion); + _activation = new Activation + { + Token = activationToken, + Record = candidate.Record, + PlacementCommand = candidate.PlacementCommand, + Controller = candidate.Controller, + Body = candidate.Body, + }; _candidate = null; return RuntimeLocalPlayerPhysicsPublicationStatus.Committed; } + internal RuntimeLocalPlayerPhysicsActivationStatus EvaluateActivation( + in RuntimeLocalPlayerPhysicsActivationToken token, + out RuntimeLocalPlayerPhysicsActivationReceipt receipt) + { + ObjectDisposedException.ThrowIf(_disposed, this); + receipt = default; + if (!token.IsValid + || _activation is not { } activation + || activation.Token != token) + { + return RuntimeLocalPlayerPhysicsActivationStatus.RejectedToken; + } + if (!IsActivationCurrent(activation)) + { + DiscardActivation(); + return RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority; + } + if (!_physics.SetPosition.TryEvaluateDormantLocalActivation( + activation.Record, + activation.Body, + token.Placement, + activation.PlacementCommand, + out RuntimeDormantSetPositionEvaluation placement)) + { + if (ReferenceEquals(_activation, activation) + && !IsActivationCurrent(activation)) + { + DiscardActivation(); + } + return RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority; + } + + receipt = new RuntimeLocalPlayerPhysicsActivationReceipt( + token, + checked(++_nextEvaluationId), + placement); + activation.Receipt = receipt; + if (placement.Result.IsDeferred) + return RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell; + return placement.Result.IsCommitted + ? RuntimeLocalPlayerPhysicsActivationStatus.Evaluated + : RuntimeLocalPlayerPhysicsActivationStatus.RejectedPlacement; + } + + internal bool IsEvaluationCurrent( + in RuntimeLocalPlayerPhysicsActivationReceipt receipt) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!receipt.IsValid + || _activation is not { } activation + || activation.Token != receipt.Token + || activation.Receipt != receipt) + { + return false; + } + return IsActivationCurrent(activation) + && _physics.SetPosition.IsDormantLocalEvaluationCurrent( + activation.Record, + activation.Body, + receipt.Placement); + } + + internal bool DiscardActivation( + in RuntimeLocalPlayerPhysicsActivationToken token) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!token.IsValid + || _activation is not { } activation + || activation.Token != token) + { + return false; + } + DiscardActivation(); + return true; + } + internal RuntimeLocalPlayerPhysicsPublicationStatus Discard( in RuntimeLocalPlayerPhysicsPublicationToken token) { @@ -231,12 +386,14 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable IsBound: true, _disposed, _candidate is null ? 0 : 1, + _activation is null ? 0 : 1, _nextPublicationId); internal void ResetSession() { ObjectDisposedException.ThrowIf(_disposed, this); DiscardCurrent(); + DiscardActivation(); } public void Dispose() @@ -244,6 +401,7 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable if (_disposed) return; DiscardCurrent(); + DiscardActivation(); _disposed = true; } @@ -251,7 +409,8 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable RuntimeEntityRecord record, in RuntimeEntityPlacementToken placement, in RuntimeSetPositionCommand command) => - record.Key is { } key + _activation is null + && record.Key is { } key && key == placement.Entity && _entities.IsCurrent(record) && !record.DeleteAcceptedForTeardown @@ -276,7 +435,8 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable command); private bool IsCurrent(Candidate candidate) => - candidate.Controller.IsSealedPublicationCandidate + _activation is null + && candidate.Controller.IsSealedPublicationCandidate && candidate.Controller.OwnsPhysicsBody(candidate.Body) && _entities.SessionLifetimeVersion == candidate.Token.SessionGenerationAuthority @@ -313,4 +473,55 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable _candidate = null; candidate?.Controller.DiscardRuntimeCandidate(); } + + private bool IsActivationCurrent(Activation activation) => + activation.Controller.IsRuntimeOwnedDormant + && activation.Controller.OwnsPhysicsBody(activation.Body) + && _entities.SessionLifetimeVersion + == activation.Token.SessionGenerationAuthority + && _entities.IsCurrent(activation.Record) + && activation.Record.Key == activation.Token.Entity + && !_identity.IsDisposed + && _identity.ServerGuid == activation.Token.LocalPlayerServerGuid + && _identity.ServerGuid == activation.Record.ServerGuid + && _identity.Revision == activation.Token.LocalPlayerIdentityRevision + && activation.Record.PhysicsOwnershipEpoch + == activation.Token.PhysicsOwnershipEpoch + && activation.Record.ObjectClockEpoch + == activation.Token.ObjectClockEpoch + && _movement.CanCommitRuntimeOwnedController( + activation.Token.ControllerOwnershipEpoch, + activation.Controller) + && ReferenceEquals(activation.Record.PhysicsBody, activation.Body) + && activation.Record.PhysicsHost is null + && activation.Record.RemoteMotion is null + && activation.Record.Projectile is null + && !activation.Record.PhysicsBodyAcquisitionInProgress + && !activation.Record.RemoteMotionBindingInProgress + && !activation.Record.ProjectileBindingInProgress + && !activation.Record.RequiresRemotePlacementRuntime + && !activation.Record.DeleteAcceptedForTeardown + && _physics.SetPosition.IsExactPreparedPlacementCurrent( + activation.Record, + activation.Token.Placement, + activation.PlacementCommand); + + private void DiscardActivation() + { + Activation? activation = _activation; + _activation = null; + if (activation is not null + && _entities.IsCurrent(activation.Record) + && ReferenceEquals( + activation.Record.PhysicsBody, + activation.Body)) + { + _entities.SetPhysicsBody(activation.Record, null); + } + if (activation is not null + && ReferenceEquals(_movement.Controller, activation.Controller)) + { + _movement.Controller = null; + } + } } diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index e67465c5..285566dd 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using AcDream.Core.Items; using AcDream.Core.Physics; using AcDream.Runtime.Entities; @@ -1042,6 +1044,7 @@ public sealed class RuntimePhysicsState : IDisposable private bool _suppressCollisionOwnerJournal; private long _nextCollisionPreparationSequence; private long _latestCollisionPreparationStartSequence; + private ulong _collisionWorldAuthority = 1UL; private readonly List> _collisionGenerationCommittedObservers = new(); private bool _disposed; @@ -1898,6 +1901,7 @@ public sealed class RuntimePhysicsState : IDisposable EnsureNotDisposed(); EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); + AdvanceCollisionWorldAuthority(); if (_collisionAdmissions.Remove( canonical, out RuntimeCollisionAdmission? superseded)) @@ -2013,6 +2017,7 @@ public sealed class RuntimePhysicsState : IDisposable admission.Generation); _collisionGenerations[admission.LandblockId] = checked( admission.Generation + 1UL); + AdvanceCollisionWorldAuthority(); } TrimCollisionOwnerJournal(); } @@ -2226,6 +2231,7 @@ public sealed class RuntimePhysicsState : IDisposable try { Engine.CommitLandblockReplacement(replacement); + AdvanceCollisionWorldAuthority(); } finally { @@ -2496,6 +2502,7 @@ public sealed class RuntimePhysicsState : IDisposable private void InvalidateCollisionAdmission(uint landblockId) { + AdvanceCollisionWorldAuthority(); ulong currentGeneration = _collisionGenerations.TryGetValue( landblockId, out ulong current) @@ -2540,6 +2547,130 @@ public sealed class RuntimePhysicsState : IDisposable : 1UL; } + /// + /// Exact collision-prefix generation authority used by private + /// SetPosition evaluations. Beginning a replacement generation advances + /// this authority immediately, so a receipt cannot survive a prepared + /// world replacement and later observe different collision rows. + /// + internal ulong CollisionGenerationAuthority(uint exactCellId) + { + uint landblockId = CanonicalLandblock(exactCellId); + return landblockId != 0u + && _collisionGenerations.TryGetValue( + landblockId, + out ulong generation) + ? generation + : 0UL; + } + + internal ulong CollisionWorldAuthority => _collisionWorldAuthority; + + internal ulong ShadowWorldAuthority => + Engine.ShadowObjects.MutationRevision; + + internal ClientObjectTable? ObjectTable => Engine.Objects; + + internal ulong ObjectTableBindingAuthority => + Engine.ObjectsBindingRevision; + + internal ulong ObjectTableAuthority => + ObjectTable?.MutationRevision ?? 0UL; + + private void AdvanceCollisionWorldAuthority() => + _collisionWorldAuthority = checked(_collisionWorldAuthority + 1UL); + + internal bool TrySealCollisionEvaluationAuthority( + in PhysicsSetPositionResult result, + ulong expectedCollisionWorldAuthority, + ulong expectedShadowWorldAuthority, + ClientObjectTable? expectedObjectTable, + ulong expectedObjectTableBindingAuthority, + ulong expectedObjectTableAuthority, + out RuntimeCollisionEvaluationAuthority authority) + { + authority = default; + if (_collisionWorldAuthority != expectedCollisionWorldAuthority + || ShadowWorldAuthority != expectedShadowWorldAuthority + || !ReferenceEquals(ObjectTable, expectedObjectTable) + || ObjectTableBindingAuthority + != expectedObjectTableBindingAuthority + || ObjectTableAuthority != expectedObjectTableAuthority) + { + return false; + } + + var prefixes = new HashSet(); + var authorities = ImmutableArray.CreateBuilder< + RuntimeCollisionGenerationAuthority>(); + void Add(uint cellId) + { + uint landblock = CanonicalLandblock(cellId); + if (landblock == 0u || !prefixes.Add(landblock)) + return; + authorities.Add(new RuntimeCollisionGenerationAuthority( + landblock, + CollisionGenerationAuthority(landblock))); + } + + Add(result.CellId); + if (!result.QueriedCellIds.IsDefaultOrEmpty) + { + foreach (uint cellId in result.QueriedCellIds) + Add(cellId); + } + foreach (uint prefix in prefixes) + { + if (_collisionAdmissions.ContainsKey(prefix)) + return false; + } + + if (_collisionWorldAuthority != expectedCollisionWorldAuthority + || ShadowWorldAuthority != expectedShadowWorldAuthority + || !ReferenceEquals(ObjectTable, expectedObjectTable) + || ObjectTableBindingAuthority + != expectedObjectTableBindingAuthority + || ObjectTableAuthority != expectedObjectTableAuthority) + { + return false; + } + authority = new RuntimeCollisionEvaluationAuthority( + expectedCollisionWorldAuthority, + expectedShadowWorldAuthority, + expectedObjectTable, + expectedObjectTableBindingAuthority, + expectedObjectTableAuthority, + authorities.ToImmutable()); + return true; + } + + internal bool IsCollisionEvaluationAuthorityCurrent( + in RuntimeCollisionEvaluationAuthority authority) + { + if (!authority.IsValid + || _collisionWorldAuthority != authority.CollisionWorldAuthority + || ShadowWorldAuthority != authority.ShadowWorldAuthority + || !ReferenceEquals(ObjectTable, authority.ObjectTable) + || ObjectTableBindingAuthority + != authority.ObjectTableBindingAuthority + || ObjectTableAuthority != authority.ObjectTableAuthority) + { + return false; + } + foreach (RuntimeCollisionGenerationAuthority generation + in authority.Generations) + { + if (generation.LandblockId == 0u + || _collisionAdmissions.ContainsKey(generation.LandblockId) + || CollisionGenerationAuthority(generation.LandblockId) + != generation.Generation) + { + return false; + } + } + return true; + } + internal bool HandleSetPositionCollisions( RuntimeEntityRecord record, ulong positionAuthorityVersion, diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index a58bd2a1..b20e2070 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Numerics; +using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime.Entities; @@ -82,6 +83,32 @@ internal readonly record struct RuntimeSetPositionCommand( float ShadowWorldOffsetY = 0f, RuntimePortalPlacementAuthority Portal = default); +internal readonly record struct RuntimeCollisionGenerationAuthority( + uint LandblockId, + ulong Generation); + +internal readonly record struct RuntimeCollisionEvaluationAuthority( + ulong CollisionWorldAuthority, + ulong ShadowWorldAuthority, + ClientObjectTable? ObjectTable, + ulong ObjectTableBindingAuthority, + ulong ObjectTableAuthority, + ImmutableArray Generations) +{ + internal bool IsValid => CollisionWorldAuthority != 0UL + && !Generations.IsDefault; +} + +internal readonly record struct RuntimeDormantSetPositionEvaluation( + RuntimeEntityPlacementToken Placement, + RuntimeSetPositionCommand Command, + PhysicsSetPositionResult Result, + RuntimeCollisionEvaluationAuthority CollisionAuthority) +{ + internal bool IsValid => Placement.IsValid + && CollisionAuthority.IsValid; +} + public readonly record struct RuntimePlacementProjectionToken( ulong Sequence, ulong Revision, @@ -597,6 +624,98 @@ internal sealed class RuntimeSetPositionState : IDisposable && IsPreparationAuthorityCurrent(operation, authority); } + /// + /// Evaluates the exact authored local-player placement without mutating + /// the canonical dormant body or any Runtime ownership index. Core's + /// SetPosition transaction is pure; collision reporting is deliberately + /// omitted until the later atomic activation commit. + /// + internal bool TryEvaluateDormantLocalActivation( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeEntityPlacementToken token, + in RuntimeSetPositionCommand command, + out RuntimeDormantSetPositionEvaluation evaluation) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(body); + evaluation = default; + if (!IsExactDormantLocalActivationCurrent( + record, + body, + token, + command, + out Operation? operation)) + { + return false; + } + + PhysicsSetPositionRequest canonicalRequest = command.Physics with + { + MoverPhysicsState = record.FinalPhysicsState, + MovingEntityId = token.Entity.LocalEntityId, + CurrentCellId = null, + }; + if (!IsStructurallyValid(canonicalRequest)) + return false; + + var canonicalCommand = command with { Physics = canonicalRequest }; + ulong collisionWorldAuthority = _physics.CollisionWorldAuthority; + ulong shadowWorldAuthority = _physics.ShadowWorldAuthority; + ClientObjectTable? objectTable = _physics.ObjectTable; + ulong objectTableBindingAuthority = + _physics.ObjectTableBindingAuthority; + ulong objectTableAuthority = objectTable?.MutationRevision ?? 0UL; + PhysicsSetPositionResult result = _physics.Engine.SetPosition( + canonicalRequest, + handleCollisions: null); + if (!IsExactDormantLocalActivationCurrent( + record, + body, + token, + command, + out Operation? current) + || !ReferenceEquals(current, operation)) + { + return false; + } + + if (!_physics.TrySealCollisionEvaluationAuthority( + result, + collisionWorldAuthority, + shadowWorldAuthority, + objectTable, + objectTableBindingAuthority, + objectTableAuthority, + out RuntimeCollisionEvaluationAuthority collisionAuthority)) + { + return false; + } + + evaluation = new RuntimeDormantSetPositionEvaluation( + token, + canonicalCommand, + result, + collisionAuthority); + return true; + } + + internal bool IsDormantLocalEvaluationCurrent( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeDormantSetPositionEvaluation evaluation) => + evaluation.IsValid + && IsExactDormantLocalActivationCurrent( + record, + body, + evaluation.Placement, + evaluation.Command, + out _, + allowCanonicalCommand: true) + && _physics.IsCollisionEvaluationAuthorityCurrent( + evaluation.CollisionAuthority); + internal RuntimeSetPositionOutcome SubmitPreparedPlacement( in RuntimeEntityPlacementToken token, in RuntimeSetPositionCommand command) => @@ -1881,6 +2000,69 @@ internal sealed class RuntimeSetPositionState : IDisposable && operation.Record.PlacementCommitVersion == operation.PlacementCommitVersion; + private bool IsExactDormantLocalActivationCurrent( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeEntityPlacementToken token, + in RuntimeSetPositionCommand command, + out Operation? operation, + bool allowCanonicalCommand = false) + { + operation = null; + if (!token.IsValid + || token.Entity != record.Key + || token.PreparationKind + is not RuntimeEntityPlacementPreparationKind.AuthoredMover + || command.Kind is not (RuntimeSetPositionOperationKind.InitialLogin + or RuntimeSetPositionOperationKind.LocalAuthoritative) + || command.Portal != default + && !command.Portal.IsValid + || !_operations.TryGetValue(token.Entity, out operation) + || operation.Token != token + || operation.Stage + is not RuntimeEntityPlacementStage.AwaitingPreparation + || !ReferenceEquals(operation.Record, record) + || !IsCurrent(operation) + || !ReferenceEquals(record.PhysicsBody, body) + || body.InWorld + || (body.TransientState & TransientStateFlags.Active) != 0 + || record.PhysicsHost is not null + || record.RemoteMotion is not null + || record.Projectile is not null + || record.PhysicsBodyAcquisitionInProgress + || record.RemoteMotionBindingInProgress + || record.ProjectileBindingInProgress + || record.RequiresRemotePlacementRuntime + || record.DeleteAcceptedForTeardown + || _physics.IsSpatialRoot(record) + || !_moverPreparationAuthorities.TryGetValue( + token.Entity, + out MoverPreparationAuthority authority) + || authority.OperationId != token.OperationId + || !authority.Prepared + || !IsPreparationAuthorityCurrent(operation, authority)) + { + operation = null; + return false; + } + + if (authority.PreparedCommand == command) + return true; + if (!allowCanonicalCommand) + return false; + + RuntimeSetPositionCommand authored = authority.PreparedCommand; + return authored with + { + Physics = authored.Physics with + { + MoverPhysicsState = record.FinalPhysicsState, + MovingEntityId = token.Entity.LocalEntityId, + CurrentCellId = null, + }, + } == command; + } + private bool IsVelocityCurrent(Operation operation) => operation.SourceVelocityAuthorityVersion == 0UL || operation.Record.VelocityAuthorityVersion diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs index 6b88f5ef..7b5f3672 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; using System.Numerics; using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; namespace AcDream.Core.Tests.Physics; @@ -32,7 +34,7 @@ public sealed class PhysicsSetPositionTests var local = new Vector3(193f, 12f, 7f); uint expectedCell = Cell; Vector3 expectedLocal = local; - Assert.True(LandDefs.AdjustToOutside( + Assert.True(AcDream.Core.Physics.LandDefs.AdjustToOutside( ref expectedCell, ref expectedLocal)); @@ -46,6 +48,9 @@ public sealed class PhysicsSetPositionTests Assert.Equal(expectedCell, result.CellId); Assert.Equal(expectedLocal, result.CellLocalPosition); Assert.Equal(new Vector3(193f, 12f, 7f), result.Position); + Assert.Equal( + new[] { Cell, expectedCell }.Distinct(), + result.QueriedCellIds); } [Fact] @@ -82,6 +87,194 @@ public sealed class PhysicsSetPositionTests Assert.Equal(0u, result.CellId); Assert.Equal(local, result.CellLocalPosition); Assert.Equal(local, result.Position); + Assert.Equal(new[] { southWestCell, 0u }, result.QueriedCellIds); + } + + [Fact] + public void QueryFootprintIncludesAdjustPositionVisibleChildProbes() + { + const uint start = Landblock | 0x0101u; + const uint sibling = Landblock | 0x0102u; + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.DataCache.RegisterCellStructForTest( + start, + ContainmentCell( + new Plane(new Vector3(0f, -1f, 0f), 3f), + [sibling])); + engine.DataCache.RegisterCellStructForTest( + sibling, + ContainmentCell( + new Plane(new Vector3(0f, 1f, 0f), -7f), + [])); + + PhysicsSetPositionResult result = engine.SetPosition( + Request( + start, + new Vector3(0f, 8f, 1f), + new Vector3(0f, 8f, 1f)) with + { + MoverPhysicsState = PhysicsStateFlags.Missile, + }); + + Assert.Contains(start, result.QueriedCellIds); + Assert.Contains(sibling, result.QueriedCellIds); + Assert.True( + result.QueriedCellIds.IndexOf(start) + < result.QueriedCellIds.IndexOf(sibling)); + } + + [Fact] + public void LateralVisibleChildRecoveryRecordsRejectedAndWinningSiblingsUnionOnly() + { + const uint start = Landblock | 0x0101u; + const uint rejected = Landblock | 0x0102u; + const uint winner = Landblock | 0x0103u; + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest( + start, + ContainmentCell( + new Plane(new Vector3(0f, -1f, 0f), 3f), + [rejected, winner])); + cache.RegisterCellStructForTest( + rejected, + ContainmentCell( + new Plane(new Vector3(0f, 1f, 0f), -20f), + [])); + cache.RegisterCellStructForTest( + winner, + ContainmentCell( + new Plane(new Vector3(0f, 1f, 0f), -7f), + [])); + var candidates = new CellArray(); + var queryFootprint = new CellArray(); + candidates.UnionTarget = queryFootprint; + var spheres = new[] + { + new Sphere + { + Origin = new Vector3(0f, 8f, 1f), + Radius = 0.48f, + }, + }; + + uint containing = CellTransit.FindCellSet( + cache, + spheres, + spheres.Length, + start, + candidates); + + Assert.Equal(winner, containing); + Assert.Equal(new[] { start }, candidates.OrderedIds); + Assert.Equal( + new[] { start, rejected, winner }, + queryFootprint.OrderedIds); + } + + [Fact] + public void RejectedPortalContainmentProbeIsRecordedUnionOnly() + { + const uint start = Landblock | 0x0101u; + const uint rejected = Landblock | 0x0102u; + const ushort portalPolygonId = 10; + var portalPolygon = new ResolvedPolygon + { + Id = portalPolygonId, + Vertices = + [ + new Vector3(10f, -1f, 0f), + new Vector3(10f, 1f, 0f), + new Vector3(10f, 1f, 2f), + ], + Plane = new Plane(Vector3.UnitX, -10f), + NumPoints = 3, + SidesType = CullMode.None, + }; + var startCell = new CellPhysics + { + BSP = new PhysicsBSPTree + { + Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf }, + }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree + { + Root = new CellBSPNode { Type = BSPNodeType.Leaf }, + }, + Portals = + [ + new PortalInfo( + (ushort)(rejected & 0xFFFFu), + portalPolygonId, + 0), + ], + PortalPolygons = new Dictionary + { + [portalPolygonId] = portalPolygon, + }, + }; + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(start, startCell); + cache.RegisterCellStructForTest( + rejected, + ContainmentCell( + new Plane(Vector3.UnitX, -100f), + [])); + var candidates = new CellArray(); + var queryFootprint = new CellArray(); + candidates.UnionTarget = queryFootprint; + var spheres = new[] + { + new Sphere + { + Origin = Vector3.Zero, + Radius = 0.48f, + }, + }; + + uint containing = CellTransit.FindCellSet( + cache, + spheres, + spheres.Length, + start, + candidates); + + Assert.Equal(start, containing); + Assert.Equal(new[] { start }, candidates.OrderedIds); + Assert.Equal(new[] { start, rejected }, queryFootprint.OrderedIds); + } + + [Fact] + public void RejectedBuildingContainmentProbeIsRecordedUnionOnly() + { + const uint rejected = Landblock | 0x0102u; + var building = new BuildingPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Portals = + [ + new BldPortalInfo( + rejected, + otherPortalId: 0, + flags: 0), + ], + }; + var candidates = new CellArray(); + var queryFootprint = new CellArray(); + candidates.UnionTarget = queryFootprint; + + CellTransit.CheckBuildingTransit( + new PhysicsDataCache(), + building, + Vector3.Zero, + sphereRadius: 0.48f, + candidates); + + Assert.Empty(candidates); + Assert.Equal(new[] { rejected }, queryFootprint.OrderedIds); } [Fact] @@ -867,6 +1060,91 @@ public sealed class PhysicsSetPositionTests Assert.Equal(new Vector3(11f, 11f, 10f), result.Position); } + [Fact] + public void NormalThenScatterUnionsEveryLandblockProbeInStableOrder() + { + PhysicsEngine engine = FlatEngine(); + const uint eastLandblock = 0xAAB40000u; + AddFlatLandblock(engine, eastLandblock, worldOffsetX: 192f); + int pass = 0; + engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => phase + is TransitionCellCollisionPhase.Environment + && pass++ == 0 + ? TransitionState.Collided + : observed; + var random = new Queue([1d, 0.5d]); + engine.SetPositionRandomUnit = random.Dequeue; + + PhysicsSetPositionResult result = engine.SetPosition( + Request( + Cell, + new Vector3(180f, 12f, 7f), + new Vector3(180f, 12f, 7f)) with + { + MoverPhysicsState = PhysicsStateFlags.Missile, + Flags = PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Scatter, + ScatterRadiusX = 20f, + ScatterAttempts = 1u, + }); + + Assert.True(result.IsCommitted); + int source = IndexOfLandblock(result.QueriedCellIds, Landblock); + int east = IndexOfLandblock( + result.QueriedCellIds, + eastLandblock); + Assert.True(source >= 0); + Assert.True(east > source); + } + + [Fact] + public void MultiScatterUnionsFailedAndSuccessfulAttemptLandblocks() + { + PhysicsEngine engine = FlatEngine(); + const uint westLandblock = 0xA8B40000u; + const uint eastLandblock = 0xAAB40000u; + AddFlatLandblock(engine, westLandblock, worldOffsetX: -192f); + AddFlatLandblock(engine, eastLandblock, worldOffsetX: 192f); + int pass = 0; + engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => phase + is TransitionCellCollisionPhase.Environment + && pass++ == 0 + ? TransitionState.Collided + : observed; + var random = new Queue([0d, 0.5d, 1d, 0.5d]); + engine.SetPositionRandomUnit = random.Dequeue; + + PhysicsSetPositionResult result = engine.SetPosition( + Request( + Cell, + new Vector3(10f, 12f, 7f), + new Vector3(10f, 12f, 7f)) with + { + MoverPhysicsState = PhysicsStateFlags.Missile, + Flags = PhysicsSetPositionFlags.RandomScatter, + ScatterRadiusX = 200f, + ScatterAttempts = 2u, + }); + + Assert.True(result.IsCommitted); + int west = IndexOfLandblock( + result.QueriedCellIds, + westLandblock); + int east = IndexOfLandblock( + result.QueriedCellIds, + eastLandblock); + Assert.True(west >= 0); + Assert.True(east > west); + Assert.DoesNotContain( + result.CrossCellIds, + cell => (cell & 0xFFFF0000u) == westLandblock); + Assert.Contains( + result.CrossCellIds, + cell => (cell & 0xFFFF0000u) == eastLandblock); + } + [Fact] public void Scatter_ReusesOneTransitionAndFailedInnerProbeCannotLeakIntoSuccess() { @@ -1038,4 +1316,43 @@ public sealed class PhysicsSetPositionTests worldOffsetX, worldOffsetY); } + + private static CellPhysics ContainmentCell( + Plane plane, + uint[] visibleCells) => new() + { + BSP = new PhysicsBSPTree + { + Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf }, + }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree + { + Root = new CellBSPNode + { + SplittingPlane = plane, + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }, + }, + Portals = [new PortalInfo(0xFFFF, 0, 0)], + PortalPolygons = new Dictionary(), + VisibleCellIds = new HashSet(visibleCells), + }; + + private static int IndexOfLandblock( + ImmutableArray cells, + uint landblock) + { + for (int index = 0; index < cells.Length; index++) + { + if ((cells[index] & 0xFFFF0000u) + == (landblock & 0xFFFF0000u)) + { + return index; + } + } + return -1; + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs index 6ab1e808..c9ed432f 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Numerics; +using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -114,6 +115,761 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests fixture.Owner.Commit(token)); } + [Fact] + public void DeferredActivationEvaluationLeavesExactOwnedGraphDormantAndRetryable() + { + using var fixture = new Fixture(); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + PhysicsBody body = fixture.Record.PhysicsBody!; + Vector3 position = body.Position; + Quaternion orientation = body.Orientation; + EvaluationPuritySnapshot before = CaptureEvaluationPurity(fixture); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var receipt)); + AssertEvaluationPurity(before, CaptureEvaluationPurity(fixture)); + + Assert.True(receipt.IsValid); + Assert.True(fixture.Owner.IsEvaluationCurrent(receipt)); + Assert.False(body.InWorld); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); + Assert.Equal(position, body.Position); + Assert.Equal(orientation, body.Orientation); + Assert.Equal(Cell, fixture.Record.FullCellId); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership() + .RetainedShadowRegistrationCount); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + AssertNotLive(fixture.Movement.Controller!); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var retry)); + AssertEvaluationPurity(before, CaptureEvaluationPurity(fixture)); + Assert.True(retry.EvaluationId > receipt.EvaluationId); + } + + [Fact] + public void CommittedEvaluationReceiptRemainsPureAndBindsExactDormantAuthority() + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + PhysicsBody body = fixture.Record.PhysicsBody!; + ulong clockEpoch = fixture.Record.ObjectClockEpoch; + ulong placementCommit = fixture.Record.PlacementCommitVersion; + uint fullCell = fixture.Record.FullCellId; + Vector3 bodyPosition = body.Position; + Quaternion bodyOrientation = body.Orientation; + Assert.Equal(fixture.Record.Key, token.Entity); + Assert.Equal(fixture.Record.PhysicsOwnershipEpoch, + token.PhysicsOwnershipEpoch); + Assert.Equal(fixture.Record.ObjectClockEpoch, token.ObjectClockEpoch); + Assert.Equal(fixture.Movement.ControllerOwnershipEpoch, + token.ControllerOwnershipEpoch); + EvaluationPuritySnapshot before = CaptureEvaluationPurity(fixture); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var receipt)); + AssertEvaluationPurity(before, CaptureEvaluationPurity(fixture)); + Assert.True(receipt.Placement.Result.IsCommitted); + Assert.True(fixture.Owner.IsEvaluationCurrent(receipt)); + Assert.False(body.InWorld); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); + Assert.Equal(bodyPosition, body.Position); + Assert.Equal(bodyOrientation, body.Orientation); + Assert.Equal(fullCell, fixture.Record.FullCellId); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(clockEpoch, fixture.Record.ObjectClockEpoch); + Assert.Equal(placementCommit, fixture.Record.PlacementCommitVersion); + Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership() + .RetainedShadowRegistrationCount); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out _)); + AssertNotLive(fixture.Movement.Controller!); + } + + [Fact] + public void RejectedPlacementReceiptIsPureAndRetryableUnderSameLease() + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + PhysicsBody body = fixture.Record.PhysicsBody!; + Vector3 position = body.Position; + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + static (_, _, _, _) => TransitionState.Collided; + EvaluationPuritySnapshot before = CaptureEvaluationPurity(fixture); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedPlacement, + fixture.Owner.EvaluateActivation(token, out var rejected)); + AssertEvaluationPurity(before, CaptureEvaluationPurity(fixture)); + Assert.True(rejected.IsValid); + Assert.True(fixture.Owner.IsEvaluationCurrent(rejected)); + Assert.False(body.InWorld); + Assert.Equal(position, body.Position); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = null; + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var retry)); + AssertEvaluationPurity(before, CaptureEvaluationPurity(fixture)); + Assert.True(fixture.Owner.IsEvaluationCurrent(retry)); + Assert.False(fixture.Owner.IsEvaluationCurrent(rejected)); + } + + [Fact] + public void StaleActivationReceiptCannotPublishAndNextEvaluationRetiresDormantOwners() + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var receipt)); + fixture.Lifetime.Entities.SetFinalPhysicsState( + fixture.Record, + fixture.Record.FinalPhysicsState | PhysicsStateFlags.Frozen); + + Assert.False(fixture.Owner.IsEvaluationCurrent(receipt)); + PhysicsBody staleBody = fixture.Record.PhysicsBody!; + Assert.False(staleBody.InWorld); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out _)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority, + fixture.Owner.EvaluateActivation(token, out _)); + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + Assert.Equal(Cell, fixture.Record.FullCellId); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership() + .RetainedShadowRegistrationCount); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + } + + [Fact] + public void CollisionAdmissionRejectsEvaluationUntilReplacementCommits() + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var receipt)); + PhysicsBody body = fixture.Record.PhysicsBody!; + + RuntimeCollisionAdmission admission = fixture.Lifetime.Physics + .BeginCollisionAdmission(Cell & 0xFFFF0000u); + + Assert.False(fixture.Owner.IsEvaluationCurrent(receipt)); + Assert.False(body.InWorld); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority, + fixture.Owner.EvaluateActivation(token, out _)); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + + using PreparedLandblockCollisionGeneration prepared = fixture + .Lifetime.Physics.PrepareCollisionGeneration(admission); + fixture.Lifetime.Physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(Cell & 0xFFFF0000u)); + Assert.True(CommitPrepared( + fixture.Lifetime.Physics, + admission, + prepared).Committed); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var replacement)); + Assert.True(fixture.Owner.IsEvaluationCurrent(replacement)); + } + + [Fact] + public void ReentrantCollisionWorldMutationRejectsOtherwiseCurrentEvaluation() + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + bool mutated = false; + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => + { + if (!mutated + && phase is TransitionCellCollisionPhase.Environment) + { + mutated = true; + RuntimeCollisionAdmission admission = fixture.Lifetime + .Physics.BeginCollisionAdmission(0x01010000u); + fixture.Lifetime.Physics.CancelCollisionGeneration( + admission); + } + return observed; + }; + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority, + fixture.Owner.EvaluateActivation(token, out _)); + Assert.True(mutated); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = null; + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var replacement)); + Assert.True(fixture.Owner.IsEvaluationCurrent(replacement)); + } + + [Fact] + public void ReentrantCollisionGenerationCommitRejectsOtherwiseCurrentEvaluation() + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + + RuntimeCollisionAdmission admission = fixture.Lifetime.Physics + .BeginCollisionAdmission(0x01010000u); + using PreparedLandblockCollisionGeneration prepared = fixture + .Lifetime.Physics.PrepareCollisionGeneration(admission); + fixture.Lifetime.Physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(0x01010000u)); + bool committed = false; + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => + { + if (!committed + && phase is TransitionCellCollisionPhase.Environment) + { + committed = CommitPrepared( + fixture.Lifetime.Physics, + admission, + prepared).Committed; + } + return observed; + }; + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority, + fixture.Owner.EvaluateActivation(token, out _)); + Assert.True(committed); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = null; + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var replacement)); + Assert.True(fixture.Owner.IsEvaluationCurrent(replacement)); + } + + [Theory] + [InlineData(ActivationInvalidation.Position)] + [InlineData(ActivationInvalidation.Vector)] + [InlineData(ActivationInvalidation.ObjectDescription)] + [InlineData(ActivationInvalidation.Create)] + [InlineData(ActivationInvalidation.Identity)] + [InlineData(ActivationInvalidation.Body)] + [InlineData(ActivationInvalidation.Controller)] + [InlineData(ActivationInvalidation.Clock)] + [InlineData(ActivationInvalidation.Spatial)] + [InlineData(ActivationInvalidation.Host)] + [InlineData(ActivationInvalidation.Remote)] + [InlineData(ActivationInvalidation.Projectile)] + [InlineData(ActivationInvalidation.PlacementCancellation)] + public void EveryPostOwnershipAuthorityReplacementInvalidatesEvaluation( + ActivationInvalidation invalidation) + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var receipt)); + + switch (invalidation) + { + case ActivationInvalidation.Position: + Assert.True(fixture.Lifetime.Physics.SetPosition + .BeginAuthoredPlacement( + fixture.Record, + fixture.Record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative) + .IsValid); + break; + case ActivationInvalidation.Vector: + fixture.Lifetime.Entities.AdvanceVectorAuthority(fixture.Record); + break; + case ActivationInvalidation.ObjectDescription: + fixture.Lifetime.Entities.AdvanceObjDescAuthority(fixture.Record); + break; + case ActivationInvalidation.Create: + fixture.Lifetime.Entities.AdvanceCreateAuthority(fixture.Record); + break; + case ActivationInvalidation.Identity: + fixture.Identity.ServerGuid++; + break; + case ActivationInvalidation.Body: + fixture.Lifetime.Entities.SetPhysicsBody( + fixture.Record, + new PhysicsBody()); + break; + case ActivationInvalidation.Controller: + fixture.Movement.Controller = new PlayerMovementController( + new PhysicsEngine()); + break; + case ActivationInvalidation.Clock: + fixture.Lifetime.Entities.SuspendObjectClock(fixture.Record); + break; + case ActivationInvalidation.Spatial: + fixture.Lifetime.Entities.SetFullCell( + fixture.Record, + Cell + 1u, + (Cell & 0xFFFF0000u) | 0xFFFFu); + break; + case ActivationInvalidation.Host: + fixture.Lifetime.Entities.SetPhysicsHost( + fixture.Record, + CreatePhysicsHost(fixture.Record.ServerGuid)); + break; + case ActivationInvalidation.Remote: + fixture.Lifetime.Entities.SetRemoteMotion( + fixture.Record, + new RemoteMotion()); + break; + case ActivationInvalidation.Projectile: + fixture.Lifetime.Entities.SetProjectile( + fixture.Record, + new RuntimeProjectile( + new PhysicsBody(), + new ProjectileCollisionSphere( + Vector3.Zero, + 0.1f))); + break; + case ActivationInvalidation.PlacementCancellation: + Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( + fixture.Record, + publishWithdrawal: false)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(invalidation)); + } + + Assert.False(fixture.Owner.IsEvaluationCurrent(receipt)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out _)); + } + + [Fact] + public void ShadowInsertMoveStateSuspendAndRemoveInvalidateReceipts() + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var emptyWorld)); + Assert.True(fixture.Owner.IsEvaluationCurrent(emptyWorld)); + uint queriedCell = emptyWorld.Placement.Result.QueriedCellIds[0]; + const uint ownerId = 0x7000F001u; + Vector3 far = new(100f, 100f, 0f); + + fixture.Lifetime.Physics.Engine.ShadowObjects.Register( + ownerId, + SetupId, + far, + Quaternion.Identity, + 0.25f, + 0f, + 0f, + queriedCell & 0xFFFF0000u, + ShadowCollisionType.Cylinder, + cylHeight: 1f, + seedCellId: queriedCell, + isStatic: false); + Assert.False(fixture.Owner.IsEvaluationCurrent(emptyWorld)); + + RuntimeLocalPlayerPhysicsActivationReceipt current = Reevaluate(); + fixture.Lifetime.Physics.Engine.ShadowObjects.UpdatePosition( + ownerId, + far + Vector3.One, + Quaternion.Identity, + 0f, + 0f, + queriedCell & 0xFFFF0000u, + queriedCell); + Assert.False(fixture.Owner.IsEvaluationCurrent(current)); + + current = Reevaluate(); + fixture.Lifetime.Physics.Engine.ShadowObjects.UpdatePhysicsState( + ownerId, + (uint)PhysicsStateFlags.Ethereal); + Assert.False(fixture.Owner.IsEvaluationCurrent(current)); + + current = Reevaluate(); + Assert.True(fixture.Lifetime.Physics.Engine.ShadowObjects.Suspend( + ownerId)); + Assert.False(fixture.Owner.IsEvaluationCurrent(current)); + + current = Reevaluate(); + fixture.Lifetime.Physics.Engine.ShadowObjects.Deregister(ownerId); + Assert.False(fixture.Owner.IsEvaluationCurrent(current)); + + RuntimeLocalPlayerPhysicsActivationReceipt Reevaluate() + { + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var next)); + Assert.True(fixture.Owner.IsEvaluationCurrent(next)); + return next; + } + } + + [Theory] + [InlineData(RestrictionObjectMutation.RemoveHouseObject)] + [InlineData(RestrictionObjectMutation.HouseOwnerProperty)] + [InlineData(RestrictionObjectMutation.HouseGuestList)] + [InlineData(RestrictionObjectMutation.MoverMonarch)] + public void RestrictionObjectMutationInvalidatesEvaluatedReceipt( + RestrictionObjectMutation mutation) + { + using var fixture = new Fixture(residentWorld: true); + SeedRestrictionObjects(fixture); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var receipt)); + + ApplyRestrictionObjectMutation(fixture, mutation); + + Assert.False(fixture.Owner.IsEvaluationCurrent(receipt)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var replacement)); + Assert.True(fixture.Owner.IsEvaluationCurrent(replacement)); + } + + [Theory] + [InlineData(RestrictionObjectMutation.RemoveHouseObject)] + [InlineData(RestrictionObjectMutation.HouseOwnerProperty)] + [InlineData(RestrictionObjectMutation.HouseGuestList)] + [InlineData(RestrictionObjectMutation.MoverMonarch)] + public void ReentrantRestrictionObjectMutationAbortsEvaluation( + RestrictionObjectMutation mutation) + { + using var fixture = new Fixture(residentWorld: true); + SeedRestrictionObjects(fixture); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + bool mutated = false; + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => + { + if (!mutated + && phase is TransitionCellCollisionPhase.Environment) + { + mutated = true; + ApplyRestrictionObjectMutation(fixture, mutation); + } + return observed; + }; + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority, + fixture.Owner.EvaluateActivation(token, out _)); + Assert.True(mutated); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = null; + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var replacement)); + Assert.True(fixture.Owner.IsEvaluationCurrent(replacement)); + } + + [Fact] + public void ObjectTableNullFreshAndEqualRevisionAbaInvalidateReceipt() + { + using var fixture = new Fixture(residentWorld: true); + SeedRestrictionObjects(fixture); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var originalReceipt)); + ClientObjectTable original = fixture.Lifetime.Objects; + + fixture.Lifetime.Physics.Engine.Objects = null; + Assert.False(fixture.Owner.IsEvaluationCurrent(originalReceipt)); + + // Returning to the same reference/revision is an ABA-shaped binding + // replacement. The engine binding authority keeps the old receipt + // stale even though object identity and mutation revision match again. + fixture.Lifetime.Physics.Engine.Objects = original; + Assert.False(fixture.Owner.IsEvaluationCurrent(originalReceipt)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var reboundReceipt)); + + var fresh = new ClientObjectTable(); + fresh.AddOrUpdate(new ClientObject { ObjectId = 0x70003F10u }); + fresh.AddOrUpdate(new ClientObject { ObjectId = 0x70003F11u }); + Assert.Equal(original.MutationRevision, fresh.MutationRevision); + fixture.Lifetime.Physics.Engine.Objects = fresh; + + Assert.False(fixture.Owner.IsEvaluationCurrent(reboundReceipt)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var freshReceipt)); + Assert.True(fixture.Owner.IsEvaluationCurrent(freshReceipt)); + } + + [Fact] + public void HouseRestrictionGuestsAreAnImmutableInputSnapshot() + { + var sourceGuests = new Dictionary(); + var restrictions = new HouseRestrictionRecord( + OpenToPublic: false, + AllegianceMonarchId: 0u, + Guests: sourceGuests); + var table = new ClientObjectTable(); + var house = new ClientObject + { + ObjectId = RestrictionObjectId, + Restrictions = restrictions, + }; + table.AddOrUpdate(house); + ulong retainedRevision = table.MutationRevision; + + sourceGuests[0x70003F20u] = 1u; + + Assert.False(restrictions.IsAllowedIn(0x70003F20u, 0u)); + if (restrictions.Guests is IDictionary dictionaryView) + { + Assert.Throws( + () => dictionaryView[0x70003F20u] = 1u); + } + Assert.False(restrictions.IsAllowedIn(0x70003F20u, 0u)); + Assert.Equal(retainedRevision, table.MutationRevision); + } + + [Fact] + public void RemovalAndClearUnbindDirectRestrictionMutationAuthority() + { + var table = new ClientObjectTable(); + var removed = new ClientObject { ObjectId = RestrictionObjectId }; + table.AddOrUpdate(removed); + Assert.True(table.Remove(removed.ObjectId)); + ulong removedRevision = table.MutationRevision; + + removed.HouseOwnerId = 0x70003F21u; + Assert.Equal(removedRevision, table.MutationRevision); + + var cleared = new ClientObject { ObjectId = 0x70003F22u }; + table.AddOrUpdate(cleared); + table.Clear(); + ulong clearedRevision = table.MutationRevision; + + cleared.MonarchId = 0x70003F23u; + Assert.Equal(clearedRevision, table.MutationRevision); + } + + [Fact] + public void AddOrUpdateReplacementTransfersDirectMutationAuthority() + { + var table = new ClientObjectTable(); + var displaced = new ClientObject { ObjectId = RestrictionObjectId }; + var replacement = new ClientObject { ObjectId = RestrictionObjectId }; + table.AddOrUpdate(displaced); + table.AddOrUpdate(replacement); + ulong replacementRevision = table.MutationRevision; + + displaced.MonarchId = 0x70003F24u; + Assert.Equal(replacementRevision, table.MutationRevision); + + replacement.MonarchId = 0x70003F25u; + Assert.True(table.MutationRevision > replacementRevision); + } + + [Fact] + public void SharedRetainedObjectNotifiesEveryOwningTable() + { + var first = new ClientObjectTable(); + var second = new ClientObjectTable(); + var shared = new ClientObject { ObjectId = RestrictionObjectId }; + first.AddOrUpdate(shared); + second.AddOrUpdate(shared); + ulong firstRevision = first.MutationRevision; + ulong secondRevision = second.MutationRevision; + + shared.HouseOwnerId = 0x70003F26u; + + Assert.True(first.MutationRevision > firstRevision); + Assert.True(second.MutationRevision > secondRevision); + } + + [Theory] + [InlineData(ReentrantActivationInvalidation.Reset)] + [InlineData(ReentrantActivationInvalidation.DeleteAndGuidReuse)] + public void ReentrantInvalidationDuringCoreEvaluationRetiresLease( + ReentrantActivationInvalidation invalidation) + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + RuntimeEntityRecord? replacement = null; + bool invalidated = false; + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => + { + if (invalidated + || phase is not TransitionCellCollisionPhase.Environment) + { + return observed; + } + invalidated = true; + if (invalidation is ReentrantActivationInvalidation.Reset) + { + fixture.Movement.ResetSession(); + } + else + { + uint guid = fixture.Record.ServerGuid; + Assert.True(fixture.Lifetime.TryAcceptDelete( + new DeleteObject.Parsed( + guid, + fixture.Record.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + fixture.Lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(fixture.Lifetime.RetireCanonicalOnly( + fixture.Record)); + replacement = fixture.Lifetime.RegisterEntity( + Spawn(guid, incarnation: 2)).Canonical!; + } + return observed; + }; + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority, + fixture.Owner.EvaluateActivation(token, out _)); + + Assert.True(invalidated); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + Assert.Null(fixture.Movement.Controller); + Assert.Null(replacement?.PhysicsBody); + } + + [Fact] + public void ExistingActivationLeaseCannotBeOverwrittenAfterExternalClear() + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, null); + fixture.Movement.Controller = null; + + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, + fixture.Owner.Prepare( + fixture.Record, + fixture.Placement, + fixture.Command, + PlayerMovementConstructionOptions.Fallback, + out _)); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + + Assert.True(fixture.Owner.DiscardActivation(token)); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Prepared, + fixture.Owner.Prepare( + fixture.Record, + fixture.Placement, + fixture.Command, + PlayerMovementConstructionOptions.Fallback, + out _)); + } + + [Fact] + public void ResetRetiresPendingActivationAndConvergesItsOwnershipLedger() + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out _)); + + fixture.Movement.ResetSession(); + + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + } + + [Fact] + public void ExplicitDiscardAndDisposeRetirePendingActivationExactlyOnce() + { + var fixture = new Fixture(residentWorld: true); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.True(fixture.Owner.DiscardActivation(token)); + Assert.False(fixture.Owner.DiscardActivation(token)); + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + + fixture.RepreparePlacement(); + Assert.Equal( + RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out _)); + fixture.Owner.Dispose(); + Assert.True(fixture.Owner.CaptureOwnership().IsConverged); + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + fixture.Dispose(); + } + [Theory] [InlineData(PublicationInvalidation.SetPositionReplacement)] [InlineData(PublicationInvalidation.Vector)] @@ -439,13 +1195,192 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests fixture.Movement.ControllerOwnershipEpoch); } + private static EvaluationPuritySnapshot CaptureEvaluationPurity( + Fixture fixture) + { + PhysicsBody body = fixture.Record.PhysicsBody!; + bool hasAwaiting = fixture.Lifetime.Physics.SetPosition + .TryGetAwaitingPreparationToken( + fixture.Record, + out RuntimeEntityPlacementToken awaiting); + return new EvaluationPuritySnapshot( + fixture.Lifetime.Physics.CaptureOwnership(), + fixture.Lifetime.Physics.SetPosition.CaptureOwnership(), + fixture.Lifetime.Physics.CollisionReports.CaptureOwnership(), + fixture.Lifetime.Physics.CollisionWorldAuthority, + fixture.Lifetime.Physics.ShadowWorldAuthority, + fixture.Lifetime.Physics.ObjectTable, + fixture.Lifetime.Physics.ObjectTableBindingAuthority, + fixture.Lifetime.Physics.ObjectTableAuthority, + hasAwaiting, + awaiting, + fixture.Lifetime.Physics.SetPosition + .IsExactPreparedPlacementCurrent( + fixture.Record, + fixture.Placement, + fixture.Command), + fixture.Record.FullCellId, + fixture.Record.SpatialAuthorityVersion, + fixture.Record.PlacementCommitVersion, + fixture.Record.ObjectClockEpoch, + fixture.Record.ObjectClock.PendingSeconds, + fixture.Record.ObjectClock.IsActive, + CaptureBody(body), + CaptureVectorBits(body.WalkableVertices)); + } + + private static void AssertEvaluationPurity( + in EvaluationPuritySnapshot expected, + in EvaluationPuritySnapshot actual) + { + Assert.Equal(expected.PhysicsOwnership, actual.PhysicsOwnership); + Assert.Equal(expected.SetPositionOwnership, actual.SetPositionOwnership); + Assert.Equal(expected.CollisionOwnership, actual.CollisionOwnership); + Assert.Equal(expected.CollisionWorldAuthority, + actual.CollisionWorldAuthority); + Assert.Equal(expected.ShadowWorldAuthority, + actual.ShadowWorldAuthority); + Assert.Same(expected.ObjectTable, actual.ObjectTable); + Assert.Equal(expected.ObjectTableBindingAuthority, + actual.ObjectTableBindingAuthority); + Assert.Equal(expected.ObjectTableAuthority, + actual.ObjectTableAuthority); + Assert.Equal(expected.HasAwaitingPlacement, + actual.HasAwaitingPlacement); + Assert.Equal(expected.AwaitingPlacement, actual.AwaitingPlacement); + Assert.Equal(expected.ExactPlacementCurrent, + actual.ExactPlacementCurrent); + Assert.Equal(expected.FullCellId, actual.FullCellId); + Assert.Equal(expected.SpatialAuthorityVersion, + actual.SpatialAuthorityVersion); + Assert.Equal(expected.PlacementCommitVersion, + actual.PlacementCommitVersion); + Assert.Equal(expected.ObjectClockEpoch, actual.ObjectClockEpoch); + Assert.Equal(expected.ObjectClockPending, + actual.ObjectClockPending); + Assert.Equal(expected.ObjectClockActive, actual.ObjectClockActive); + Assert.Equal(expected.Body, actual.Body); + Assert.True(expected.WalkableVertexBits.AsSpan().SequenceEqual( + actual.WalkableVertexBits.AsSpan())); + } + + private static BodyPuritySnapshot CaptureBody(PhysicsBody body) => new( + body.Position, + body.CellPosition, + body.InWorld, + body.Orientation, + body.Velocity, + body.CachedVelocity, + body.FramesStationaryFall, + body.Acceleration, + body.Omega, + body.GroundNormal, + body.SlidingNormal, + body.ContactPlaneValid, + body.ContactPlane, + body.ContactPlaneCellId, + body.ContactPlaneIsWater, + body.WalkablePolygonValid, + body.WalkablePlane, + body.WalkableUp, + body.Elasticity, + body.Friction, + body.State, + body.TransientState, + body.LastUpdateTime, + body.IsFullyConstrained, + body.LastMoveWasAutonomous); + + private static ImmutableArray CaptureVectorBits( + Vector3[]? vectors) + { + if (vectors is null) + return ImmutableArray.Empty; + var result = ImmutableArray.CreateBuilder(vectors.Length * 3); + foreach (Vector3 vector in vectors) + { + result.Add(BitConverter.SingleToUInt32Bits(vector.X)); + result.Add(BitConverter.SingleToUInt32Bits(vector.Y)); + result.Add(BitConverter.SingleToUInt32Bits(vector.Z)); + } + return result.ToImmutable(); + } + + private readonly record struct EvaluationPuritySnapshot( + RuntimePhysicsOwnershipSnapshot PhysicsOwnership, + RuntimeSetPositionOwnershipSnapshot SetPositionOwnership, + RuntimeCollisionReportingOwnershipSnapshot CollisionOwnership, + ulong CollisionWorldAuthority, + ulong ShadowWorldAuthority, + ClientObjectTable? ObjectTable, + ulong ObjectTableBindingAuthority, + ulong ObjectTableAuthority, + bool HasAwaitingPlacement, + RuntimeEntityPlacementToken AwaitingPlacement, + bool ExactPlacementCurrent, + uint FullCellId, + ulong SpatialAuthorityVersion, + ulong PlacementCommitVersion, + ulong ObjectClockEpoch, + double ObjectClockPending, + bool ObjectClockActive, + BodyPuritySnapshot Body, + ImmutableArray WalkableVertexBits); + + private readonly record struct BodyPuritySnapshot( + Vector3 Position, + AcDream.Core.Physics.Position CellPosition, + bool InWorld, + Quaternion Orientation, + Vector3 Velocity, + Vector3 CachedVelocity, + int FramesStationaryFall, + Vector3 Acceleration, + Vector3 Omega, + Vector3 GroundNormal, + Vector3 SlidingNormal, + bool ContactPlaneValid, + Plane ContactPlane, + uint ContactPlaneCellId, + bool ContactPlaneIsWater, + bool WalkablePolygonValid, + Plane WalkablePlane, + Vector3 WalkableUp, + float Elasticity, + float Friction, + PhysicsStateFlags State, + TransientStateFlags TransientState, + double LastUpdateTime, + bool IsFullyConstrained, + bool LastMoveWasAutonomous); + private sealed class Fixture : IDisposable { private bool _lifetimeDisposed; - internal Fixture(bool preparePlacement = true) + internal Fixture( + bool preparePlacement = true, + bool residentWorld = false) { - Lifetime = new RuntimeEntityObjectLifetime(); + if (residentWorld) + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + Lifetime = new RuntimeEntityObjectLifetime(engine); + } + else + { + Lifetime = new RuntimeEntityObjectLifetime(); + } Movement = new RuntimeLocalPlayerMovementState(); Identity = new RuntimeLocalPlayerIdentityState(); Owner = new RuntimeLocalPlayerPhysicsPublicationState( @@ -594,6 +1529,106 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Physics: physics); } + private const uint RestrictionObjectId = 0x70003F01u; + + private static void SeedRestrictionObjects(Fixture fixture) + { + fixture.Lifetime.Objects.AddOrUpdate(new ClientObject + { + ObjectId = RestrictionObjectId, + HouseOwnerId = 0x70003F02u, + Restrictions = new HouseRestrictionRecord( + OpenToPublic: false, + AllegianceMonarchId: 0x70003F03u, + Guests: new Dictionary()), + }); + fixture.Lifetime.Objects.AddOrUpdate(new ClientObject + { + ObjectId = fixture.Record.ServerGuid, + MonarchId = 0x70003F03u, + }); + } + + private static void ApplyRestrictionObjectMutation( + Fixture fixture, + RestrictionObjectMutation mutation) + { + ClientObjectTable objects = fixture.Lifetime.Objects; + switch (mutation) + { + case RestrictionObjectMutation.RemoveHouseObject: + Assert.True(objects.Remove(RestrictionObjectId)); + break; + case RestrictionObjectMutation.HouseOwnerProperty: + { + ClientObject house = objects.Get(RestrictionObjectId)!; + house.HouseOwnerId = 0x70003F04u; + break; + } + case RestrictionObjectMutation.HouseGuestList: + objects.Get(RestrictionObjectId)!.Restrictions = + new HouseRestrictionRecord( + OpenToPublic: false, + AllegianceMonarchId: 0x70003F03u, + Guests: new Dictionary + { + [fixture.Record.ServerGuid] = 1u, + }); + break; + case RestrictionObjectMutation.MoverMonarch: + { + ClientObject mover = objects.Get(fixture.Record.ServerGuid)!; + mover.MonarchId = 0x70003F05u; + break; + } + default: + throw new ArgumentOutOfRangeException(nameof(mutation)); + } + } + + private static RuntimeCollisionGenerationCommit CommitPrepared( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + while (true) + { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (seal.Completed) + break; + } + return physics.CommitCollisionGeneration(admission, prepared); + } + + private static RuntimeLandblockCollisionAssets CollisionAssets( + uint landblockId) => new( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + 0f, + 0f, + 0u); + public enum PublicationInvalidation { SetPositionReplacement, @@ -629,6 +1664,37 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Dispose, } + public enum ActivationInvalidation + { + Position, + Vector, + ObjectDescription, + Create, + Identity, + Body, + Controller, + Clock, + Spatial, + Host, + Remote, + Projectile, + PlacementCancellation, + } + + public enum ReentrantActivationInvalidation + { + Reset, + DeleteAndGuidReuse, + } + + public enum RestrictionObjectMutation + { + RemoveHouseObject, + HouseOwnerProperty, + HouseGuestList, + MoverMonarch, + } + private static EntityPhysicsHost CreatePhysicsHost(uint id) => new( id, getPosition: static () => default, From 5785a07b3e0cd165b74ab69c63da9a9dc0244fc9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 14:25:02 +0200 Subject: [PATCH 33/73] feat(runtime): commit dormant SetPosition activation --- ...untime-local-player-physics-publication.md | 149 ++- src/AcDream.Core/Physics/PhysicsBody.cs | 15 +- src/AcDream.Core/Physics/PhysicsObjUpdate.cs | 62 +- .../Physics/ShadowObjectRegistry.cs | 556 ++++++++++- .../Gameplay/PlayerMovementController.cs | 84 +- ...ntimeLocalPlayerPhysicsPublicationState.cs | 540 ++++++++++- .../Physics/RuntimeCollisionReportingState.cs | 662 ++++++++++++- .../Physics/RuntimePhysicsState.cs | 44 + .../Physics/RuntimeSetPositionState.cs | 876 ++++++++++++++++- .../Physics/ShadowSetPositionCommitTests.cs | 278 ++++++ .../Gameplay/PlayerMovementControllerTests.cs | 7 + ...LocalPlayerPhysicsPublicationStateTests.cs | 892 +++++++++++++++++- .../RuntimeCollisionReportingStateTests.cs | 620 ++++++++++++ 13 files changed, 4674 insertions(+), 111 deletions(-) diff --git a/docs/research/2026-08-01-runtime-local-player-physics-publication.md b/docs/research/2026-08-01-runtime-local-player-physics-publication.md index 6146dc93..a8aef87e 100644 --- a/docs/research/2026-08-01-runtime-local-player-physics-publication.md +++ b/docs/research/2026-08-01-runtime-local-player-physics-publication.md @@ -2,19 +2,20 @@ ## Scope -This is placement Slice 4B2 checkpoints 4-5. It adds the dormant, +This is placement Slice 4B2 checkpoints 4-6. It adds the dormant, presentation-independent transaction which prepares and assigns ownership of -one local-player `PhysicsBody` and `PlayerMovementController`, then retains an -exact post-ownership activation lease which can evaluate retail SetPosition -without publishing it. No App or -Headless production route invokes this transaction, so graphical and no-window -game behavior is unchanged and AP-1/AD-1 remain open. +one local-player `PhysicsBody` and `PlayerMovementController`, retains an exact +post-ownership evaluation lease, and commits the canonical Runtime SetPosition +activation in retail order. No App or Headless production route invokes this +transaction yet, so graphical and no-window game behavior is unchanged and +AP-1/AD-1 remain open until their hosts cut over. -The checkpoints deliberately stop before canonical SetPosition activation. -They do not consume the prepared placement operation, enter the body into the -physics engine, publish FullCell/world/host/shadow/workset state, or project a -presentation entity. Those effects belong to the next transaction and must all -use the same Runtime-owned dormant body. +Checkpoint 6 consumes the prepared placement operation only after the exact +body/controller/identity/collision envelope is current. It publishes FullCell, +world residence, host, shadow, workset, object-clock, and ordered Place state +from that same Runtime-owned dormant body. There is no second body, mirrored +gameplay owner, rollback mutation, or presentation callback inside the +canonical tail. ## Ownership contract @@ -46,8 +47,9 @@ the same server GUID as the exact entity incarnation. Unpublished candidates and ownership-committed dormant controllers reject live movement operations: update, public SetPosition, blip, outbound-position capture, movement/position send tracking, and shared-engine position commit. -Only the subsequent activation transaction may promote `RuntimeOwnedDormant` -to `RuntimePublished`; this checkpoint never invokes that transition. Once a +Only the checkpoint-6 activation transaction may promote `RuntimeOwnedDormant` +to `RuntimePublished`; preparation and evaluation never invoke that transition. +Once a Runtime-owned dormant or published controller is replaced, reset, or disposed, its terminal retirement state rejects the same operations plus body/configuration mutation and manager acquisition. Publicly constructed @@ -106,8 +108,9 @@ transaction is pure: it returns committed, deferred-cell, or rejected placement data without writing the canonical body, FullCell, clock, spatial worksets, shadows, collision-report owners, host, operation stage, or Place projection. A missing cell therefore leaves the exact body dormant and the -authored operation retryable. A valid result likewise remains only an -immutable evaluation receipt; this checkpoint has no activation/commit API. +authored operation retryable. During evaluation, a valid result likewise +remains only an immutable receipt; checkpoint 6's separate commit API consumes +that receipt only after revalidating the complete activation envelope. Each evaluation carries an append-only, stable-order union of every cell read by the complete Core transaction: the AdjustPosition seed and adjusted cell, @@ -154,6 +157,71 @@ discard is required before a new candidate can be prepared. Reset and disposal retire the lease, body, and dormant controller and include the pending activation in the ownership convergence ledger. +## Canonical activation and retail ordering + +The implementation follows the named-retail chain rather than treating +SetPosition as a single opaque callback: + +- `CPhysicsObj::SetPosition` at `0x005160C0` owns the outer placement call. +- The internal wrapper at `0x00515BD0` evaluates residence and collision. +- `CPhysicsObj::SetPositionInternal(CTransition*)` at `0x00515330` commits the + accepted frame/contact prefix and later shadow/cell state. +- `CPhysicsObj::enter_world` at `0x00516170` is the final live edge. +- `CPhysicsObj::leave_world` at `0x005155A0` is the canonical retirement edge. + +Runtime splits that chain into a prepared, callback-free transaction and an +ordered notification suffix: + +1. Install the accepted frame and contact prefix on the still-dormant body and + perform the first acceleration calculation. +2. Open one narrow dormant ground phase and invoke `HitGround` or + `LeaveGround`. Movement reapplication may call retail `set_velocity`, but + the phase closes with `Active=false`; the body is still out of world, has no + host/spatial membership, and its object clock is inactive. +3. Synchronize accepted State and Vector authorities, run the post-ground + acceleration/sliding phase, and dispatch the already-installed collision + batch. +4. Revalidate the complete ownership/collision envelope. Accepted State and + Vector updates are synchronized; Position, ObjDesc, Create, Setup, + incarnation, identity, collision-generation, body, controller, host, or + session displacement aborts the old transaction. +5. Apply velocity-current physical response and stationary bits, prepare the + final shadow mutation and Place receipt, then perform the callback-free + FullCell/body/host/controller/spatial/object-clock tail. +6. Dispatch exact shadow notifications and the ordered Place projection only + after the complete live graph is visible. + +Collision and shadow mutations use explicit prepare/apply/dispatch receipts. +Receipt dispatch is exact-once and owner-local, so reverse-order receipts for +different owners remain valid while a superseding mutation of the same owner +stops the stale suffix. Collision owner states carry the exact SetPosition +batch ID. Reentrant Position or newer-batch replacement suppresses remaining +reciprocal/environment callbacks, and abort cleanup force-ends/removes only the +still-exact old batch, including reverse rows and the environment latch. The +combined Runtime physics ownership ledger includes pending collision and +shadow SetPosition receipts; teardown cannot report convergence while either +receipt remains. + +Candidate construction applies the accepted `PhysicsDesc` values in retail +`CPhysicsObj::set_description` order before sealing ownership: final state, +friction, clamped elasticity, `set_velocity` (including the 50-unit clamp), +and angular velocity. Network acceleration remains parse-only because retail +recalculates it from the final physics state. This initial vector bootstrap is +required even when the SetPosition receipt's source Vector authority is still +current; the later refresh intentionally skips in that case. Collision +callbacks may advance State/Vector authority without invalidating the +immutable geometry/identity envelope, and a changed Vector authority refreshes +the dormant body through the same `set_velocity` path before physical response. + +A deferred-cell commit atomically suspends an authored shadow registration and +consumes its notification receipt. Explicit publication discard cancels the +exact SetPosition lease and body/controller ownership, while the suspended +registration remains owned by the live entity/shadow registry and is reusable +by a later activation. A deterministic discard -> generation-ready -> new +activation gate proves the same registration restores without stale rows or a +pending receipt. Entity/lifetime teardown remains the terminal owner of that +suspended registration. + ## Gates - Candidate privacy and live-operation rejection. @@ -161,15 +229,22 @@ activation in the ownership convergence ledger. acquisition/binding, and remote-placement ownership. - Exact local-player identity, identity-switch, and disposed-identity rejection. - Exact same-body ownership in entity record and dormant movement controller. -- Dormant rejection after ownership commit plus the isolated controller-level - `dormant -> activated -> live` lifecycle contract for the next checkpoint. +- Initial PhysicsDesc velocity, angular velocity, friction, and elasticity + bootstrap, including activation with the retail 50-unit velocity clamp. +- Dormant rejection after ownership commit plus the controller-level + `dormant -> activated -> live` lifecycle contract exercised by checkpoint 6. - No mutation of SetPosition, FullCell, spatial roots, host projections, - shadows, worksets, world residence, or presentation during this checkpoint. + shadows, worksets, world residence, or presentation during preparation or + evaluation; the separately gated activation commit owns those mutations. - Replacement by position, vector, final physics state, object description, CreateObject, remote/projectile/body/clock/controller ownership, and explicit placement cancellation. - Delete plus same-GUID reincarnation. - Candidate replacement, reset, disposal, and ownership convergence. +- Publication/activation sequence exhaustion is preflighted before candidate + allocation or replacement, leaving no private or canonical owner behind. +- Shadow-registry reset invalidates even a prepared, unapplied shapeless + transaction which owns no logical rows or pending dispatch receipt. - Pure committed/deferred/rejected SetPosition evaluation with bit-exact body-state snapshots and no canonical, collision-report, projection, clock, FullCell, host, shadow, workset, or operation-stage mutation. @@ -193,30 +268,20 @@ activation in the ownership convergence ledger. - Terminal stale-controller rejection after replacement, reset, and disposal. - Body/controller epochs advance only on actual ownership changes. -The checkpoint-5 focused publication suite passes 70/70 and the focused Core -SetPosition suite passes 59/59. The complete Runtime project passes 666/666; -the complete Core project passes 4,230 tests / 1 skip; and the App Runtime -physics/movement ownership guard passes 6/6. The complete Release solution -builds with zero errors (three pre-existing App-test nullability warnings are -outside this checkpoint), and its invariant-culture test gate passes 10,419 -tests / 4 intentional skips. The installed prepared-package gate -uses the exact local `acdream.pak`. Under the machine's Swedish current culture, -the same three previously known formatting assertions remain unrelated (`0,5` -versus `0.5` and localized sky text), so the canonical gate runs under -invariant culture. +The checkpoint-6 focused publication/collision suite passes 129/129, the +focused Core shadow transaction suite passes 16/16, and the complete Runtime +project passes 695/695 under invariant globalization. The Runtime Release build +passes with zero warnings and zero errors. Broader Core/App/solution and +connected gates remain for the parent integration checkpoint. Under the +machine's Swedish current culture, the three previously known formatting +assertions remain unrelated (`0,5` versus `0.5` and localized sky text), so the +canonical Runtime gate runs under invariant globalization. ## Next checkpoint -Add the canonical Runtime SetPosition activation transaction. Before its -callback-free tail, it must prepare a presentation-independent Runtime -`PhysicsHost`/`PositionManager`/`MoveToManager` graph, the existing authoritative -authored shadow payload (without rebuilding AP-22 shapes), and an exact staged -collision-report batch. Then it may commit the already-owned dormant body, -contact/water/walkable/response state, object clock, FullCell, exact shadow, -spatial/ordinary workset, host, and SetPosition operation versions; seal the -ordered Place receipt; and invoke the sole `ActivateRuntimePublication` -transition as the final tickable edge. Collision reports and the Place observer -publish only afterward and must tolerate delete/reset re-entry without replaying -physics. Every pre-commit failure leaves the dormant lease and authored -operation retryable or rejects them; no rollback mutation and no second body or -controller are allowed. +Cut the graphical and no-window local-player hosts over to this Runtime-owned +activation transaction, then delete their duplicate SetPosition +activation/publication paths. The cutover must preserve the same exact body, +controller, shadow payload, deferred-cell lease, collision receipt ordering, +and graceful teardown proven here; no host may reconstruct or replay the +canonical transaction. diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index c55d72ce..37cd20f7 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -199,6 +199,20 @@ public sealed class PhysicsBody /// point unchanged for an in-range local. /// public void SnapToCell(uint cellId, Vector3 worldPos, Vector3 cellLocal) + { + StageDormantCellFrame(cellId, worldPos, cellLocal); + InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell + } + + /// + /// Installs the exact SetPosition cell/frame while the Runtime owner is + /// still dormant. This is the frame half of retail SetPositionInternal; + /// enter_world remains a later explicit publication suffix. + /// + public void StageDormantCellFrame( + uint cellId, + Vector3 worldPos, + Vector3 cellLocal) { _position = worldPos; uint cell = cellId; @@ -206,7 +220,6 @@ public sealed class PhysicsBody if ((cellId & 0xFFFFu) is >= 1u and <= 0x40u) LandDefs.AdjustToOutside(ref cell, ref local); CellPosition = new Position(cell, new CellFrame(local, Orientation)); - InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell } /// diff --git a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs index afd699ab..73578ef0 100644 --- a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs +++ b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs @@ -128,27 +128,11 @@ public static class PhysicsObjUpdate { ArgumentNullException.ThrowIfNull(body); - if (previousOnWalkable) - body.TransientState |= TransientStateFlags.OnWalkable; - else - body.TransientState &= ~TransientStateFlags.OnWalkable; - - if (inContact) - body.TransientState |= TransientStateFlags.Contact; - else - body.TransientState &= ~TransientStateFlags.Contact; - body.calc_acceleration(); - - bool finalOnWalkable = inContact && onWalkable; - if (finalOnWalkable) - body.TransientState |= TransientStateFlags.OnWalkable; - else - body.TransientState &= ~TransientStateFlags.OnWalkable; - - if (body.ContactPlaneIsWater) - body.TransientState |= TransientStateFlags.WaterContact; - else - body.TransientState &= ~TransientStateFlags.WaterContact; + bool finalOnWalkable = CommitSetPositionContactPrefix( + body, + inContact, + onWalkable, + previousOnWalkable); if (!previousOnWalkable && finalOnWalkable) { @@ -162,10 +146,44 @@ public static class PhysicsObjUpdate if (isCurrent?.Invoke() == false) return false; } - body.calc_acceleration(); + CommitSetPositionPostGround(body); return isCurrent?.Invoke() ?? true; } + public static bool CommitSetPositionContactPrefix( + PhysicsBody body, + bool inContact, + bool onWalkable, + bool previousOnWalkable) + { + ArgumentNullException.ThrowIfNull(body); + if (previousOnWalkable) + body.TransientState |= TransientStateFlags.OnWalkable; + else + body.TransientState &= ~TransientStateFlags.OnWalkable; + if (inContact) + body.TransientState |= TransientStateFlags.Contact; + else + body.TransientState &= ~TransientStateFlags.Contact; + body.calc_acceleration(); + bool finalOnWalkable = inContact && onWalkable; + if (finalOnWalkable) + body.TransientState |= TransientStateFlags.OnWalkable; + else + body.TransientState &= ~TransientStateFlags.OnWalkable; + if (body.ContactPlaneIsWater) + body.TransientState |= TransientStateFlags.WaterContact; + else + body.TransientState &= ~TransientStateFlags.WaterContact; + return finalOnWalkable; + } + + public static void CommitSetPositionPostGround(PhysicsBody body) + { + ArgumentNullException.ThrowIfNull(body); + body.calc_acceleration(); + } + /// /// retail handle_all_collisions (0x00514780). Reflects or zeros the body's /// (retail m_velocityVector) based on diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index c16d100a..083011ed 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -79,6 +79,10 @@ public sealed class ShadowObjectRegistry private readonly HashSet _prefixScratch = new(); private readonly List _removedPrefixScratch = new(); private ulong _mutationRevision; + private ulong _nextPreparedSetPositionCommitId; + private ulong _lastAppliedSetPositionCommitId; + private readonly HashSet _pendingSetPositionDispatches = []; + private long _setPositionDispatchFailureCount; internal event Action? OwnerMutated; internal event Action? OwnerPrefixMembershipChanged; @@ -783,6 +787,553 @@ public sealed class ShadowObjectRegistry } } + /// + /// Immutable owner-local shadow transaction prepared before Runtime's + /// SetPosition publication tail. The prepared rows are built against an + /// isolated registry; applying them never re-runs the flood oracle. + /// + internal sealed record PreparedSetPositionShadowCommit( + ulong CommitId, + uint EntityId, + ulong ExpectedMutationRevision, + ulong ExpectedOwnerVersion, + ulong FinalMutationRevision, + ulong FinalOwnerVersion, + bool ProvenShapeless, + PreparedShadowOwnerState? OwnerState, + PreparedShadowCellReplacement[] CellReplacements, + PreparedShadowPrefixReplacement[] PrefixReplacements, + HashSet? OwnerPrefixes, + uint[] ChangedPrefixes); + + internal sealed record PreparedShadowCellReplacement( + uint CellId, + List Entries); + + internal sealed record PreparedShadowPrefixReplacement( + uint Prefix, + bool Remove, + List? Slots, + Dictionary? Indices, + Stack? FreeSlots); + + internal readonly record struct SetPositionShadowCommitReceipt( + ulong CommitId, + uint EntityId, + ulong OwnerVersion, + uint[] ChangedPrefixes, + bool Mutated) + { + internal bool IsValid => CommitId != 0UL && EntityId != 0u; + } + + /// + /// Prepares the complete owner-row replacement without touching the active + /// collision world. A missing registration is accepted only when the + /// caller carries an explicit proven-shapeless disposition; absence alone + /// is not evidence because an authored BSP payload may still be pending. + /// + internal bool TryPrepareSetPosition( + uint entityId, + Vector3 worldPosition, + Quaternion worldRotation, + uint seedCellId, + float worldOffsetX, + float worldOffsetY, + PhysicsShadowCommitAction action, + System.Collections.Immutable.ImmutableArray crossCellIds, + bool provenShapeless, + bool suspendOwner, + out PreparedSetPositionShadowCommit? prepared) + { + prepared = null; + ulong expectedMutation = _mutationRevision; + ulong expectedOwner = GetOwnerVersion(entityId); + bool hasOwner = TryCaptureOwnerState( + entityId, + out PreparedShadowOwnerState? source); + if (!hasOwner) + { + if (!provenShapeless) + return false; + _pendingSetPositionDispatches.EnsureCapacity( + _pendingSetPositionDispatches.Count + 1); + prepared = new PreparedSetPositionShadowCommit( + checked(++_nextPreparedSetPositionCommitId), + entityId, + expectedMutation, + expectedOwner, + expectedMutation, + expectedOwner, + ProvenShapeless: true, + OwnerState: null, + CellReplacements: [], + PrefixReplacements: [], + OwnerPrefixes: null, + ChangedPrefixes: Array.Empty()); + return _mutationRevision == expectedMutation + && GetOwnerVersion(entityId) == expectedOwner + && !HasLogicalOwner(entityId); + } + if (provenShapeless || source is null) + return false; + + var staging = new ShadowObjectRegistry + { + DataCache = DataCache, + }; + staging.InstallOwnerState(source); + if (suspendOwner) + { + if (!staging.Suspend(entityId)) + return false; + } + else + { + staging.CommitSetPosition( + entityId, + worldPosition, + worldRotation, + seedCellId, + worldOffsetX, + worldOffsetY, + action, + crossCellIds); + } + if (!staging.TryCaptureOwnerState( + entityId, + out PreparedShadowOwnerState? replacement) + || replacement is null) + { + return false; + } + + uint[] changedPrefixes = CaptureChangedPrefixes(source, replacement); + PreparedShadowCellReplacement[] cellReplacements = + PrepareCellReplacements(entityId, source, replacement); + HashSet replacementPrefixes = CapturePrefixes(replacement); + PreparedShadowPrefixReplacement[] prefixReplacements = + PreparePrefixReplacements( + entityId, + CapturePrefixes(source), + replacementPrefixes, + changedPrefixes); + ulong finalMutation = checked(expectedMutation + 1UL); + ulong finalOwner = checked(expectedOwner + 1UL); + // Reserve dictionary capacity before the non-fallible publication + // suffix. Row/list payloads themselves were allocated in staging. + _cells.EnsureCapacity(_cells.Count + replacement.Rows.Count); + _entityToCells.EnsureCapacity(_entityToCells.Count + 1); + _entityReg.EnsureCapacity(_entityReg.Count + 1); + _entityShapes.EnsureCapacity(_entityShapes.Count + 1); + _suspendedEntityCells.EnsureCapacity(_suspendedEntityCells.Count + 1); + _withdrawnPrefixesByOwner.EnsureCapacity( + _withdrawnPrefixesByOwner.Count + 1); + _ownerVersions.EnsureCapacity(_ownerVersions.Count + 1); + _ownerPrefixes.EnsureCapacity(_ownerPrefixes.Count + 1); + _prefixOwnerSlots.EnsureCapacity( + _prefixOwnerSlots.Count + changedPrefixes.Length); + _prefixOwnerIndices.EnsureCapacity( + _prefixOwnerIndices.Count + changedPrefixes.Length); + _prefixFreeSlots.EnsureCapacity( + _prefixFreeSlots.Count + changedPrefixes.Length); + _suspendedEntities.EnsureCapacity(_suspendedEntities.Count + 1); + _pendingSetPositionDispatches.EnsureCapacity( + _pendingSetPositionDispatches.Count + 1); + + prepared = new PreparedSetPositionShadowCommit( + checked(++_nextPreparedSetPositionCommitId), + entityId, + expectedMutation, + expectedOwner, + finalMutation, + finalOwner, + ProvenShapeless: false, + replacement, + cellReplacements, + prefixReplacements, + replacementPrefixes, + changedPrefixes); + return _mutationRevision == expectedMutation + && GetOwnerVersion(entityId) == expectedOwner + && HasLogicalOwner(entityId); + } + + /// + /// Applies a previously prepared owner-local row swap with callbacks + /// suppressed. Runtime dispatches the returned exact notification only + /// after the complete SetPosition state suffix is visible. + /// + internal bool TryApplySetPosition( + PreparedSetPositionShadowCommit prepared, + out SetPositionShadowCommitReceipt receipt) + { + ArgumentNullException.ThrowIfNull(prepared); + receipt = default; + if (prepared.CommitId <= _lastAppliedSetPositionCommitId + || _mutationRevision != prepared.ExpectedMutationRevision + || GetOwnerVersion(prepared.EntityId) + != prepared.ExpectedOwnerVersion + || HasLogicalOwner(prepared.EntityId) + == prepared.ProvenShapeless) + { + return false; + } + + if (prepared.ProvenShapeless) + { + receipt = new SetPositionShadowCommitReceipt( + prepared.CommitId, + prepared.EntityId, + prepared.ExpectedOwnerVersion, + Array.Empty(), + Mutated: false); + _lastAppliedSetPositionCommitId = prepared.CommitId; + _pendingSetPositionDispatches.Add(prepared.CommitId); + return true; + } + if (prepared.OwnerState is null) + return false; + + for (int index = 0; index < prepared.CellReplacements.Length; index++) + { + PreparedShadowCellReplacement replacement = + prepared.CellReplacements[index]; + _cells[replacement.CellId] = replacement.Entries; + } + PreparedShadowOwnerState state = prepared.OwnerState; + _entityReg[prepared.EntityId] = state.Registration; + ReplaceOwnerValue(_entityShapes, prepared.EntityId, state.Shapes); + if (state.Suspended) + _suspendedEntities.Add(prepared.EntityId); + else + _suspendedEntities.Remove(prepared.EntityId); + ReplaceOwnerValue( + _suspendedEntityCells, + prepared.EntityId, + state.SuspendedCellIds); + ReplaceOwnerValue( + _withdrawnPrefixesByOwner, + prepared.EntityId, + state.WithdrawnPrefixes); + ReplaceOwnerValue( + _entityToCells, + prepared.EntityId, + state.CellIds); + if (prepared.OwnerPrefixes is not null) + _ownerPrefixes[prepared.EntityId] = prepared.OwnerPrefixes; + for (int index = 0; index < prepared.PrefixReplacements.Length; index++) + { + PreparedShadowPrefixReplacement replacement = + prepared.PrefixReplacements[index]; + if (replacement.Remove) + { + _prefixOwnerSlots.Remove(replacement.Prefix); + _prefixOwnerIndices.Remove(replacement.Prefix); + _prefixFreeSlots.Remove(replacement.Prefix); + continue; + } + _prefixOwnerSlots[replacement.Prefix] = replacement.Slots!; + _prefixOwnerIndices[replacement.Prefix] = replacement.Indices!; + _prefixFreeSlots[replacement.Prefix] = replacement.FreeSlots!; + } + _mutationRevision = prepared.FinalMutationRevision; + _ownerVersions[prepared.EntityId] = prepared.FinalOwnerVersion; + _lastAppliedSetPositionCommitId = prepared.CommitId; + _pendingSetPositionDispatches.Add(prepared.CommitId); + receipt = new SetPositionShadowCommitReceipt( + prepared.CommitId, + prepared.EntityId, + prepared.FinalOwnerVersion, + prepared.ChangedPrefixes, + Mutated: true); + return true; + } + + internal bool IsPreparedSetPositionCurrent( + PreparedSetPositionShadowCommit prepared) + { + ArgumentNullException.ThrowIfNull(prepared); + return prepared.CommitId > _lastAppliedSetPositionCommitId + && _mutationRevision == prepared.ExpectedMutationRevision + && GetOwnerVersion(prepared.EntityId) + == prepared.ExpectedOwnerVersion + && HasLogicalOwner(prepared.EntityId) + != prepared.ProvenShapeless; + } + + internal void DispatchSetPositionCommit( + in SetPositionShadowCommitReceipt receipt) + { + if (!receipt.IsValid + || receipt.CommitId > _lastAppliedSetPositionCommitId + || !_pendingSetPositionDispatches.Remove(receipt.CommitId)) + return; + if (!receipt.Mutated) + return; + ulong currentOwnerVersion = GetOwnerVersion(receipt.EntityId); + if (!HasLogicalOwner(receipt.EntityId) + || currentOwnerVersion != receipt.OwnerVersion) + { + return; + } + for (int index = 0; index < receipt.ChangedPrefixes.Length; index++) + { + if (!HasLogicalOwner(receipt.EntityId) + || GetOwnerVersion(receipt.EntityId) != receipt.OwnerVersion) + { + return; + } + DispatchSetPositionPrefixObservers( + receipt.EntityId, + receipt.ChangedPrefixes[index]); + } + if (!HasLogicalOwner(receipt.EntityId)) + return; + currentOwnerVersion = GetOwnerVersion(receipt.EntityId); + if (currentOwnerVersion != receipt.OwnerVersion) + return; + DispatchSetPositionOwnerObservers( + receipt.EntityId, + currentOwnerVersion); + } + + internal bool DiscardSetPositionCommit( + in SetPositionShadowCommitReceipt receipt) => + receipt.IsValid + && _pendingSetPositionDispatches.Remove(receipt.CommitId); + + internal int PendingSetPositionDispatchCount => + _pendingSetPositionDispatches.Count; + + internal long SetPositionDispatchFailureCount => + _setPositionDispatchFailureCount; + + private void DispatchSetPositionPrefixObservers(uint owner, uint prefix) + { + Action? observers = OwnerPrefixMembershipChanged; + if (observers is null) + return; + foreach (Action observer in observers.GetInvocationList()) + { + try + { + observer(owner, prefix); + } + catch + { + _setPositionDispatchFailureCount++; + } + } + } + + private void DispatchSetPositionOwnerObservers(uint owner, ulong version) + { + Action? observers = OwnerMutated; + if (observers is null) + return; + foreach (Action observer in observers.GetInvocationList()) + { + try + { + observer(owner, version); + } + catch + { + _setPositionDispatchFailureCount++; + } + } + } + + private static uint[] CaptureChangedPrefixes( + PreparedShadowOwnerState before, + PreparedShadowOwnerState after) + { + HashSet oldPrefixes = CapturePrefixes(before); + HashSet newPrefixes = CapturePrefixes(after); + var changed = new List(); + foreach (uint prefix in oldPrefixes) + { + if (!newPrefixes.Contains(prefix)) + changed.Add(prefix); + } + foreach (uint prefix in newPrefixes) + { + if (!oldPrefixes.Contains(prefix)) + changed.Add(prefix); + } + changed.Sort(); + return changed.ToArray(); + } + + private static HashSet CapturePrefixes( + PreparedShadowOwnerState state) + { + var prefixes = new HashSet + { + state.Registration.SeedCellId & 0xFFFF0000u, + }; + if (state.CellIds is not null) + { + for (int index = 0; index < state.CellIds.Count; index++) + prefixes.Add(state.CellIds[index] & 0xFFFF0000u); + } + if (state.WithdrawnPrefixes is not null) + { + foreach (uint prefix in state.WithdrawnPrefixes) + prefixes.Add(prefix & 0xFFFF0000u); + } + return prefixes; + } + + private PreparedShadowCellReplacement[] PrepareCellReplacements( + uint entityId, + PreparedShadowOwnerState before, + PreparedShadowOwnerState after) + { + var touched = new HashSet(); + AddCells(touched, before.CellIds); + AddCells(touched, after.CellIds); + var afterRows = new Dictionary(); + for (int index = 0; index < after.Rows.Count; index++) + { + PreparedShadowCellRows row = after.Rows[index]; + touched.Add(row.CellId); + afterRows[row.CellId] = row.Entries; + } + for (int index = 0; index < before.Rows.Count; index++) + touched.Add(before.Rows[index].CellId); + + uint[] ordered = touched.ToArray(); + Array.Sort(ordered); + var result = new PreparedShadowCellReplacement[ordered.Length]; + for (int index = 0; index < ordered.Length; index++) + { + uint cellId = ordered[index]; + _cells.TryGetValue(cellId, out List? active); + afterRows.TryGetValue(cellId, out ShadowEntry[]? ownerRows); + int retainedCount = 0; + if (active is not null) + { + for (int row = 0; row < active.Count; row++) + { + if (active[row].EntityId != entityId) + retainedCount++; + } + } + var replacement = new List( + retainedCount + (ownerRows?.Length ?? 0)); + if (active is not null) + { + for (int row = 0; row < active.Count; row++) + { + if (active[row].EntityId != entityId) + replacement.Add(active[row]); + } + } + if (ownerRows is not null) + replacement.AddRange(ownerRows); + result[index] = new PreparedShadowCellReplacement( + cellId, + replacement); + } + return result; + } + + private PreparedShadowPrefixReplacement[] PreparePrefixReplacements( + uint entityId, + HashSet before, + HashSet after, + uint[] changedPrefixes) + { + var result = new PreparedShadowPrefixReplacement[ + changedPrefixes.Length]; + for (int index = 0; index < changedPrefixes.Length; index++) + { + uint prefix = changedPrefixes[index]; + bool removeOwner = before.Contains(prefix) + && !after.Contains(prefix); + _prefixOwnerSlots.TryGetValue(prefix, out List? oldSlots); + _prefixOwnerIndices.TryGetValue( + prefix, + out Dictionary? oldIndices); + _prefixFreeSlots.TryGetValue(prefix, out Stack? oldFree); + var slots = oldSlots is null ? [] : new List(oldSlots); + var indices = oldIndices is null + ? new Dictionary() + : new Dictionary(oldIndices); + Stack free = CloneStack(oldFree); + if (removeOwner) + { + if (indices.Remove(entityId, out int ownerSlot)) + { + slots[ownerSlot] = 0u; + free.Push(ownerSlot); + } + result[index] = indices.Count == 0 + ? new PreparedShadowPrefixReplacement( + prefix, + Remove: true, + Slots: null, + Indices: null, + FreeSlots: null) + : new PreparedShadowPrefixReplacement( + prefix, + Remove: false, + slots, + indices, + free); + continue; + } + + if (!indices.ContainsKey(entityId)) + { + if (free.TryPop(out int freeIndex)) + { + slots[freeIndex] = entityId; + indices[entityId] = freeIndex; + } + else + { + indices[entityId] = slots.Count; + slots.Add(entityId); + } + } + result[index] = new PreparedShadowPrefixReplacement( + prefix, + Remove: false, + slots, + indices, + free); + } + return result; + } + + private static Stack CloneStack(Stack? source) => + source is null + ? new Stack() + : new Stack(source.Reverse()); + + private static void AddCells(HashSet destination, List? cells) + { + if (cells is null) + return; + for (int index = 0; index < cells.Count; index++) + destination.Add(cells[index]); + } + + private static void ReplaceOwnerValue( + Dictionary destination, + uint entityId, + T? value) + where T : class + { + if (value is null) + destination.Remove(entityId); + else + destination[entityId] = value; + } + private void RefreshPositionRows( uint entityId, RegistrationRecord registration, @@ -1896,7 +2447,9 @@ public sealed class ShadowObjectRegistry || _entityToCells.Count != 0 || _entityReg.Count != 0 || _suspendedEntities.Count != 0 - || _suspendedEntityCells.Count != 0; + || _suspendedEntityCells.Count != 0 + || _nextPreparedSetPositionCommitId + != _lastAppliedSetPositionCommitId; if (mutated) AdvanceMutationRevision(); _cells.Clear(); @@ -1916,6 +2469,7 @@ public sealed class ShadowObjectRegistry _ownerFreeSlots.Clear(); _prefixScratch.Clear(); _removedPrefixScratch.Clear(); + _pendingSetPositionDispatches.Clear(); _fallback = null; } diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 6c6c2f24..cc2e8fd5 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -707,6 +707,57 @@ public sealed class PlayerMovementController internal bool IsRuntimeOwnedDormant => _publicationLifecycle is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant; + internal bool IsRuntimePublished => _publicationLifecycle + is PlayerMovementControllerPublicationLifecycle.RuntimePublished; + + private bool _dormantSetPositionGroundPhase; + + internal void BeginDormantSetPositionGroundPhase() + { + if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase) + throw new InvalidOperationException( + "Dormant SetPosition ground phase requires one dormant Runtime owner."); + _dormantSetPositionGroundPhase = true; + } + + internal void EndDormantSetPositionGroundPhase() + { + if (!_dormantSetPositionGroundPhase) + throw new InvalidOperationException( + "Dormant SetPosition ground phase is not active."); + _body.TransientState &= ~TransientStateFlags.Active; + _dormantSetPositionGroundPhase = false; + } + + internal bool IsDormantSetPositionGroundPhaseActive => + _dormantSetPositionGroundPhase; + + internal void RefreshDormantRuntimePhysicsState( + PhysicsStateFlags state, + bool recalculateAcceleration) + { + if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase) + throw new InvalidOperationException( + "Only an idle dormant Runtime owner can refresh physics state."); + _body.State = state; + if (recalculateAcceleration) + _body.calc_acceleration(); + } + + internal void RefreshDormantRuntimeVector( + Vector3? velocity, + Vector3? omega) + { + if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase) + throw new InvalidOperationException( + "Only an idle dormant Runtime owner can refresh vector state."); + if (velocity is { } liveVelocity) + _body.set_velocity(liveVelocity); + if (omega is { } liveOmega) + _body.Omega = liveOmega; + _body.TransientState &= ~TransientStateFlags.Active; + } + internal void SealPublicationCandidate() { if (_publicationLifecycle @@ -738,7 +789,8 @@ public sealed class PlayerMovementController { if (_publicationLifecycle is not PlayerMovementControllerPublicationLifecycle - .RuntimeOwnedDormant) + .RuntimeOwnedDormant + || _dormantSetPositionGroundPhase) { throw new InvalidOperationException( "Only a dormant Runtime-owned movement controller can be activated."); @@ -747,6 +799,30 @@ public sealed class PlayerMovementController .RuntimePublished; } + /// + /// Installs the already-committed Runtime SetPosition frame into the + /// controller's interpolation/cell sidecar without invoking the public + /// teleport path. The canonical body is written by Runtime first; this + /// method only makes the controller's private frame agree while it is + /// still dormant. It deliberately does not touch CellGraph, movement, + /// PositionManager, the object clock, or callbacks. + /// + internal void CommitRuntimeActivationFrame() + { + if (_publicationLifecycle + is not PlayerMovementControllerPublicationLifecycle + .RuntimeOwnedDormant + || _dormantSetPositionGroundPhase) + { + throw new InvalidOperationException( + "Only a dormant Runtime-owned movement controller can accept its activation frame."); + } + + _prevPhysicsPos = _body.Position; + _currPhysicsPos = _body.Position; + CellId = _body.CellPosition.ObjCellId; + } + internal void DiscardRuntimeCandidate() { if (_publicationLifecycle @@ -775,7 +851,8 @@ public sealed class PlayerMovementController is PlayerMovementControllerPublicationLifecycle.StandalonePublished or PlayerMovementControllerPublicationLifecycle .CandidatePreparing - or PlayerMovementControllerPublicationLifecycle.RuntimePublished) + or PlayerMovementControllerPublicationLifecycle.RuntimePublished + || IsRuntimeOwnedDormant && _dormantSetPositionGroundPhase) { return; } @@ -807,6 +884,9 @@ public sealed class PlayerMovementController if ((_body.State & PhysicsStateFlags.Static) != 0) return; + if (IsRuntimeOwnedDormant && _dormantSetPositionGroundPhase) + return; + _objectClock.Activate(); _body.TransientState |= TransientStateFlags.Active; } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs index 17510ac7..b57ee1dd 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; using AcDream.Runtime.Entities; using AcDream.Runtime.Physics; @@ -23,6 +24,26 @@ internal enum RuntimeLocalPlayerPhysicsActivationStatus RejectedToken, } +internal enum RuntimeLocalPlayerShadowDisposition : byte +{ + RegisteredAuthoredPayload, + ProvenShapeless, +} + +internal readonly record struct RuntimeLocalPlayerPhysicsActivationPreparation( + float Radius, + float Height, + RuntimeLocalPlayerShadowDisposition ShadowDisposition) +{ + internal bool IsValid => float.IsFinite(Radius) + && Radius >= 0f + && float.IsFinite(Height) + && Height >= 0f + && ShadowDisposition is RuntimeLocalPlayerShadowDisposition + .RegisteredAuthoredPayload + or RuntimeLocalPlayerShadowDisposition.ProvenShapeless; +} + internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken( RuntimeEntityKey Entity, RuntimeEntityPlacementToken Placement, @@ -111,6 +132,12 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable { get; init; } internal required PlayerMovementController Controller { get; init; } internal required PhysicsBody Body { get; init; } + internal required EntityPhysicsHost PhysicsHost { get; init; } + internal required MovementManager Movement { get; init; } + internal required MotionInterpreter Motion { get; init; } + internal required RuntimeLocalPlayerPhysicsActivationPreparation + ActivationPreparation { get; init; } + internal required Activation PreparedActivation { get; init; } } private sealed class Activation @@ -122,8 +149,15 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable { get; init; } internal required PlayerMovementController Controller { get; init; } internal required PhysicsBody Body { get; init; } + internal required EntityPhysicsHost PhysicsHost { get; init; } + internal required MovementManager Movement { get; init; } + internal required MotionInterpreter Motion { get; init; } + internal required RuntimeLocalPlayerPhysicsActivationPreparation + ActivationPreparation { get; init; } internal RuntimeLocalPlayerPhysicsActivationReceipt Receipt { get; set; } + internal RuntimeDormantSetPositionCommitReceipt PendingFinalCommit + { get; set; } } private readonly RuntimeEntityDirectory _entities; @@ -135,6 +169,7 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable private ulong _nextPublicationId; private ulong _nextActivationId; private ulong _nextEvaluationId; + private long _activationDispatchFailureCount; private bool _disposed; internal RuntimeLocalPlayerPhysicsPublicationState( @@ -154,14 +189,25 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable in RuntimeEntityPlacementToken placement, in RuntimeSetPositionCommand command, PlayerMovementConstructionOptions options, + in RuntimeLocalPlayerPhysicsActivationPreparation activationPreparation, out RuntimeLocalPlayerPhysicsPublicationToken token) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(record); token = default; - if (!CanPrepare(record, placement, command)) + if (!activationPreparation.IsValid + || !CanPrepare(record, placement, command)) return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority; + // Exhaustion is checked before allocating or replacing a private + // candidate. A checked overflow must not strand an unowned controller + // or discard an already-prepared exact receipt. + ulong publicationId = checked(_nextPublicationId + 1UL); + ulong activationId = checked(_nextActivationId + 1UL); + + RuntimeLocalPlayerPhysicsActivationPreparation preparedActivation = + activationPreparation; + var controller = PlayerMovementController.CreatePublicationCandidate( _physics.Engine, options); @@ -177,6 +223,69 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable controller.SetBodyOrientation(command.Physics.Orientation); controller.ApplyPhysicsState(record.FinalPhysicsState); PhysicsBody body = controller.PhysicsBody; + var physics = record.Snapshot.Physics; + body.Friction = NormalizeFriction( + physics?.Friction ?? record.Snapshot.Friction); + body.Elasticity = NormalizeElasticity( + physics?.Elasticity + ?? record.Snapshot.Elasticity + ?? body.Elasticity); + if (physics?.Velocity is { } initialVelocity) + body.set_velocity(initialVelocity); + if (physics?.AngularVelocity is { } initialOmega) + body.Omega = initialOmega; + MovementManager movement = controller.Movement; + MotionInterpreter motion = controller.Motion; + EntityPhysicsHost physicsHost = null!; + movement.MoveToFactory = () => + { + var moveTo = new MoveToManager( + motion, + stopCompletely: () => + _ = controller.StopCompletelyAtPhysicsObjectBoundary(), + getPosition: () => body.CellPosition, + getHeading: () => MoveToMath.GetHeading(body.Orientation), + setHeading: (heading, _) => body.Orientation = + MoveToMath.SetHeading(body.Orientation, heading), + getOwnRadius: () => preparedActivation.Radius, + getOwnHeight: () => preparedActivation.Height, + contact: () => body.InContact, + isInterpolating: static () => false, + getVelocity: () => body.Velocity, + getSelfId: () => record.ServerGuid, + setTarget: (context, target, radius, quantum) => + physicsHost.SetTarget(context, target, radius, quantum), + clearTarget: () => physicsHost.ClearTarget(), + getTargetQuantum: () => + physicsHost.TargetManager.GetTargetQuantum(), + setTargetQuantum: quantum => + physicsHost.TargetManager.SetTargetQuantum(quantum), + curTime: () => controller.SimTimeSeconds); + moveTo.StickTo = (target, radius, height) => + physicsHost.PositionManager.StickTo(target, radius, height); + moveTo.Unstick = physicsHost.PositionManager.UnStick; + return moveTo; + }; + physicsHost = new EntityPhysicsHost( + record.ServerGuid, + getPosition: () => body.CellPosition, + getVelocity: () => body.Velocity, + getRadius: () => preparedActivation.Radius, + inContact: () => body.InContact, + minterpMaxSpeed: () => motion.GetAdjustedMaxSpeed(), + curTime: () => controller.SimTimeSeconds, + physicsTimerTime: () => controller.SimTimeSeconds, + getObjectA: id => _physics.TryGetPhysicsHost(id, out var host) + ? host + : null, + handleUpdateTarget: movement.HandleUpdateTarget, + interruptCurrentMovement: () => + movement.CancelMoveTo(WeenieError.ActionCancelled)); + movement.MakeMoveToManager(); + motion.UnstickFromObject = physicsHost.PositionManager.UnStick; + motion.InterruptCurrentMovement = () => + movement.CancelMoveTo(WeenieError.ActionCancelled); + controller.PositionManager = physicsHost.PositionManager; // This checkpoint publishes ownership only. The subsequent canonical // SetPosition transaction is the sole authority which may enter the // body into world simulation and activate its ordinary workset. @@ -194,16 +303,41 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable } DiscardCurrent(); + _nextPublicationId = publicationId; + _nextActivationId = activationId; token = new RuntimeLocalPlayerPhysicsPublicationToken( record.Key.Value, placement, - checked(++_nextPublicationId), + publicationId, _identity.ServerGuid, _identity.Revision, record.PhysicsOwnershipEpoch, record.ObjectClockEpoch, _movement.ControllerOwnershipEpoch, _entities.SessionLifetimeVersion); + ulong expectedControllerEpoch = checked( + _movement.ControllerOwnershipEpoch + 1UL); + var activationEnvelope = new Activation + { + Token = new RuntimeLocalPlayerPhysicsActivationToken( + token.Entity, + token.Placement, + activationId, + token.LocalPlayerServerGuid, + token.LocalPlayerIdentityRevision, + checked(token.PhysicsOwnershipEpoch + 1UL), + token.ObjectClockEpoch, + expectedControllerEpoch, + token.SessionGenerationAuthority), + Record = record, + PlacementCommand = command, + Controller = controller, + Body = body, + PhysicsHost = physicsHost, + Movement = movement, + Motion = motion, + ActivationPreparation = preparedActivation, + }; _candidate = new Candidate { Token = token, @@ -211,10 +345,27 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable PlacementCommand = command, Controller = controller, Body = body, + PhysicsHost = physicsHost, + Movement = movement, + Motion = motion, + ActivationPreparation = preparedActivation, + PreparedActivation = activationEnvelope, }; return RuntimeLocalPlayerPhysicsPublicationStatus.Prepared; } + private static float NormalizeFriction(float? value) => + value is >= 0f and <= 1f && float.IsFinite(value.Value) + ? value.Value + : PhysicsBody.DefaultFriction; + + private static float NormalizeElasticity(float value) + { + if (float.IsNaN(value) || value <= 0f) + return 0f; + return MathF.Min(value, 0.1f); + } + internal RuntimeLocalPlayerPhysicsPublicationStatus Commit( in RuntimeLocalPlayerPhysicsPublicationToken token) => Commit(token, out _); @@ -245,24 +396,8 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable candidate.Record.ObjectClock); candidate.Record.SetPhysicsBody(candidate.Body); _movement.CommitRuntimeOwnedController(candidate.Controller); - activationToken = new RuntimeLocalPlayerPhysicsActivationToken( - candidate.Token.Entity, - candidate.Token.Placement, - checked(++_nextActivationId), - candidate.Token.LocalPlayerServerGuid, - candidate.Token.LocalPlayerIdentityRevision, - candidate.Record.PhysicsOwnershipEpoch, - candidate.Record.ObjectClockEpoch, - _movement.ControllerOwnershipEpoch, - _entities.SessionLifetimeVersion); - _activation = new Activation - { - Token = activationToken, - Record = candidate.Record, - PlacementCommand = candidate.PlacementCommand, - Controller = candidate.Controller, - Body = candidate.Body, - }; + activationToken = candidate.PreparedActivation.Token; + _activation = candidate.PreparedActivation; _candidate = null; return RuntimeLocalPlayerPhysicsPublicationStatus.Committed; } @@ -291,6 +426,16 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable activation.PlacementCommand, out RuntimeDormantSetPositionEvaluation placement)) { + if (ReferenceEquals(_activation, activation) + && IsActivationCurrent(activation) + && _physics.SetPosition.IsDormantLocalActivationAwaitingCell( + activation.Record, + activation.Body, + token.Placement, + activation.PlacementCommand)) + { + return RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell; + } if (ReferenceEquals(_activation, activation) && !IsActivationCurrent(activation)) { @@ -329,6 +474,304 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable receipt.Placement); } + internal RuntimeDormantSetPositionCommitStatus CommitActivation( + in RuntimeLocalPlayerPhysicsActivationReceipt receipt, + out RuntimePlacementProjectionToken projection) + { + ObjectDisposedException.ThrowIf(_disposed, this); + projection = default; + if (!receipt.IsValid + || _activation is not { } activation + || activation.Token != receipt.Token + || activation.Receipt != receipt) + { + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + if (activation.PendingFinalCommit.Status + is RuntimeDormantSetPositionCommitStatus + .AwaitingFinalShadowPreparation) + { + return FinalizeActivation( + activation, + activation.PendingFinalCommit, + out projection); + } + if (!IsActivationCurrent(activation) + || !_physics.SetPosition.IsDormantLocalEvaluationCurrent( + activation.Record, + activation.Body, + receipt.Placement)) + { + activation.Receipt = default; + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + + bool provenShapeless = activation.ActivationPreparation + .ShadowDisposition + is RuntimeLocalPlayerShadowDisposition.ProvenShapeless; + if (!_physics.SetPosition.TryPrepareDormantLocalActivationCommit( + activation.Record, + activation.Body, + receipt.Placement, + provenShapeless, + out PreparedDormantSetPositionCommit? prepared) + || prepared is null + || !IsActivationCurrent(activation)) + { + activation.Receipt = default; + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + + if (!_physics.SetPosition.TryApplyDormantLocalActivationCommit( + activation.Record, + activation.Body, + activation.Controller, + activation.PhysicsHost, + prepared, + out RuntimeDormantSetPositionCommitReceipt committed)) + { + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + if (committed.Status is RuntimeDormantSetPositionCommitStatus.DeferredCell) + { + activation.Receipt = default; + _physics.SetPosition.DispatchDormantLocalActivationShadow(committed); + return committed.Status; + } + + // Named retail SetPosition: contact prefix, ground edge, second + // acceleration/sliding, collision callbacks, physical response, then + // shadow/live publication. + try + { + activation.Controller.BeginDormantSetPositionGroundPhase(); + if (committed.HitGround) + activation.Movement.HitGround(); + else if (committed.LeaveGround) + activation.Motion.LeaveGround(); + } + catch + { + _activationDispatchFailureCount++; + } + finally + { + activation.Controller.EndDormantSetPositionGroundPhase(); + } + if (committed.Status is RuntimeDormantSetPositionCommitStatus + .AwaitingFinalShadowPreparation + && (!IsActivationPrephaseEnvelopeCurrent(activation, committed) + || !RefreshDormantVector(activation, committed) + || !RefreshDormantState(activation) + || !_physics.SetPosition.CommitDormantLocalActivationPostGround( + activation.Record, + activation.Body, + committed))) + { + AbortActivation( + activation, committed, collisionAlreadyDispatched: false); + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + try + { + SetPositionCollisionBatchDispatchResult collisionDispatch = + _physics.SetPosition.DispatchDormantLocalActivationCollision( + committed); + if (collisionDispatch.Status + is not SetPositionCollisionBatchDispatchStatus.Completed) + { + AbortActivation( + activation, committed, collisionAlreadyDispatched: true); + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + } + catch + { + _activationDispatchFailureCount++; + AbortActivation( + activation, committed, collisionAlreadyDispatched: true); + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + if (!IsActivationResponseEnvelopeCurrent(activation, committed)) + { + AbortActivation( + activation, committed, collisionAlreadyDispatched: true); + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + activation.Controller.RefreshDormantRuntimePhysicsState( + activation.Record.FinalPhysicsState, + recalculateAcceleration: false); + _ = RefreshDormantVector(activation, committed); + if (committed.Status is RuntimeDormantSetPositionCommitStatus + .AwaitingFinalShadowPreparation + && !IsActivationPrephaseEnvelopeCurrent(activation, committed) + || !_physics.SetPosition.CommitDormantLocalActivationPostCollision( + activation.Record, + activation.Body, + committed)) + { + AbortActivation( + activation, committed, collisionAlreadyDispatched: true); + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + if (committed.Status is RuntimeDormantSetPositionCommitStatus + .RejectedPlacement) + { + activation.Receipt = default; + activation.PendingFinalCommit = committed; + return committed.Status; + } + activation.PendingFinalCommit = committed; + return FinalizeActivation(activation, committed, out projection); + } + + private RuntimeDormantSetPositionCommitStatus FinalizeActivation( + Activation activation, + in RuntimeDormantSetPositionCommitReceipt prephase, + out RuntimePlacementProjectionToken projection) + { + projection = default; + if (!IsActivationPrephaseEnvelopeCurrent(activation, prephase)) + { + AbortActivation( + activation, prephase, collisionAlreadyDispatched: true); + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + activation.Controller.RefreshDormantRuntimePhysicsState( + activation.Record.FinalPhysicsState, + recalculateAcceleration: false); + bool provenShapeless = activation.ActivationPreparation + .ShadowDisposition is RuntimeLocalPlayerShadowDisposition.ProvenShapeless; + if (!_physics.SetPosition.TryPrepareDormantLocalActivationFinalCommit( + activation.Record, + activation.Body, + prephase, + provenShapeless, + out PreparedDormantActivationFinalCommit? prepared) + || prepared is null) + { + if (IsActivationPrephaseEnvelopeCurrent(activation, prephase)) + return prephase.Status; + AbortActivation( + activation, prephase, collisionAlreadyDispatched: true); + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + if (!IsActivationPrephaseEnvelopeCurrent(activation, prephase)) + { + AbortActivation( + activation, prephase, collisionAlreadyDispatched: true); + return RuntimeDormantSetPositionCommitStatus.RejectedAuthority; + } + if (!_physics.SetPosition.TryApplyDormantLocalActivationFinalCommit( + activation.Record, + activation.Body, + activation.Controller, + activation.PhysicsHost, + prephase, + prepared, + out RuntimeDormantSetPositionCommitReceipt committed)) + { + return prephase.Status; + } + activation.Receipt = default; + activation.PendingFinalCommit = default; + _activation = null; + _physics.SetPosition.DispatchDormantLocalActivationShadow(committed); + if (!IsCommittedActivationSuffixCurrent(activation, committed)) + return committed.Status; + _physics.SetPosition.DispatchDormantLocalActivationPlacement(committed); + projection = committed.Projection.Token; + return committed.Status; + } + + private bool IsActivationPrephaseEnvelopeCurrent( + Activation activation, + in RuntimeDormantSetPositionCommitReceipt receipt) => + IsActivationOwnershipEnvelopeCurrent(activation) + && _physics.IsCollisionEvaluationFatalAuthorityCurrent( + receipt.CollisionAuthority) + && _physics.SetPosition.IsDormantLocalActivationPrephaseCurrent( + activation.Record, + activation.Body, + receipt); + + private bool IsActivationResponseEnvelopeCurrent( + Activation activation, + in RuntimeDormantSetPositionCommitReceipt receipt) => + IsActivationOwnershipEnvelopeCurrent(activation) + && _physics.IsCollisionEvaluationFatalAuthorityCurrent( + receipt.CollisionAuthority) + && _physics.SetPosition.IsDormantLocalActivationResponseCurrent( + activation.Record, + activation.Body, + receipt); + + private bool IsActivationOwnershipEnvelopeCurrent( + Activation activation) => + activation.Controller.IsRuntimeOwnedDormant + && !activation.Controller.IsDormantSetPositionGroundPhaseActive + && activation.Controller.OwnsPhysicsBody(activation.Body) + && _entities.SessionLifetimeVersion + == activation.Token.SessionGenerationAuthority + && _entities.IsCurrent(activation.Record) + && activation.Record.Key == activation.Token.Entity + && !_identity.IsDisposed + && _identity.ServerGuid == activation.Token.LocalPlayerServerGuid + && _identity.ServerGuid == activation.Record.ServerGuid + && _identity.Revision == activation.Token.LocalPlayerIdentityRevision + && activation.Record.PhysicsOwnershipEpoch + == activation.Token.PhysicsOwnershipEpoch + && activation.Record.ObjectClockEpoch + == activation.Token.ObjectClockEpoch + && _movement.CanCommitRuntimeOwnedController( + activation.Token.ControllerOwnershipEpoch, + activation.Controller) + && ReferenceEquals(activation.Record.PhysicsBody, activation.Body) + && activation.Record.PhysicsHost is null + && activation.Record.RemoteMotion is null + && activation.Record.Projectile is null + && !activation.Record.PhysicsBodyAcquisitionInProgress + && !activation.Record.RemoteMotionBindingInProgress + && !activation.Record.ProjectileBindingInProgress + && !activation.Record.RequiresRemotePlacementRuntime + && !activation.Record.DeleteAcceptedForTeardown; + + private static bool RefreshDormantState(Activation activation) + { + activation.Controller.RefreshDormantRuntimePhysicsState( + activation.Record.FinalPhysicsState, + recalculateAcceleration: false); + return true; + } + + private static bool RefreshDormantVector( + Activation activation, + in RuntimeDormantSetPositionCommitReceipt receipt) + { + if (activation.Record.VectorAuthorityVersion + == receipt.SourceVectorAuthorityVersion) + return true; + var physics = activation.Record.Snapshot.Physics; + activation.Controller.RefreshDormantRuntimeVector( + physics?.Velocity, + physics?.AngularVelocity); + return true; + } + + private void AbortActivation( + Activation activation, + in RuntimeDormantSetPositionCommitReceipt receipt, + bool collisionAlreadyDispatched) + { + _physics.SetPosition.RetireDormantLocalActivation( + receipt, + collisionAlreadyDispatched); + activation.Receipt = default; + activation.PendingFinalCommit = default; + if (ReferenceEquals(_activation, activation)) + DiscardActivation(); + } + internal bool DiscardActivation( in RuntimeLocalPlayerPhysicsActivationToken token) { @@ -389,6 +832,9 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable _activation is null ? 0 : 1, _nextPublicationId); + internal long ActivationDispatchFailureCount => + _activationDispatchFailureCount; + internal void ResetSession() { ObjectDisposedException.ThrowIf(_disposed, this); @@ -501,15 +947,67 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable && !activation.Record.ProjectileBindingInProgress && !activation.Record.RequiresRemotePlacementRuntime && !activation.Record.DeleteAcceptedForTeardown - && _physics.SetPosition.IsExactPreparedPlacementCurrent( + && _physics.SetPosition.IsDormantLocalActivationLeaseCurrent( activation.Record, + activation.Body, activation.Token.Placement, activation.PlacementCommand); + private bool IsCommittedActivationSuffixCurrent( + Activation activation, + in RuntimeDormantSetPositionCommitReceipt receipt) + { + return activation.Token.ObjectClockEpoch != ulong.MaxValue + && _entities.SessionLifetimeVersion + == activation.Token.SessionGenerationAuthority + && _entities.IsCurrent(activation.Record) + && activation.Record.Key == activation.Token.Entity + && !_identity.IsDisposed + && _identity.ServerGuid == activation.Token.LocalPlayerServerGuid + && _identity.ServerGuid == activation.Record.ServerGuid + && _identity.Revision + == activation.Token.LocalPlayerIdentityRevision + && activation.Record.PhysicsOwnershipEpoch + == activation.Token.PhysicsOwnershipEpoch + && activation.Record.ObjectClockEpoch + == activation.Token.ObjectClockEpoch + 1UL + && _movement.ControllerOwnershipEpoch + == activation.Token.ControllerOwnershipEpoch + && ReferenceEquals(_movement.Controller, activation.Controller) + && activation.Controller.IsRuntimePublished + && activation.Controller.OwnsPhysicsBody(activation.Body) + && ReferenceEquals( + activation.Record.PhysicsBody, + activation.Body) + && ReferenceEquals( + activation.Record.PhysicsHost, + activation.PhysicsHost) + && activation.Record.RemoteMotion is null + && activation.Record.Projectile is null + && !activation.Record.DeleteAcceptedForTeardown + && _physics.SetPosition.IsDormantLocalActivationCommitCurrent( + activation.Record, + activation.Body, + receipt); + } + private void DiscardActivation() { Activation? activation = _activation; _activation = null; + if (activation is not null) + { + if (activation.PendingFinalCommit.Status + is not RuntimeDormantSetPositionCommitStatus.None) + { + _physics.SetPosition.RetireDormantLocalActivation( + activation.PendingFinalCommit, + collisionAlreadyDispatched: true); + } + _physics.SetPosition.RetireDormantLocalActivationToken( + activation.Record, + activation.Token.Placement); + } if (activation is not null && _entities.IsCurrent(activation.Record) && ReferenceEquals( diff --git a/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs b/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs index cbd61c95..b3dbc360 100644 --- a/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs @@ -11,6 +11,17 @@ internal enum RuntimeCollisionReportKind EnvironmentCollision, } +internal enum SetPositionCollisionBatchDispatchStatus : byte +{ + RejectedReceipt, + Displaced, + Completed, +} + +internal readonly record struct SetPositionCollisionBatchDispatchResult( + SetPositionCollisionBatchDispatchStatus Status, + bool Reported); + /// /// Immutable presentation-free projection of one retail weenie collision /// callback. Runtime commits the callback before an observer can re-enter. @@ -38,6 +49,7 @@ internal readonly record struct RuntimeCollisionReportingOwnershipSnapshot( int PendingReportCount, int LeavingOwnerCount, int AdmissionBlockedOwnerCount, + int PendingSetPositionDispatchCount, bool IsDispatching, long DispatchFailureCount, bool IsDisposed) @@ -51,6 +63,7 @@ internal readonly record struct RuntimeCollisionReportingOwnershipSnapshot( && PendingReportCount == 0 && LeavingOwnerCount == 0 && AdmissionBlockedOwnerCount == 0 + && PendingSetPositionDispatchCount == 0 && !IsDispatching; } @@ -78,6 +91,10 @@ internal sealed class RuntimeCollisionReportingState : IDisposable private ulong _nextSequence; private ulong _dispatchEpoch = 1UL; private long _dispatchFailureCount; + private ulong _mutationRevision; + private ulong _nextPreparedBatchId; + private ulong _lastInstalledBatchId; + private readonly HashSet _pendingSetPositionDispatches = []; private bool _dispatching; private bool _disposed; @@ -102,6 +119,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable _pendingReports.Count, _leaving.Count, _admissionBlocked.Count, + _pendingSetPositionDispatches.Count, _dispatching, _dispatchFailureCount, _disposed); @@ -125,6 +143,574 @@ internal sealed class RuntimeCollisionReportingState : IDisposable return new Subscription(this, observer); } + internal enum StagedReportEligibility : byte + { + Environment, + Object, + } + + private sealed record FrozenCollisionSubject( + uint LocalEntityId, + bool IsStatic, + RuntimeEntityRecord? Record, + PhysicsBody? Body, + RuntimeEntityKey Key); + + internal sealed record StagedReportAction( + StagedReportEligibility Eligibility, + RuntimeEntityRecord Recipient, + PhysicsBody RecipientBody, + RuntimeEntityKey RecipientKey, + RuntimeEntityRecord? Other, + PhysicsBody? OtherBody, + RuntimeEntityKey? OtherKey, + bool RecipientContact, + bool ExactDormantRecipient, + ulong RecipientPositionAuthorityVersion); + + internal sealed class PreparedSetPositionCollisionBatch + { + internal required ulong BatchId { get; init; } + internal required ulong ExpectedMutationRevision { get; init; } + internal required ulong InstalledMutationRevision { get; init; } + internal required ulong SessionLifetimeVersion { get; init; } + internal required RuntimeEntityRecord Owner { get; init; } + internal required PhysicsBody OwnerBody { get; init; } + internal required RuntimeEntityKey OwnerKey { get; init; } + internal required ulong OwnerPositionAuthorityVersion { get; init; } + internal required OwnerState? OwnerState { get; init; } + internal required bool PreviousContact { get; init; } + internal required bool FinalCollidedWithEnvironment { get; init; } + internal required bool FinalGroundEdge { get; init; } + internal required double PhysicsTime { get; init; } + internal required StagedReportAction[] Actions { get; init; } + } + + internal readonly record struct SetPositionCollisionBatchReceipt( + ulong BatchId, + RuntimeEntityRecord Owner, + PhysicsBody OwnerBody, + RuntimeEntityKey OwnerKey, + ulong OwnerPositionAuthorityVersion, + bool PreviousContact, + bool FinalCollidedWithEnvironment, + bool FinalGroundEdge, + double PhysicsTime, + StagedReportAction[] Actions) + { + internal bool IsValid => BatchId != 0UL; + } + + /// + /// Freezes retail COLLISIONINFO subjects and prepares the owner-table, + /// environment-latch, and callback subjects without mutating Runtime. + /// Dynamic tracking and reverse-index writes occur during ordered dispatch + /// after each subject's live behavior flags are revalidated. Callback + /// eligibility is evaluated after the dormant + /// frame/contact/ground prephase and before physical response, shadow + /// publication, and enter-world activation, matching retail SetPosition. + /// + internal bool TryPrepareSetPositionBatch( + RuntimeEntityRecord owner, + PhysicsBody ownerBody, + double physicsTime, + bool previousContact, + bool previousOnWalkable, + bool finalOnWalkable, + bool collidedWithEnvironment, + ImmutableArray collidedObjectIds, + out PreparedSetPositionCollisionBatch? prepared) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(owner); + ArgumentNullException.ThrowIfNull(ownerBody); + prepared = null; + RuntimeEntityKey ownerKey = owner.Key ?? default; + if (!double.IsFinite(physicsTime) + || ownerKey == default + || !IsKnownParticipant(owner, ownerBody, ownerKey) + || _leaving.Contains(ownerKey) + || _admissionBlocked.Contains(ownerKey)) + { + return false; + } + + ulong expectedMutation = _mutationRevision; + ulong installedMutation = checked(expectedMutation + 1UL); + ulong sessionLifetime = _entities.SessionLifetimeVersion; + if (collidedObjectIds.IsDefault) + collidedObjectIds = ImmutableArray.Empty; + var subjects = new List( + collidedObjectIds.Length); + for (int index = 0; index < collidedObjectIds.Length; index++) + { + uint localId = collidedObjectIds[index]; + if (localId == 0u || localId == ownerKey.LocalEntityId) + continue; + if (!_shadows.TryGetCollisionOwner( + localId, + out uint shadowState, + out bool isStatic)) + { + continue; + } + if (isStatic) + { + subjects.Add(new FrozenCollisionSubject( + localId, + IsStatic: true, + Record: null, + Body: null, + Key: default)); + continue; + } + if (!_entities.TryGetByLocalId(localId, out RuntimeEntityRecord target) + || target.PhysicsBody is not { } targetBody + || !_entities.IsCurrent(target) + || target.Key is not { } targetKey) + { + continue; + } + subjects.Add(new FrozenCollisionSubject( + localId, + IsStatic: false, + target, + targetBody, + targetKey)); + _ = shadowState; + } + + OwnerState staged = CloneOwnerState(TryGetOwner(ownerKey)); + var actions = new List(subjects.Count); + void StageEnvironment() + { + actions.Add(new StagedReportAction( + StagedReportEligibility.Environment, + owner, + ownerBody, + ownerKey, + Other: null, + OtherBody: null, + OtherKey: null, + RecipientContact: previousContact, + ExactDormantRecipient: !ownerBody.InWorld, + RecipientPositionAuthorityVersion: + owner.PositionAuthorityVersion)); + } + for (int index = 0; index < subjects.Count; index++) + { + FrozenCollisionSubject subject = subjects[index]; + if (subject.IsStatic) + { + StageEnvironment(); + continue; + } + RuntimeEntityRecord target = subject.Record!; + PhysicsBody targetBody = subject.Body!; + RuntimeEntityKey targetKey = subject.Key; + // Dynamic classification and behavior flags are evaluated after + // the ground edge. Retain the old record unchanged in the + // installed batch; the ordered tracking action below performs + // retail's timestamp/Ethereal clobber or Static conversion. + actions.Add(new StagedReportAction( + StagedReportEligibility.Object, + owner, + ownerBody, + ownerKey, + target, + targetBody, + targetKey, + RecipientContact: previousContact, + ExactDormantRecipient: !ownerBody.InWorld, + RecipientPositionAuthorityVersion: + owner.PositionAuthorityVersion)); + } + + _owners.EnsureCapacity(_owners.Count + 1); + _pendingSetPositionDispatches.EnsureCapacity( + _pendingSetPositionDispatches.Count + 1); + prepared = new PreparedSetPositionCollisionBatch + { + BatchId = checked(++_nextPreparedBatchId), + ExpectedMutationRevision = expectedMutation, + InstalledMutationRevision = installedMutation, + SessionLifetimeVersion = sessionLifetime, + Owner = owner, + OwnerBody = ownerBody, + OwnerKey = ownerKey, + OwnerPositionAuthorityVersion = owner.PositionAuthorityVersion, + OwnerState = staged.Records.Count == 0 + && !staged.CollidingWithEnvironment + ? null + : staged, + PreviousContact = previousContact, + FinalCollidedWithEnvironment = collidedWithEnvironment, + FinalGroundEdge = !previousOnWalkable && finalOnWalkable, + PhysicsTime = physicsTime, + Actions = actions.ToArray(), + }; + _ = previousContact; + return _mutationRevision == expectedMutation + && _entities.SessionLifetimeVersion == sessionLifetime + && IsKnownParticipant(owner, ownerBody, ownerKey); + } + + internal bool TryInstallSetPositionBatch( + PreparedSetPositionCollisionBatch prepared, + out SetPositionCollisionBatchReceipt receipt) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(prepared); + receipt = default; + if (prepared.BatchId <= _lastInstalledBatchId + || _mutationRevision != prepared.ExpectedMutationRevision + || _entities.SessionLifetimeVersion + != prepared.SessionLifetimeVersion + || !IsKnownParticipant( + prepared.Owner, + prepared.OwnerBody, + prepared.OwnerKey)) + { + return false; + } + if (!IsKnownParticipant( + prepared.Owner, + prepared.OwnerBody, + prepared.OwnerKey)) + return false; + + if (prepared.OwnerState is null) + _owners.Remove(prepared.OwnerKey); + else + { + prepared.OwnerState.SetPositionBatchId = prepared.BatchId; + _owners[prepared.OwnerKey] = prepared.OwnerState; + } + _mutationRevision = prepared.InstalledMutationRevision; + _lastInstalledBatchId = prepared.BatchId; + _pendingSetPositionDispatches.Add(prepared.BatchId); + receipt = new SetPositionCollisionBatchReceipt( + prepared.BatchId, + prepared.Owner, + prepared.OwnerBody, + prepared.OwnerKey, + prepared.OwnerPositionAuthorityVersion, + prepared.PreviousContact, + prepared.FinalCollidedWithEnvironment, + prepared.FinalGroundEdge, + prepared.PhysicsTime, + prepared.Actions); + return true; + } + + internal bool IsPreparedSetPositionBatchCurrent( + PreparedSetPositionCollisionBatch prepared) + { + ArgumentNullException.ThrowIfNull(prepared); + return !_disposed + && prepared.BatchId > _lastInstalledBatchId + && _mutationRevision == prepared.ExpectedMutationRevision + && _entities.SessionLifetimeVersion + == prepared.SessionLifetimeVersion + && IsKnownParticipant( + prepared.Owner, + prepared.OwnerBody, + prepared.OwnerKey); + } + + internal bool DispatchSetPositionBatch( + in SetPositionCollisionBatchReceipt receipt) => + DispatchSetPositionBatchResult(receipt).Reported; + + internal SetPositionCollisionBatchDispatchResult + DispatchSetPositionBatchResult( + in SetPositionCollisionBatchReceipt receipt) + { + if (!receipt.IsValid + || _disposed + || receipt.BatchId > _lastInstalledBatchId + || !_pendingSetPositionDispatches.Remove(receipt.BatchId)) + { + return new( + SetPositionCollisionBatchDispatchStatus.RejectedReceipt, + Reported: false); + } + bool reported = false; + for (int index = 0; index < receipt.Actions.Length; index++) + { + if (receipt.Owner.PositionAuthorityVersion + != receipt.OwnerPositionAuthorityVersion + || _owners.TryGetValue( + receipt.OwnerKey, out OwnerState? currentOwner) + && currentOwner.SetPositionBatchId != receipt.BatchId) + { + return new( + SetPositionCollisionBatchDispatchStatus.Displaced, + reported); + } + StagedReportAction action = receipt.Actions[index]; + if (action.Eligibility is StagedReportEligibility.Environment) + { + reported |= DispatchEnvironmentAction( + action.Recipient, + action.RecipientBody, + action.RecipientKey, + action.RecipientContact, + receipt.BatchId); + continue; + } + reported |= DispatchTrackingAction( + action, receipt.PhysicsTime, receipt.BatchId); + } + + if (receipt.Owner.PositionAuthorityVersion + != receipt.OwnerPositionAuthorityVersion + || _owners.TryGetValue( + receipt.OwnerKey, out OwnerState? suffixOwner) + && suffixOwner.SetPositionBatchId != receipt.BatchId) + { + return new( + SetPositionCollisionBatchDispatchStatus.Displaced, + reported); + } + + // Retail chooses the expired set only after every current collision + // has either refreshed its live Ethereal bit/timestamp or converted + // to environment. EndExpiredObjectCollisions predeletes the complete + // selected suffix before its first callback. + EndExpiredObjectCollisions( + receipt.Owner, + receipt.OwnerBody, + receipt.OwnerKey, + receipt.PhysicsTime, + force: false, + receipt.BatchId, + receipt.OwnerPositionAuthorityVersion); + if (!IsSetPositionBatchOwnerCurrent(receipt)) + { + return new( + SetPositionCollisionBatchDispatchStatus.Displaced, + reported); + } + reported |= DispatchEnvironmentSuffix(receipt); + return new( + SetPositionCollisionBatchDispatchStatus.Completed, + reported); + } + + internal bool DiscardSetPositionBatch( + in SetPositionCollisionBatchReceipt receipt) => + receipt.IsValid + && _pendingSetPositionDispatches.Remove(receipt.BatchId); + + internal void RetireSetPositionBatchOwner( + in SetPositionCollisionBatchReceipt receipt) + { + if (!receipt.IsValid || _disposed) + return; + _pendingSetPositionDispatches.Remove(receipt.BatchId); + if (!IsKnownParticipant( + receipt.Owner, + receipt.OwnerBody, + receipt.OwnerKey) + || !_owners.TryGetValue( + receipt.OwnerKey, out OwnerState? owner) + || owner.SetPositionBatchId != receipt.BatchId) + { + return; + } + _mutationRevision = checked(_mutationRevision + 1UL); + if (!_admissionBlocked.Add(receipt.OwnerKey)) + return; + try + { + ForceEnd(receipt.Owner, receipt.OwnerKey); + if (_owners.TryGetValue(receipt.OwnerKey, out OwnerState? current) + && ReferenceEquals(current, owner) + && current.SetPositionBatchId == receipt.BatchId) + { + current.CollidingWithEnvironment = false; + _owners.Remove(receipt.OwnerKey); + } + } + finally + { + _admissionBlocked.Remove(receipt.OwnerKey); + } + } + + private bool DispatchTrackingAction( + StagedReportAction action, + double physicsTime, + ulong batchId) + { + if (action.Other is null + || action.OtherBody is null + || action.OtherKey is not { } targetKey + || !IsKnownParticipant( + action.Recipient, + action.RecipientBody, + action.RecipientKey) + || !IsKnownParticipant( + action.Other, + action.OtherBody, + targetKey)) + { + return false; + } + + PhysicsStateFlags targetState = action.OtherBody.State; + if ((targetState & PhysicsStateFlags.Static) != 0) + { + // track_object_collision is skipped entirely on this path. Any + // older record therefore retains its old timestamp and remains + // eligible for the immediately-following expiry pass. + return DispatchEnvironmentAction( + action.Recipient, + action.RecipientBody, + action.RecipientKey, + action.RecipientContact, + batchId); + } + + OwnerState state = GetOrCreateOwner(action.RecipientKey, batchId); + bool isNew = !state.Records.ContainsKey(targetKey); + state.Records[targetKey] = new CollisionRecord( + physicsTime, + (targetState & PhysicsStateFlags.Ethereal) != 0, + action.Other.ServerGuid); + if (!isNew) + { + _mutationRevision = checked(_mutationRevision + 1UL); + return false; + } + + state.Order.Add(targetKey); + AddReverseOwner(targetKey, action.RecipientKey); + _mutationRevision = checked(_mutationRevision + 1UL); + return ReportObject( + action.Recipient, + action.RecipientBody, + action.RecipientKey, + action.Other, + action.OtherBody, + targetKey, + targetState, + action.RecipientContact, + action.ExactDormantRecipient, + action.RecipientPositionAuthorityVersion, + batchId); + } + + private bool DispatchEnvironmentAction( + RuntimeEntityRecord owner, + PhysicsBody ownerBody, + RuntimeEntityKey ownerKey, + bool previousContact, + ulong batchId) + { + if (!IsKnownParticipant(owner, ownerBody, ownerKey)) + return false; + OwnerState state = GetOrCreateOwner(ownerKey, batchId); + if (state.CollidingWithEnvironment) + return false; + + state.CollidingWithEnvironment = true; + _mutationRevision = checked(_mutationRevision + 1UL); + bool reported = (ownerBody.State + & PhysicsStateFlags.ReportCollisions) != 0; + if (reported) + { + Publish(new RuntimeCollisionReport( + NextSequence(), + RuntimeCollisionReportKind.EnvironmentCollision, + ownerKey, + owner.ServerGuid, + Other: null, + OtherServerGuid: null, + previousContact, + OtherWasInContact: false)); + } + + // Environment collision tests Missile after the callback returns. + StopMissileForStagedOwner( + owner, + ownerBody, + ownerKey, + requireCurrentMissile: true); + return reported; + } + + private bool DispatchEnvironmentSuffix( + in SetPositionCollisionBatchReceipt receipt) + { + if (!IsSetPositionBatchOwnerCurrent(receipt) + || !IsKnownParticipant( + receipt.Owner, + receipt.OwnerBody, + receipt.OwnerKey)) + { + return false; + } + + OwnerState? state = TryGetOwner(receipt.OwnerKey); + if (state?.CollidingWithEnvironment == true) + { + if (state.CollidingWithEnvironment + != receipt.FinalCollidedWithEnvironment) + { + state.CollidingWithEnvironment = + receipt.FinalCollidedWithEnvironment; + _mutationRevision = checked(_mutationRevision + 1UL); + } + } + else if (receipt.FinalCollidedWithEnvironment + || receipt.FinalGroundEdge) + { + bool reported = DispatchEnvironmentAction( + receipt.Owner, + receipt.OwnerBody, + receipt.OwnerKey, + receipt.PreviousContact, + receipt.BatchId); + TrimEmptyOwner(receipt.OwnerKey); + return reported; + } + TrimEmptyOwner(receipt.OwnerKey); + return false; + } + + private void StopMissileForStagedOwner( + RuntimeEntityRecord owner, + PhysicsBody ownerBody, + RuntimeEntityKey ownerKey, + bool requireCurrentMissile) + { + if (!IsKnownParticipant(owner, ownerBody, ownerKey) + || !_entities.StopMissileAfterCollision( + owner, + requireCurrentMissile)) + { + return; + } + _shadows.UpdatePhysicsState( + ownerKey.LocalEntityId, + (uint)owner.FinalPhysicsState); + } + + private static OwnerState CloneOwnerState(OwnerState? source) + { + var clone = new OwnerState(); + if (source is null) + return clone; + foreach ((RuntimeEntityKey key, CollisionRecord record) + in source.Records) + clone.Records.Add(key, record); + clone.Order.AddRange(source.Order); + clone.CollidingWithEnvironment = source.CollidingWithEnvironment; + return clone; + } + /// /// Ports the reporting/tracking portion of retail /// CPhysicsObj::handle_all_collisions (0x00514780). The return is @@ -148,6 +734,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable { return false; } + _mutationRevision = checked(_mutationRevision + 1UL); bool reported = false; if (collidedObjectIds.IsDefault) @@ -266,6 +853,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable ArgumentNullException.ThrowIfNull(record); if (record.Key is not { } key) return; + _mutationRevision = checked(_mutationRevision + 1UL); if (!_admissionBlocked.Add(key)) return; try @@ -288,6 +876,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(records); + _mutationRevision = checked(_mutationRevision + 1UL); var blocked = new List<(RuntimeEntityRecord Record, RuntimeEntityKey Key)>( records.Count); for (int index = 0; index < records.Count; index++) @@ -322,6 +911,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable ArgumentNullException.ThrowIfNull(record); if (record.Key is not { } key) return; + _mutationRevision = checked(_mutationRevision + 1UL); // A session-clear batch blocks every owner before publishing the // first force-end callback. A callback may synchronously accept the // deletion of a later, already-blocked owner. That owner must still @@ -342,11 +932,13 @@ internal sealed class RuntimeCollisionReportingState : IDisposable internal void ResetSession() { EnsureNotDisposed(); + _mutationRevision = checked(_mutationRevision + 1UL); _owners.Clear(); _ownersByPeer.Clear(); _pendingReports.Clear(); _leaving.Clear(); _admissionBlocked.Clear(); + _pendingSetPositionDispatches.Clear(); _dispatchEpoch = checked(_dispatchEpoch + 1UL); } @@ -359,6 +951,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable _pendingReports.Clear(); _leaving.Clear(); _admissionBlocked.Clear(); + _pendingSetPositionDispatches.Clear(); _observers = []; _dispatchEpoch = checked(_dispatchEpoch + 1UL); _disposed = true; @@ -372,7 +965,10 @@ internal sealed class RuntimeCollisionReportingState : IDisposable PhysicsBody targetBody, RuntimeEntityKey targetKey, PhysicsStateFlags targetState, - bool previousContact) + bool previousContact, + bool exactDormantOwner = false, + ulong expectedOwnerPositionAuthorityVersion = 0UL, + ulong setPositionBatchId = 0UL) { if ((targetState & PhysicsStateFlags.ReportAsEnvironment) != 0) { @@ -380,7 +976,8 @@ internal sealed class RuntimeCollisionReportingState : IDisposable owner, ownerBody, ownerKey, - previousContact); + previousContact, + setPositionBatchId); } PhysicsStateFlags ownerState = ownerBody.State; @@ -416,7 +1013,13 @@ internal sealed class RuntimeCollisionReportingState : IDisposable // reentrant state update can therefore suppress this second report. bool targetReported = IsCurrentParticipant(target, targetBody, targetKey) && (targetBody.State & PhysicsStateFlags.ReportCollisions) != 0 - && IsCurrentParticipant(owner, ownerBody, ownerKey) + && (IsCurrentParticipant(owner, ownerBody, ownerKey) + || exactDormantOwner + && IsExactDormantParticipant( + owner, + ownerBody, + ownerKey, + expectedOwnerPositionAuthorityVersion)) && (ownerBody.State & PhysicsStateFlags.IgnoreCollisions) == 0; if (targetReported) { @@ -433,13 +1036,31 @@ internal sealed class RuntimeCollisionReportingState : IDisposable return ownerReported || targetReported; } + private bool IsExactDormantParticipant( + RuntimeEntityRecord record, + PhysicsBody body, + RuntimeEntityKey key, + ulong expectedPositionAuthorityVersion) => + !body.InWorld + && (body.TransientState & TransientStateFlags.Active) == 0 + && (body.State & PhysicsStateFlags.Hidden) == 0 + && _entities.IsCurrent(record) + && !_leaving.Contains(key) + && !_admissionBlocked.Contains(key) + && expectedPositionAuthorityVersion != 0UL + && record.PositionAuthorityVersion + == expectedPositionAuthorityVersion + && IsKnownParticipant(record, body, key); + private bool ReportEnvironment( RuntimeEntityRecord owner, PhysicsBody ownerBody, RuntimeEntityKey ownerKey, - bool previousContact) + bool previousContact, + ulong setPositionBatchId = 0UL) { - OwnerState state = GetOrCreateOwner(ownerKey); + OwnerState state = GetOrCreateOwner( + ownerKey, setPositionBatchId); if (state.CollidingWithEnvironment) return false; @@ -467,7 +1088,9 @@ internal sealed class RuntimeCollisionReportingState : IDisposable PhysicsBody? ownerBody, RuntimeEntityKey ownerKey, double physicsTime, - bool force) + bool force, + ulong setPositionBatchId = 0UL, + ulong expectedPositionAuthorityVersion = 0UL) { if (!_owners.TryGetValue(ownerKey, out OwnerState? state) || state.Records.Count == 0) @@ -524,7 +1147,15 @@ internal sealed class RuntimeCollisionReportingState : IDisposable || reportEpoch != _dispatchEpoch || _entities.SessionLifetimeVersion != sourceSessionVersion || _entities.CurrentLifetimeMutation(owner.ServerGuid) - != sourceLifetimeMutation) + != sourceLifetimeMutation + || setPositionBatchId != 0UL + && (owner.PositionAuthorityVersion + != expectedPositionAuthorityVersion + || !_owners.TryGetValue( + ownerKey, out OwnerState? currentOwner) + || !ReferenceEquals(currentOwner, state) + || currentOwner.SetPositionBatchId + != setPositionBatchId)) { break; } @@ -563,6 +1194,13 @@ internal sealed class RuntimeCollisionReportingState : IDisposable TrimEmptyOwner(ownerKey); } + private bool IsSetPositionBatchOwnerCurrent( + in SetPositionCollisionBatchReceipt receipt) => + receipt.Owner.PositionAuthorityVersion + == receipt.OwnerPositionAuthorityVersion + && (!_owners.TryGetValue(receipt.OwnerKey, out OwnerState? owner) + || owner.SetPositionBatchId == receipt.BatchId); + private void PublishResolvedObjectEnd( RuntimeEntityRecord owner, PhysicsBody? ownerBody, @@ -654,13 +1292,16 @@ internal sealed class RuntimeCollisionReportingState : IDisposable (uint)owner.FinalPhysicsState); } - private OwnerState GetOrCreateOwner(RuntimeEntityKey key) + private OwnerState GetOrCreateOwner( + RuntimeEntityKey key, + ulong setPositionBatchId = 0UL) { if (!_owners.TryGetValue(key, out OwnerState? owner)) { owner = new OwnerState(); _owners.Add(key, owner); } + owner.SetPositionBatchId = setPositionBatchId; return owner; } @@ -874,15 +1515,16 @@ internal sealed class RuntimeCollisionReportingState : IDisposable private void EnsureNotDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); - private sealed class OwnerState + internal sealed class OwnerState { internal Dictionary Records { get; } = new(); internal List Order { get; } = []; internal bool CollidingWithEnvironment { get; set; } + internal ulong SetPositionBatchId { get; set; } } - private readonly record struct CollisionRecord( + internal readonly record struct CollisionRecord( double TouchedTime, bool Ethereal, uint ServerGuid); diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 285566dd..67fd2f26 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -32,6 +32,8 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( int PendingCollisionReportCount, int LeavingCollisionReportOwnerCount, int CollisionReportAdmissionBlockedOwnerCount, + int PendingCollisionSetPositionDispatchCount, + int PendingShadowSetPositionDispatchCount, bool IsCollisionReportDispatching, int CollisionAdmissionCount, int CollisionGenerationCount, @@ -66,6 +68,8 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( && PendingCollisionReportCount == 0 && LeavingCollisionReportOwnerCount == 0 && CollisionReportAdmissionBlockedOwnerCount == 0 + && PendingCollisionSetPositionDispatchCount == 0 + && PendingShadowSetPositionDispatchCount == 0 && !IsCollisionReportDispatching && CollisionAdmissionCount == 0 && CollisionGenerationCount == 0 @@ -1162,6 +1166,8 @@ public sealed class RuntimePhysicsState : IDisposable collisionReports.PendingReportCount, collisionReports.LeavingOwnerCount, collisionReports.AdmissionBlockedOwnerCount, + collisionReports.PendingSetPositionDispatchCount, + Engine.ShadowObjects.PendingSetPositionDispatchCount, collisionReports.IsDispatching, _collisionAdmissions.Count, _collisionGenerations.Count, @@ -2529,6 +2535,18 @@ public sealed class RuntimePhysicsState : IDisposable TrimCollisionOwnerJournal(); } + internal bool TryPrepareSpatialRootAdmission(RuntimeEntityRecord record) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + if (record.Key is null || !Entities.IsCurrent(record)) + return false; + _spatialRoots.EnsureCapacity(_spatialRoots.Count + 1); + _spatialRemotes.EnsureCapacity(_spatialRemotes.Count + 1); + _spatialProjectiles.EnsureCapacity(_spatialProjectiles.Count + 1); + return Entities.IsCurrent(record); + } + internal ulong ExpectedCollisionGeneration(uint exactCellId) { uint landblockId = CanonicalLandblock(exactCellId); @@ -2671,6 +2689,32 @@ public sealed class RuntimePhysicsState : IDisposable return true; } + internal bool IsCollisionEvaluationFatalAuthorityCurrent( + in RuntimeCollisionEvaluationAuthority authority) + { + if (!authority.IsValid + || _collisionWorldAuthority != authority.CollisionWorldAuthority + || !ReferenceEquals(ObjectTable, authority.ObjectTable) + || ObjectTableBindingAuthority + != authority.ObjectTableBindingAuthority + || ObjectTableAuthority != authority.ObjectTableAuthority) + { + return false; + } + foreach (RuntimeCollisionGenerationAuthority generation + in authority.Generations) + { + if (generation.LandblockId == 0u + || _collisionAdmissions.ContainsKey(generation.LandblockId) + || CollisionGenerationAuthority(generation.LandblockId) + != generation.Generation) + { + return false; + } + } + return true; + } + internal bool HandleSetPositionCollisions( RuntimeEntityRecord record, ulong positionAuthorityVersion, diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index b20e2070..dbe197f2 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -4,6 +4,7 @@ using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Physics; @@ -29,6 +30,7 @@ internal enum RuntimeEntityPlacementStage AwaitingPreparation, AwaitingWithdrawalAcknowledgement, AwaitingCell, + AwaitingFinalShadowPreparation, AwaitingCommitAcknowledgement, CancelledAwaitingAcknowledgement, } @@ -109,6 +111,65 @@ internal readonly record struct RuntimeDormantSetPositionEvaluation( && CollisionAuthority.IsValid; } +internal enum RuntimeDormantSetPositionCommitStatus : byte +{ + None, + AwaitingFinalShadowPreparation, + Committed, + DeferredCell, + RejectedPlacement, + RejectedAuthority, +} + +internal sealed class PreparedDormantSetPositionCommit +{ + internal required RuntimeDormantSetPositionEvaluation Evaluation + { get; init; } + internal required RuntimeEntityKey Entity { get; init; } + internal required ulong OperationId { get; init; } + internal required ulong ExpectedProjectionSequence { get; init; } + internal required ShadowObjectRegistry.PreparedSetPositionShadowCommit? + Shadow { get; init; } + internal required RuntimeCollisionReportingState + .PreparedSetPositionCollisionBatch? Collision { get; init; } + internal required RuntimePlacementProjectionSnapshot Projection + { get; init; } + internal required SortedDictionary? + PendingProjection { get; init; } + internal required ulong DeferredCollisionGeneration { get; init; } + internal required List? DeferredBucket { get; init; } + internal required bool DeferredBucketIsNew { get; init; } +} + +internal sealed class PreparedDormantActivationFinalCommit +{ + internal required RuntimeEntityKey Entity { get; init; } + internal required ulong OperationId { get; init; } + internal required ulong ExpectedProjectionSequence { get; init; } + internal required ShadowObjectRegistry.PreparedSetPositionShadowCommit + Shadow { get; init; } + internal required RuntimePlacementProjectionSnapshot Projection + { get; init; } + internal required SortedDictionary + PendingProjection { get; init; } +} + +internal readonly record struct RuntimeDormantSetPositionCommitReceipt( + RuntimeDormantSetPositionCommitStatus Status, + RuntimeEntityKey Entity, + ulong OperationId, + RuntimePlacementProjectionSnapshot Projection, + RuntimeCollisionReportingState.SetPositionCollisionBatchReceipt Collision, + ShadowObjectRegistry.SetPositionShadowCommitReceipt Shadow, + RuntimeCollisionEvaluationAuthority CollisionAuthority, + ulong SourceVectorAuthorityVersion, + bool HitGround, + bool LeaveGround) +{ + internal bool IsCommitted => Status + is RuntimeDormantSetPositionCommitStatus.Committed; +} + public readonly record struct RuntimePlacementProjectionToken( ulong Sequence, ulong Revision, @@ -258,6 +319,7 @@ internal sealed class RuntimeSetPositionState : IDisposable internal List? LostFamilyKeys { get; set; } internal bool InheritedLostDeadline { get; set; } internal bool EnteringWorldFromCelllessResidence { get; set; } + internal bool DormantLocalActivation { get; set; } internal RuntimeSetPositionCommand? PreparedCommandAwaitingWithdrawalAck { get; @@ -289,7 +351,7 @@ internal sealed class RuntimeSetPositionState : IDisposable private readonly Dictionary _operations = []; private readonly Dictionary> _deferredByCellGeneration = []; - private readonly SortedDictionary + private SortedDictionary _pendingProjection = []; private readonly List _deferredBucketOrder = []; private readonly Dictionary> @@ -558,8 +620,12 @@ internal sealed class RuntimeSetPositionState : IDisposable if (!token.IsValid || !_operations.TryGetValue(token.Entity, out Operation? operation) || operation.Token != token - || operation.Stage - is not RuntimeEntityPlacementStage.AwaitingPreparation + || operation.Stage is not ( + RuntimeEntityPlacementStage.AwaitingPreparation + or RuntimeEntityPlacementStage.AwaitingCell) + || operation.Stage is RuntimeEntityPlacementStage.AwaitingCell + && (!operation.DormantLocalActivation + || !operation.WakeableLostCell) || !IsCurrent(operation) || !_moverPreparationAuthorities.TryGetValue( token.Entity, @@ -641,6 +707,20 @@ internal sealed class RuntimeSetPositionState : IDisposable ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(body); evaluation = default; + if (!IsExactDormantLocalActivationCurrent( + record, + body, + token, + command, + out _) + && !TryRearmDeferredDormantLocalActivation( + record, + body, + token, + command)) + { + return false; + } if (!IsExactDormantLocalActivationCurrent( record, body, @@ -716,6 +796,779 @@ internal sealed class RuntimeSetPositionState : IDisposable && _physics.IsCollisionEvaluationAuthorityCurrent( evaluation.CollisionAuthority); + internal bool IsDormantLocalActivationLeaseCurrent( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeEntityPlacementToken token, + in RuntimeSetPositionCommand command) => + IsExactDormantLocalActivationCurrent( + record, + body, + token, + command, + out _, + allowDeferredLease: true); + + internal bool IsDormantLocalActivationAwaitingCell( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeEntityPlacementToken token, + in RuntimeSetPositionCommand command) + { + return IsExactDormantLocalActivationCurrent( + record, + body, + token, + command, + out Operation? operation, + allowDeferredLease: true) + && operation is not null + && operation.Stage is RuntimeEntityPlacementStage.AwaitingCell + && operation.DormantLocalActivation + && operation.WakeableLostCell; + } + + private bool TryRearmDeferredDormantLocalActivation( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeEntityPlacementToken token, + in RuntimeSetPositionCommand command) + { + if (!IsExactDormantLocalActivationCurrent( + record, + body, + token, + command, + out Operation? operation, + allowDeferredLease: true) + || operation is null + || operation.Stage is not RuntimeEntityPlacementStage.AwaitingCell + || !operation.DormantLocalActivation + || !operation.WakeableLostCell + || !operation.CollisionGenerationReady + || operation.ProjectionSequence != 0UL + || operation.CollisionGeneration != _physics + .ExpectedCollisionGeneration(operation.ExactCellId) + || !_physics.Engine.IsSpawnCellReady(operation.ExactCellId)) + { + return false; + } + + UnindexDeferred(operation); + operation.WakeableLostCell = false; + operation.CollisionGenerationReady = false; + operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation; + return true; + } + + internal bool TryPrepareDormantLocalActivationCommit( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeDormantSetPositionEvaluation evaluation, + bool provenShapeless, + out PreparedDormantSetPositionCommit? prepared) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(body); + prepared = null; + if (!IsDormantLocalEvaluationCurrent(record, body, evaluation) + || !IsExactDormantLocalActivationCurrent( + record, + body, + evaluation.Placement, + evaluation.Command, + out Operation? operation, + allowCanonicalCommand: true) + || operation is null) + { + return false; + } + + PhysicsSetPositionResult result = evaluation.Result; + ShadowObjectRegistry.PreparedSetPositionShadowCommit? shadow = null; + RuntimeCollisionReportingState.PreparedSetPositionCollisionBatch? + collision = null; + RuntimePlacementProjectionSnapshot projection = default; + SortedDictionary? + pendingProjection = null; + ulong deferredCollisionGeneration = 0UL; + List? deferredBucket = null; + bool deferredBucketIsNew = false; + + if (!result.IsDeferred) + { + if (!_physics.CollisionReports.TryPrepareSetPositionBatch( + record, + body, + evaluation.Command.GameTime, + result.IsCommitted && operation.PreviousContact, + result.IsCommitted && operation.PreviousOnWalkable, + result.IsCommitted && result.OnWalkable, + result.CollidedWithEnvironment, + result.CollidedObjectIds, + out collision) + || collision is null) + { + return false; + } + } + + if (result.IsDeferred) + { + deferredCollisionGeneration = _physics + .ExpectedCollisionGeneration(result.CellId); + _preparedMovers.EnsureCapacity(_preparedMovers.Count + 1); + if (result.CellId != 0u && deferredCollisionGeneration != 0UL) + { + var bucketKey = new CellGenerationKey( + result.CellId, + deferredCollisionGeneration); + if (_deferredByCellGeneration.TryGetValue( + bucketKey, + out deferredBucket)) + { + deferredBucket.EnsureCapacity(deferredBucket.Count + 1); + } + else + { + deferredBucket = [operation.Key]; + deferredBucketIsNew = true; + _deferredByCellGeneration.EnsureCapacity( + _deferredByCellGeneration.Count + 1); + _deferredBucketOrder.EnsureCapacity( + _deferredBucketOrder.Count + 1); + } + } + if (!_physics.Engine.ShadowObjects.TryPrepareSetPosition( + operation.Key.LocalEntityId, + result.Position, + result.Orientation, + result.CellId, + evaluation.Command.ShadowWorldOffsetX, + evaluation.Command.ShadowWorldOffsetY, + PhysicsShadowCommitAction.Preserve, + ImmutableArray.Empty, + provenShapeless, + suspendOwner: true, + out shadow) + || shadow is null) + { + return false; + } + } + + ulong expectedProjectionSequence = _nextProjectionSequence; + if (result.IsCommitted) + { + _ = checked(record.ObjectClockEpoch + 1UL); + _ = checked(record.PlacementCommitVersion + 1UL); + if (record.FullCellId != result.CellId) + _ = checked(record.SpatialAuthorityVersion + 1UL); + if (!_physics.TryPrepareSpatialRootAdmission(record)) + return false; + + ulong sequence = checked(expectedProjectionSequence + 1UL); + ulong spatial = record.SpatialAuthorityVersion + + (record.FullCellId == result.CellId ? 0UL : 1UL); + ulong placement = checked(record.PlacementCommitVersion + 1UL); + var token = new RuntimePlacementProjectionToken( + sequence, + Revision: 1UL, + operation.Key, + operation.PositionAuthorityVersion, + spatial, + placement, + operation.SessionLifetimeVersion, + result.CellId, + operation.CollisionGeneration, + evaluation.Command.Portal); + projection = new RuntimePlacementProjectionSnapshot( + token, + RuntimePlacementProjectionKind.Place, + result.Position, + result.Orientation, + result.CellLocalPosition, + result.InContact, + result.OnWalkable); + pendingProjection = new SortedDictionary< + ulong, + RuntimePlacementProjectionSnapshot>(_pendingProjection) + { + [sequence] = projection, + }; + } + + prepared = new PreparedDormantSetPositionCommit + { + Evaluation = evaluation, + Entity = operation.Key, + OperationId = operation.Token.OperationId, + ExpectedProjectionSequence = expectedProjectionSequence, + Shadow = shadow, + Collision = collision, + Projection = projection, + PendingProjection = pendingProjection, + DeferredCollisionGeneration = deferredCollisionGeneration, + DeferredBucket = deferredBucket, + DeferredBucketIsNew = deferredBucketIsNew, + }; + return IsPreparedDormantCommitCurrent(record, body, prepared); + } + + internal bool TryApplyDormantLocalActivationCommit( + RuntimeEntityRecord record, + PhysicsBody body, + PlayerMovementController controller, + EntityPhysicsHost physicsHost, + PreparedDormantSetPositionCommit prepared, + out RuntimeDormantSetPositionCommitReceipt receipt) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(body); + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(physicsHost); + ArgumentNullException.ThrowIfNull(prepared); + receipt = default; + if (!IsPreparedDormantCommitCurrent(record, body, prepared) + || !_operations.TryGetValue( + prepared.Entity, + out Operation? operation)) + { + return false; + } + + PhysicsSetPositionResult result = prepared.Evaluation.Result; + operation.Body = body; + operation.DormantLocalActivation = true; + if (result.IsDeferred) + { + if (prepared.Shadow is null + || !_physics.Engine.ShadowObjects.TryApplySetPosition( + prepared.Shadow, + out ShadowObjectRegistry.SetPositionShadowCommitReceipt + deferredShadowReceipt)) + { + return false; + } + body.Orientation = result.Orientation; + body.StageDormantCellFrame( + result.CellId, + result.Position, + result.CellLocalPosition); + body.InWorld = false; + body.TransientState &= ~TransientStateFlags.Active; + operation.Result = result; + operation.ExactCellId = result.CellId; + operation.WakeableLostCell = true; + operation.CollisionGeneration = prepared + .DeferredCollisionGeneration; + operation.CollisionGenerationReady = false; + operation.Stage = RuntimeEntityPlacementStage.AwaitingCell; + _preparedMovers[operation.Key] = prepared.Evaluation.Command.Physics; + if (prepared.DeferredBucket is { } deferredBucket) + { + var bucketKey = new CellGenerationKey( + result.CellId, + prepared.DeferredCollisionGeneration); + if (prepared.DeferredBucketIsNew) + { + _deferredByCellGeneration.Add(bucketKey, deferredBucket); + _deferredBucketOrder.Add(bucketKey); + } + else if (!deferredBucket.Contains(operation.Key)) + { + deferredBucket.Add(operation.Key); + } + } + receipt = new RuntimeDormantSetPositionCommitReceipt( + RuntimeDormantSetPositionCommitStatus.DeferredCell, + operation.Key, + operation.Token.OperationId, + default, + default, + deferredShadowReceipt, + prepared.Evaluation.CollisionAuthority, + operation.Record.VectorAuthorityVersion, + HitGround: false, + LeaveGround: false); + return true; + } + + if (prepared.Collision is null + || !_physics.CollisionReports.TryInstallSetPositionBatch( + prepared.Collision, + out RuntimeCollisionReportingState + .SetPositionCollisionBatchReceipt collisionReceipt)) + { + return false; + } + + if (!result.IsCommitted) + { + operation.Result = result; + receipt = new RuntimeDormantSetPositionCommitReceipt( + RuntimeDormantSetPositionCommitStatus.RejectedPlacement, + operation.Key, + operation.Token.OperationId, + default, + collisionReceipt, + default, + prepared.Evaluation.CollisionAuthority, + operation.Record.VectorAuthorityVersion, + HitGround: false, + LeaveGround: false); + return true; + } + + bool previousOnWalkable = operation.PreviousOnWalkable; + bool hitGround = !previousOnWalkable + && result.InContact + && result.OnWalkable; + bool leaveGround = previousOnWalkable + && !(result.InContact && result.OnWalkable); + + UnindexDeferred(operation); + body.Orientation = result.Orientation; + body.StageDormantCellFrame( + result.CellId, + result.Position, + result.CellLocalPosition); + body.LastUpdateTime = prepared.Evaluation.Command.GameTime; + body.ContactPlaneValid = result.InContact; + body.ContactPlane = result.ContactPlane; + body.ContactPlaneCellId = result.ContactPlaneCellId; + body.ContactPlaneIsWater = result.ContactPlaneIsWater; + if (result.InContact) + body.GroundNormal = result.ContactPlane.Normal; + _ = PhysicsObjUpdate.CommitSetPositionContactPrefix( + body, + result.InContact, + result.OnWalkable, + previousOnWalkable); + operation.Result = result; + operation.ExactCellId = result.CellId; + operation.WakeableLostCell = false; + operation.CollisionGenerationReady = false; + operation.EnteringWorldFromCelllessResidence = false; + operation.Stage = RuntimeEntityPlacementStage + .AwaitingFinalShadowPreparation; + receipt = new RuntimeDormantSetPositionCommitReceipt( + RuntimeDormantSetPositionCommitStatus + .AwaitingFinalShadowPreparation, + operation.Key, + operation.Token.OperationId, + prepared.Projection, + collisionReceipt, + default, + prepared.Evaluation.CollisionAuthority, + operation.Record.VectorAuthorityVersion, + hitGround, + leaveGround); + return true; + } + + internal SetPositionCollisionBatchDispatchResult + DispatchDormantLocalActivationCollision( + in RuntimeDormantSetPositionCommitReceipt receipt) + { + if (receipt.Status + is RuntimeDormantSetPositionCommitStatus.DeferredCell + or RuntimeDormantSetPositionCommitStatus.RejectedAuthority) + { + return new( + SetPositionCollisionBatchDispatchStatus.RejectedReceipt, + Reported: false); + } + SetPositionCollisionBatchDispatchResult dispatch = _physics + .CollisionReports.DispatchSetPositionBatchResult(receipt.Collision); + bool reported = dispatch.Reported; + if (receipt.Status + is RuntimeDormantSetPositionCommitStatus.RejectedPlacement + && _operations.TryGetValue(receipt.Entity, out Operation? operation) + && operation.Token.OperationId == receipt.OperationId + && IsCurrent(operation) + && !operation.Result.IsCommitted + && !operation.Result.IsDeferred) + { + operation.Result = operation.Result with + { + Error = reported + ? PhysicsSetPositionError.Collided + : PhysicsSetPositionError.NoValidPosition, + CollisionHandlerResult = reported, + }; + } + return dispatch; + } + + internal bool IsDormantLocalActivationPrephaseCurrent( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeDormantSetPositionCommitReceipt receipt) + { + return receipt.Status is RuntimeDormantSetPositionCommitStatus + .AwaitingFinalShadowPreparation + && _operations.TryGetValue(receipt.Entity, out Operation? operation) + && operation.Token.OperationId == receipt.OperationId + && operation.Stage is RuntimeEntityPlacementStage + .AwaitingFinalShadowPreparation + && operation.DormantLocalActivation + && IsCurrent(operation) + && ReferenceEquals(operation.Record, record) + && ReferenceEquals(record.PhysicsBody, body) + && !body.InWorld + && (body.TransientState & TransientStateFlags.Active) == 0 + && record.PhysicsHost is null + && record.RemoteMotion is null + && record.Projectile is null + && !_physics.IsSpatialRoot(record) + && _moverPreparationAuthorities.TryGetValue( + receipt.Entity, + out MoverPreparationAuthority authority) + && authority.OperationId == receipt.OperationId + && authority.Prepared + && record.PositionAuthorityVersion + == authority.PositionAuthorityVersion + && record.ObjDescAuthorityVersion + == authority.ObjDescAuthorityVersion + && record.CreateIntegrationVersion + == authority.CreateIntegrationVersion + && CanonicalSetupTableId(record) == authority.SetupTableId; + } + + internal bool IsDormantLocalActivationResponseCurrent( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeDormantSetPositionCommitReceipt receipt) + { + if (receipt.Status is RuntimeDormantSetPositionCommitStatus + .AwaitingFinalShadowPreparation) + return IsDormantLocalActivationPrephaseCurrent(record, body, receipt); + return receipt.Status is RuntimeDormantSetPositionCommitStatus + .RejectedPlacement + && _operations.TryGetValue(receipt.Entity, out Operation? operation) + && operation.Token.OperationId == receipt.OperationId + && operation.DormantLocalActivation + && IsCurrent(operation) + && ReferenceEquals(operation.Record, record) + && ReferenceEquals(record.PhysicsBody, body) + && !operation.Result.IsCommitted + && !operation.Result.IsDeferred + && !body.InWorld + && (body.TransientState & TransientStateFlags.Active) == 0 + && record.PhysicsHost is null + && record.RemoteMotion is null + && record.Projectile is null + && !_physics.IsSpatialRoot(record); + } + + internal bool CommitDormantLocalActivationPostGround( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeDormantSetPositionCommitReceipt receipt) + { + if (!IsDormantLocalActivationPrephaseCurrent(record, body, receipt)) + return false; + PhysicsObjUpdate.CommitSetPositionPostGround(body); + PhysicsSetPositionResult result = _operations[receipt.Entity].Result; + body.SlidingNormal = result.SlidingNormal; + if (result.SlidingNormalValid) + body.TransientState |= TransientStateFlags.Sliding; + else + body.TransientState &= ~TransientStateFlags.Sliding; + return IsDormantLocalActivationPrephaseCurrent(record, body, receipt); + } + + internal bool CommitDormantLocalActivationPostCollision( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeDormantSetPositionCommitReceipt receipt) + { + if (!_operations.TryGetValue(receipt.Entity, out Operation? operation) + || operation.Token.OperationId != receipt.OperationId + || !IsCurrent(operation) + || !ReferenceEquals(operation.Record, record) + || !ReferenceEquals(record.PhysicsBody, body)) + { + return false; + } + if (receipt.Status is RuntimeDormantSetPositionCommitStatus + .AwaitingFinalShadowPreparation + && !IsDormantLocalActivationPrephaseCurrent(record, body, receipt)) + { + return false; + } + PhysicsSetPositionResult result = operation.Result; + body.FramesStationaryFall = result.FramesStationaryFall; + if (IsVelocityCurrent(operation)) + { + PhysicsObjUpdate.HandleAllCollisions( + body, + result.CollisionNormalValid, + result.CollisionNormal, + operation.PreviousContact, + operation.PreviousOnWalkable, + body.OnWalkable); + } + CommitStationaryBits(body, result.FramesStationaryFall); + return IsCurrent(operation); + } + + internal bool TryPrepareDormantLocalActivationFinalCommit( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeDormantSetPositionCommitReceipt receipt, + bool provenShapeless, + out PreparedDormantActivationFinalCommit? prepared) + { + prepared = null; + if (!IsDormantLocalActivationPrephaseCurrent(record, body, receipt) + || !_operations.TryGetValue(receipt.Entity, out Operation? operation)) + { + return false; + } + PhysicsSetPositionResult result = operation.Result; + if (!_physics.Engine.ShadowObjects.TryPrepareSetPosition( + operation.Key.LocalEntityId, + result.Position, + result.Orientation, + result.CellId, + operation.Command.ShadowWorldOffsetX, + operation.Command.ShadowWorldOffsetY, + result.ShadowAction, + result.CrossCellIds, + provenShapeless, + suspendOwner: false, + out ShadowObjectRegistry.PreparedSetPositionShadowCommit? shadow) + || shadow is null + || _nextProjectionSequence + 1UL + != receipt.Projection.Token.Sequence) + { + return false; + } + var pending = new SortedDictionary< + ulong, + RuntimePlacementProjectionSnapshot>(_pendingProjection) + { + [receipt.Projection.Token.Sequence] = receipt.Projection, + }; + prepared = new PreparedDormantActivationFinalCommit + { + Entity = receipt.Entity, + OperationId = receipt.OperationId, + ExpectedProjectionSequence = _nextProjectionSequence, + Shadow = shadow, + Projection = receipt.Projection, + PendingProjection = pending, + }; + bool current = IsDormantLocalActivationPrephaseCurrent( + record, body, receipt); + bool shadowCurrent = _physics.Engine.ShadowObjects + .IsPreparedSetPositionCurrent(shadow); + return current && shadowCurrent; + } + + internal bool TryApplyDormantLocalActivationFinalCommit( + RuntimeEntityRecord record, + PhysicsBody body, + PlayerMovementController controller, + EntityPhysicsHost physicsHost, + in RuntimeDormantSetPositionCommitReceipt prephase, + PreparedDormantActivationFinalCommit prepared, + out RuntimeDormantSetPositionCommitReceipt committed) + { + committed = default; + if (prepared.Entity != prephase.Entity + || prepared.OperationId != prephase.OperationId + || prepared.ExpectedProjectionSequence != _nextProjectionSequence + || prepared.Projection != prephase.Projection + || !IsDormantLocalActivationPrephaseCurrent(record, body, prephase) + || !_physics.Engine.ShadowObjects.TryApplySetPosition( + prepared.Shadow, + out ShadowObjectRegistry.SetPositionShadowCommitReceipt shadow)) + { + return false; + } + Operation operation = _operations[prephase.Entity]; + PhysicsSetPositionResult result = operation.Result; + if (record.FullCellId != result.CellId) + { + _entities.SetFullCell(record, result.CellId, + (result.CellId & 0xFFFF0000u) | 0xFFFFu); + } + operation.SpatialAuthorityVersion = record.SpatialAuthorityVersion; + _entities.AdvancePlacementCommit(record); + operation.PlacementCommitVersion = record.PlacementCommitVersion; + body.InWorld = true; + bool isStatic = (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0; + if (!isStatic) + body.TransientState |= TransientStateFlags.Active; + _entities.SetPhysicsHost(record, physicsHost); + controller.CommitRuntimeActivationFrame(); + _physics.Engine.UpdatePlayerCurrCell(result.CellId); + _physics.AcknowledgeSpatialProjection(record, spatial: true); + _entities.ResetObjectClockForEnterWorld(record, isStatic); + operation.Stage = RuntimeEntityPlacementStage.AwaitingCommitAcknowledgement; + operation.ProjectionSequence = prepared.Projection.Token.Sequence; + _pendingProjection = prepared.PendingProjection; + _nextProjectionSequence = prepared.Projection.Token.Sequence; + CancelLostFamilyDeadlines(operation); + controller.ActivateRuntimePublication(); + committed = prephase with + { + Status = RuntimeDormantSetPositionCommitStatus.Committed, + Shadow = shadow, + }; + return true; + } + + internal void DispatchDormantLocalActivationShadow( + in RuntimeDormantSetPositionCommitReceipt receipt) + { + if (receipt.Status is not ( + RuntimeDormantSetPositionCommitStatus.Committed + or RuntimeDormantSetPositionCommitStatus.DeferredCell)) + return; + _physics.Engine.ShadowObjects.DispatchSetPositionCommit(receipt.Shadow); + } + + internal void DispatchDormantLocalActivationPlacement( + in RuntimeDormantSetPositionCommitReceipt receipt) + { + if (!receipt.IsCommitted) + return; + PublishPlacement(receipt.Projection); + } + + internal void DiscardDormantLocalActivationDispatches( + in RuntimeDormantSetPositionCommitReceipt receipt, + bool collisionAlreadyDispatched) + { + if (!collisionAlreadyDispatched) + _physics.CollisionReports.DiscardSetPositionBatch(receipt.Collision); + _physics.Engine.ShadowObjects.DiscardSetPositionCommit(receipt.Shadow); + } + + internal void RetireDormantLocalActivation( + in RuntimeDormantSetPositionCommitReceipt receipt, + bool collisionAlreadyDispatched) + { + DiscardDormantLocalActivationDispatches( + receipt, + collisionAlreadyDispatched); + _physics.CollisionReports.RetireSetPositionBatchOwner( + receipt.Collision); + if (_operations.TryGetValue(receipt.Entity, out Operation? operation) + && operation.Token.OperationId == receipt.OperationId) + { + _ = CancelCore(operation); + } + } + + internal void RetireDormantLocalActivationToken( + RuntimeEntityRecord record, + in RuntimeEntityPlacementToken token) + { + if (!token.IsValid + || record.Key != token.Entity + || !_operations.TryGetValue(token.Entity, out Operation? operation) + || operation.Token != token + || !ReferenceEquals(operation.Record, record)) + { + return; + } + _ = CancelCore(operation); + } + + internal bool IsDormantLocalActivationCommitCurrent( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeDormantSetPositionCommitReceipt receipt) + { + if (!receipt.IsCommitted + || !receipt.Projection.Token.IsValid + || record.Key != receipt.Projection.Token.Entity + || !ReferenceEquals(record.PhysicsBody, body) + || !_pendingProjection.TryGetValue( + receipt.Projection.Token.Sequence, + out RuntimePlacementProjectionSnapshot pending) + || pending != receipt.Projection + || !_operations.TryGetValue( + receipt.Projection.Token.Entity, + out Operation? operation) + || operation.Stage is not RuntimeEntityPlacementStage + .AwaitingCommitAcknowledgement + || operation.ProjectionSequence + != receipt.Projection.Token.Sequence + || !IsCurrent(operation) + || !body.InWorld + || !_physics.IsSpatialRoot(record)) + { + return false; + } + return true; + } + + internal bool TryCaptureDormantLocalActivationResult( + in RuntimeEntityPlacementToken token, + out PhysicsSetPositionResult result) + { + if (!_disposed + && token.IsValid + && _operations.TryGetValue(token.Entity, out Operation? operation) + && operation.Token == token) + { + result = operation.Result; + return true; + } + result = default; + return false; + } + + private bool IsPreparedDormantCommitCurrent( + RuntimeEntityRecord record, + PhysicsBody body, + PreparedDormantSetPositionCommit prepared) + { + if (_nextProjectionSequence != prepared.ExpectedProjectionSequence + || !IsDormantLocalEvaluationCurrent( + record, + body, + prepared.Evaluation) + || !_operations.TryGetValue( + prepared.Entity, + out Operation? operation) + || operation.Token.OperationId != prepared.OperationId) + { + return false; + } + if (prepared.Collision is not null + && !_physics.CollisionReports.IsPreparedSetPositionBatchCurrent( + prepared.Collision)) + return false; + return prepared.Shadow is null + || _physics.Engine.ShadowObjects.IsPreparedSetPositionCurrent( + prepared.Shadow); + } + + private static void CommitStationaryBits( + PhysicsBody body, + int framesStationaryFall) + { + body.TransientState &= ~(TransientStateFlags.StationaryFall + | TransientStateFlags.StationaryStop + | TransientStateFlags.StationaryStuck); + body.TransientState |= framesStationaryFall switch + { + 1 => TransientStateFlags.StationaryFall, + 2 => TransientStateFlags.StationaryStop, + 3 => TransientStateFlags.StationaryStuck, + _ => TransientStateFlags.None, + }; + } + internal RuntimeSetPositionOutcome SubmitPreparedPlacement( in RuntimeEntityPlacementToken token, in RuntimeSetPositionCommand command) => @@ -1559,6 +2412,12 @@ internal sealed class RuntimeSetPositionState : IDisposable private void RetryDeferred(Operation operation) { + // The local-player activation lease owns its dormant body/controller + // and must re-enter through the same sealed evaluation/commit path. + // A collision-generation wake only marks readiness; it must never + // bypass that path through the ordinary remote CommitCanonical tail. + if (operation.DormantLocalActivation) + return; if (!IsCurrent(operation) || !operation.WakeableLostCell || operation.RequiresPreparation @@ -2006,7 +2865,8 @@ internal sealed class RuntimeSetPositionState : IDisposable in RuntimeEntityPlacementToken token, in RuntimeSetPositionCommand command, out Operation? operation, - bool allowCanonicalCommand = false) + bool allowCanonicalCommand = false, + bool allowDeferredLease = false) { operation = null; if (!token.IsValid @@ -2021,6 +2881,14 @@ internal sealed class RuntimeSetPositionState : IDisposable || operation.Token != token || operation.Stage is not RuntimeEntityPlacementStage.AwaitingPreparation + && !(allowDeferredLease + && operation.DormantLocalActivation + && (operation.Stage + is RuntimeEntityPlacementStage.AwaitingCell + && operation.WakeableLostCell + || operation.Stage is RuntimeEntityPlacementStage + .AwaitingFinalShadowPreparation) + && operation.ProjectionSequence == 0UL) || !ReferenceEquals(operation.Record, record) || !IsCurrent(operation) || !ReferenceEquals(record.PhysicsBody, body) diff --git a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs index 4d914cd1..2b3d8a51 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs @@ -10,6 +10,284 @@ public sealed class ShadowSetPositionCommitTests private const uint Cell1 = Landblock | 0x0001u; private const uint Cell9 = Landblock | 0x0009u; + [Fact] + public void PreparedAuthoredMoveKeepsOldRowsUntilAtomicApply() + { + var registry = RegisteredSingle(); + var moved = new Vector3(36f, 12f, 50f); + Assert.True(registry.TryPrepareSetPosition( + 1u, + moved, + Quaternion.Identity, + Cell9, + 0f, + 0f, + PhysicsShadowCommitAction.Replace, + [Cell9], + provenShapeless: false, + suspendOwner: false, + out var prepared)); + + Assert.Equal(new Vector3(12f, 12f, 50f), + Assert.Single(registry.GetObjectsInCell(Cell1)).Position); + Assert.Empty(registry.GetObjectsInCell(Cell9)); + Assert.True(registry.TryApplySetPosition(prepared!, out var receipt)); + Assert.True(receipt.Mutated); + Assert.Empty(registry.GetObjectsInCell(Cell1)); + Assert.Equal(moved, + Assert.Single(registry.GetObjectsInCell(Cell9)).Position); + } + + [Fact] + public void PreparedCrossPrefixDispatchesMembershipThenMutationOnce() + { + const uint otherCell = 0xA9B50001u; + var registry = RegisteredSingle(); + var callbacks = new List(); + registry.OwnerPrefixMembershipChanged += (owner, prefix) => + callbacks.Add($"prefix:{owner:X8}:{prefix:X8}"); + registry.OwnerMutated += (owner, version) => + callbacks.Add($"owner:{owner:X8}:{version}"); + Assert.True(registry.TryPrepareSetPosition( + 1u, + new Vector3(204f, 12f, 50f), + Quaternion.Identity, + otherCell, + 192f, + 0f, + PhysicsShadowCommitAction.Replace, + [otherCell], + provenShapeless: false, + suspendOwner: false, + out var prepared)); + Assert.True(registry.TryApplySetPosition(prepared!, out var receipt)); + Assert.Empty(callbacks); + + registry.DispatchSetPositionCommit(receipt); + registry.DispatchSetPositionCommit(receipt); + + Assert.Equal( + [ + "prefix:00000001:A9B40000", + "prefix:00000001:A9B50000", + $"owner:00000001:{receipt.OwnerVersion}", + ], callbacks); + } + + [Fact] + public void TwoOwnerReceiptsDispatchExactlyOnceInReverseOrder() + { + const uint otherCell = 0xA9B50001u; + var registry = RegisteredSingle(); + registry.Register( + 2u, 0x01000002u, new Vector3(13f, 12f, 50f), + Quaternion.Identity, 1f, 0f, 0f, Landblock, + seedCellId: Cell1, isStatic: false); + var owners = new List(); + registry.OwnerMutated += (owner, _) => owners.Add(owner); + + Assert.True(registry.TryPrepareSetPosition( + 1u, new Vector3(204f, 12f, 50f), Quaternion.Identity, + otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace, + [otherCell], provenShapeless: false, suspendOwner: false, + out var first)); + Assert.True(registry.TryApplySetPosition(first!, out var firstReceipt)); + Assert.True(registry.TryPrepareSetPosition( + 2u, new Vector3(205f, 12f, 50f), Quaternion.Identity, + otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace, + [otherCell], provenShapeless: false, suspendOwner: false, + out var second)); + Assert.True(registry.TryApplySetPosition(second!, out var secondReceipt)); + + registry.DispatchSetPositionCommit(secondReceipt); + registry.DispatchSetPositionCommit(firstReceipt); + registry.DispatchSetPositionCommit(secondReceipt); + registry.DispatchSetPositionCommit(firstReceipt); + + Assert.Equal([2u, 1u], owners); + Assert.Equal(0, registry.PendingSetPositionDispatchCount); + } + + [Fact] + public void PrefixObserverMutationCannotRegressFinalOwnerVersion() + { + const uint otherCell = 0xA9B50001u; + var registry = RegisteredSingle(); + var versions = new List(); + var prefixes = new List(); + bool mutated = false; + registry.OwnerPrefixMembershipChanged += (owner, prefix) => + { + prefixes.Add(prefix); + if (mutated) + return; + mutated = true; + registry.UpdatePhysicsState( + owner, + (uint)PhysicsStateFlags.Hidden); + }; + registry.OwnerMutated += (_, version) => versions.Add(version); + Assert.True(registry.TryPrepareSetPosition( + 1u, new Vector3(204f, 12f, 50f), Quaternion.Identity, + otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace, + [otherCell], provenShapeless: false, suspendOwner: false, + out var prepared)); + Assert.True(registry.TryApplySetPosition(prepared!, out var receipt)); + + registry.DispatchSetPositionCommit(receipt); + + Assert.Single(versions); + Assert.Equal(registry.GetOwnerVersion(1u), versions[0]); + Assert.Equal([Landblock], prefixes); + } + + [Fact] + public void PreparedShapelessRequiresExplicitDispositionAndCreatesNoRows() + { + var registry = new ShadowObjectRegistry(); + Assert.False(registry.TryPrepareSetPosition( + 77u, + Vector3.One, + Quaternion.Identity, + Cell1, + 0f, + 0f, + PhysicsShadowCommitAction.Replace, + [Cell1], + provenShapeless: false, + suspendOwner: false, + out _)); + Assert.True(registry.TryPrepareSetPosition( + 77u, + Vector3.One, + Quaternion.Identity, + Cell1, + 0f, + 0f, + PhysicsShadowCommitAction.Replace, + [Cell1], + provenShapeless: true, + suspendOwner: false, + out var prepared)); + Assert.True(registry.TryApplySetPosition(prepared!, out var receipt)); + Assert.False(receipt.Mutated); + Assert.Empty(registry.GetObjectsInCell(Cell1)); + Assert.False(registry.TryApplySetPosition(prepared!, out _)); + } + + [Fact] + public void ClearInvalidatesPreparedUnappliedShapelessCommit() + { + var registry = new ShadowObjectRegistry(); + Assert.True(registry.TryPrepareSetPosition( + 77u, + Vector3.One, + Quaternion.Identity, + Cell1, + 0f, + 0f, + PhysicsShadowCommitAction.Replace, + [Cell1], + provenShapeless: true, + suspendOwner: false, + out var prepared)); + + registry.Clear(); + + Assert.False(registry.TryApplySetPosition(prepared!, out _)); + Assert.Equal(0, registry.PendingSetPositionDispatchCount); + Assert.Empty(registry.GetObjectsInCell(Cell1)); + } + + [Fact] + public void PreparedCommitRejectsStaleGlobalRevisionWithoutMutation() + { + var registry = RegisteredSingle(); + Assert.True(registry.TryPrepareSetPosition( + 1u, new Vector3(36f, 12f, 50f), Quaternion.Identity, + Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9], + provenShapeless: false, suspendOwner: false, out var prepared)); + registry.Register( + 2u, 0x01000002u, new Vector3(13f, 12f, 50f), + Quaternion.Identity, 1f, 0f, 0f, Landblock, + seedCellId: Cell1, isStatic: false); + Vector3 before = registry.GetObjectsInCell(Cell1) + .Single(entry => entry.EntityId == 1u).Position; + + Assert.False(registry.TryApplySetPosition(prepared!, out _)); + + Assert.Equal(before, registry.GetObjectsInCell(Cell1) + .Single(entry => entry.EntityId == 1u).Position); + Assert.Empty(registry.GetObjectsInCell(Cell9)); + } + + [Fact] + public void PreparedCommitRejectsStaleOwnerVersionWithoutMutation() + { + var registry = RegisteredSingle(); + Assert.True(registry.TryPrepareSetPosition( + 1u, new Vector3(36f, 12f, 50f), Quaternion.Identity, + Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9], + provenShapeless: false, suspendOwner: false, out var prepared)); + registry.UpdatePhysicsState(1u, (uint)PhysicsStateFlags.Hidden); + ShadowEntry before = Assert.Single(registry.GetObjectsInCell(Cell1)); + + Assert.False(registry.TryApplySetPosition(prepared!, out _)); + + Assert.Equal(before, Assert.Single(registry.GetObjectsInCell(Cell1))); + Assert.Empty(registry.GetObjectsInCell(Cell9)); + } + + [Fact] + public void PreparedApplyTailAllocatesZeroManagedBytes() + { + // Warm the generic owner-value and dictionary replacement paths. + var warm = RegisteredSingle(); + Assert.True(warm.TryPrepareSetPosition( + 1u, new Vector3(36f, 12f, 50f), Quaternion.Identity, + Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9], + provenShapeless: false, suspendOwner: false, out var warmPrepared)); + Assert.True(warm.TryApplySetPosition(warmPrepared!, out _)); + + var registry = RegisteredSingle(); + Assert.True(registry.TryPrepareSetPosition( + 1u, new Vector3(36f, 12f, 50f), Quaternion.Identity, + Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9], + provenShapeless: false, suspendOwner: false, out var prepared)); + long before = GC.GetAllocatedBytesForCurrentThread(); + + bool applied = registry.TryApplySetPosition(prepared!, out _); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(applied); + Assert.Equal(0, allocated); + } + + [Fact] + public void DeferredDispatchRejectsReceiptAfterInterveningOwnerMutation() + { + const uint otherCell = 0xA9B50001u; + var registry = RegisteredSingle(); + var versions = new List(); + var prefixes = new List(); + registry.OwnerMutated += (_, version) => versions.Add(version); + registry.OwnerPrefixMembershipChanged += (_, prefix) => + prefixes.Add(prefix); + Assert.True(registry.TryPrepareSetPosition( + 1u, new Vector3(204f, 12f, 50f), Quaternion.Identity, + otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace, + [otherCell], provenShapeless: false, suspendOwner: false, + out var prepared)); + Assert.True(registry.TryApplySetPosition(prepared!, out var receipt)); + + registry.UpdatePhysicsState(1u, (uint)PhysicsStateFlags.Hidden); + ulong current = registry.GetOwnerVersion(1u); + registry.DispatchSetPositionCommit(receipt); + + Assert.Equal([current], versions); + Assert.Empty(prefixes); + } + [Fact] public void NoneRefreshesFrameWithoutChangingExactMembership() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs index d9ba7ffe..d4c2c2bb 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs @@ -1156,6 +1156,13 @@ public class PlayerMovementControllerTests candidate.CaptureMovementResult(mouseLookEvent: false)); Assert.Throws(() => _ = candidate.PhysicsBody); + candidate.BeginDormantSetPositionGroundPhase(); + Assert.Throws( + candidate.CommitRuntimeActivationFrame); + Assert.Throws( + candidate.ActivateRuntimePublication); + candidate.EndDormantSetPositionGroundPhase(); + candidate.ActivateRuntimePublication(); Assert.Same(body, candidate.PhysicsBody); _ = candidate.CaptureMovementResult(mouseLookEvent: false); diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs index c9ed432f..1768124f 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -45,6 +45,30 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.True(token.IsValid); } + [Fact] + public void ActivationIdExhaustionMutatesNoCandidateOrCanonicalOwner() + { + using var fixture = new Fixture(); + typeof(RuntimeLocalPlayerPhysicsPublicationState) + .GetField( + "_nextActivationId", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic)! + .SetValue(fixture.Owner, ulong.MaxValue); + + Assert.Throws(() => fixture.Prepare()); + + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); + Assert.Equal(0, fixture.Owner.CaptureOwnership().PendingActivationCount); + Assert.True(fixture.Lifetime.Physics.SetPosition + .IsExactPreparedPlacementCurrent( + fixture.Record, + fixture.Placement, + fixture.Command)); + } + [Fact] public void CommitOwnsExactDormantBodyAndControllerWithoutWorldEdges() { @@ -115,6 +139,39 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests fixture.Owner.Commit(token)); } + [Fact] + public void InitialPhysicsVectorsAndCoefficientsSeedCanonicalActivation() + { + Vector3 omega = new(1f, 2f, 3f); + using var fixture = new Fixture( + residentWorld: true, + initialVelocity: new Vector3(100f, 0f, 0f), + initialOmega: omega, + initialFriction: 0.25f, + initialElasticity: 0.08f); + + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out var token)); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.Equal(new Vector3(50f, 0f, 0f), body.Velocity); + Assert.Equal(omega, body.Omega); + Assert.Equal(0.25f, body.Friction); + Assert.Equal(0.08f, body.Elasticity); + Assert.False(body.InWorld); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + + Assert.Equal(new Vector3(50f, 0f, 0f), body.Velocity); + Assert.Equal(omega, body.Omega); + Assert.Equal(0.25f, body.Friction); + Assert.Equal(0.08f, body.Elasticity); + Assert.True(body.InWorld); + } + [Fact] public void DeferredActivationEvaluationLeavesExactOwnedGraphDormantAndRetryable() { @@ -198,6 +255,752 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests AssertNotLive(fixture.Movement.Controller!); } + [Fact] + public void CommitActivationPublishesExactLiveRuntimeGraphOnce() + { + using var fixture = new Fixture(residentWorld: true); + bool callbackSawExactGraph = false; + var placements = new PlacementObserver(delta => + { + if (delta.Placement.Kind is not RuntimePlacementProjectionKind.Place) + return; + callbackSawExactGraph = fixture.Record.PhysicsBody is { InWorld: true } + && fixture.Record.PhysicsHost is not null + && fixture.Movement.Controller is { IsRuntimePublished: true } + && fixture.Movement.Controller.Movement.MoveTo is not null + && fixture.Record.ObjectClock.IsActive + && fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record); + }); + using IDisposable subscription = fixture.Lifetime.Events + .SubscribePlacement(placements); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + ulong clockEpoch = fixture.Record.ObjectClockEpoch; + + RuntimeDormantSetPositionCommitStatus status = fixture.Owner + .CommitActivation(evaluation, out var projection); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, status); + Assert.True(projection.IsValid); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.True(body.TransientState.HasFlag(TransientStateFlags.Active)); + Assert.NotNull(fixture.Record.PhysicsHost); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.True(fixture.Record.ObjectClock.IsActive); + Assert.Equal(clockEpoch + 1UL, fixture.Record.ObjectClockEpoch); + Assert.True(fixture.Movement.Controller!.IsRuntimePublished); + RuntimePlacementDelta place = Assert.Single(placements.Deltas); + Assert.Equal(RuntimePlacementProjectionKind.Place, + place.Placement.Kind); + Assert.Equal(projection.Sequence, + place.Placement.Token.Sequence); + Assert.True(callbackSawExactGraph); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority, + fixture.Owner.CommitActivation(evaluation, out _)); + Assert.Single(placements.Deltas); + } + + [Fact] + public void DeferredCommitWaitsThenRearmsSameLeaseAfterExactGenerationWake() + { + using var fixture = new Fixture(); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var deferred)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell, + fixture.Owner.CommitActivation(deferred, out var noProjection)); + Assert.False(noProjection.IsValid); + Assert.False(fixture.Record.PhysicsBody!.InWorld); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var waiting)); + Assert.False(waiting.IsValid); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, + generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, + generation, + ready: true); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var ready)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(ready, out var projection)); + Assert.True(projection.IsValid); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + } + + [Fact] + public void RejectedCommitAppliesResponseOnceAndRetainsRetryableLease() + { + using var fixture = new Fixture(residentWorld: true); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + PhysicsBody body = fixture.Record.PhysicsBody!; + Vector3 position = body.Position; + uint fullCell = fixture.Record.FullCellId; + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + static (_, phase, _, observed) => phase + is TransitionCellCollisionPhase.Objects + ? TransitionState.Collided + : observed; + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedPlacement, + fixture.Owner.EvaluateActivation(token, out var rejected)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedPlacement, + fixture.Owner.CommitActivation(rejected, out var projection)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .TryCaptureDormantLocalActivationResult( + token.Placement, + out PhysicsSetPositionResult rejectedResult)); + Assert.Equal(PhysicsSetPositionError.NoValidPosition, + rejectedResult.Error); + Assert.False(rejectedResult.CollisionHandlerResult); + Assert.False(projection.IsValid); + Assert.Equal(position, body.Position); + Assert.Equal(fullCell, fixture.Record.FullCellId); + Assert.False(body.InWorld); + Assert.Null(fixture.Record.PhysicsHost); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority, + fixture.Owner.CommitActivation(rejected, out _)); + + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = null; + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var retry)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(retry, out projection)); + Assert.True(projection.IsValid); + } + + [Fact] + public void UndefinedShadowDispositionIsRejectedBeforeCandidateConstruction() + { + using var fixture = new Fixture(residentWorld: true); + var invalid = new RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, + 1.835f, + (RuntimeLocalPlayerShadowDisposition)0x7F); + + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, + fixture.Owner.Prepare( + fixture.Record, + fixture.Placement, + fixture.Command, + PlayerMovementConstructionOptions.Fallback, + invalid, + out var token)); + Assert.False(token.IsValid); + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); + } + + [Fact] + public void RejectedCommitMapsEligibleEnvironmentCallbackToCollided() + { + using var fixture = new Fixture(residentWorld: true); + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + static (transition, _, _, _) => + { + transition.CollisionInfo.CollidedWithEnvironment = true; + return TransitionState.Collided; + }; + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedPlacement, + fixture.Owner.EvaluateActivation(token, out var rejected)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedPlacement, + fixture.Owner.CommitActivation(rejected, out _)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .TryCaptureDormantLocalActivationResult( + token.Placement, + out PhysicsSetPositionResult result)); + Assert.Equal(PhysicsSetPositionError.Collided, result.Error); + Assert.True(result.CollisionHandlerResult); + Assert.Equal(1, fixture.Lifetime.Physics.CollisionReports + .CaptureOwnership().OwnerCount); + + Assert.True(fixture.Owner.DiscardActivation(token)); + + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + Assert.Equal(0, fixture.Lifetime.Physics.SetPosition + .CaptureOwnership().ActiveOperationCount); + RuntimeCollisionReportingOwnershipSnapshot ownership = fixture.Lifetime + .Physics.CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.TrackedObjectCount); + Assert.Equal(0, ownership.ReversePeerCount); + Assert.Equal(0, ownership.PendingSetPositionDispatchCount); + } + + [Fact] + public void RegisteredAuthoredShadowActivatesAndMovesExactRows() + { + using var fixture = new Fixture( + residentWorld: true, + shadowDisposition: + RuntimeLocalPlayerShadowDisposition.RegisteredAuthoredPayload); + uint localId = fixture.Record.Key!.Value.LocalEntityId; + fixture.Lifetime.Physics.Engine.ShadowObjects.Register( + localId, + SetupId, + fixture.Command.Physics.Position + Vector3.UnitX, + Quaternion.Identity, + 0.48f, + 0f, + 0f, + Cell & 0xFFFF0000u, + ShadowCollisionType.Sphere, + state: (uint)fixture.Record.FinalPhysicsState, + seedCellId: Cell, + isStatic: false); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + + ShadowEntry row = Assert.Single(fixture.Lifetime.Physics.Engine + .ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == localId); + Assert.Equal(fixture.Record.PhysicsBody!.Position, row.Position); + } + + [Fact] + public void DeferredAuthoredActivationSuspendsRowsAndExactWakeRestoresThem() + { + using var fixture = new Fixture( + shadowDisposition: + RuntimeLocalPlayerShadowDisposition.RegisteredAuthoredPayload); + uint localId = fixture.Record.Key!.Value.LocalEntityId; + fixture.Lifetime.Physics.Engine.ShadowObjects.Register( + localId, SetupId, fixture.Command.Physics.Position, + Quaternion.Identity, 0.48f, 0f, 0f, + Cell & 0xFFFF0000u, ShadowCollisionType.Sphere, + state: (uint)fixture.Record.FinalPhysicsState, + seedCellId: Cell, isStatic: false); + Assert.Contains(fixture.Lifetime.Physics.Engine.ShadowObjects + .AllEntriesForDebug(), row => row.EntityId == localId); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out var token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var deferred)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell, + fixture.Owner.CommitActivation(deferred, out _)); + + Assert.False(fixture.Record.PhysicsBody!.InWorld); + Assert.DoesNotContain(fixture.Lifetime.Physics.Engine.ShadowObjects + .AllEntriesForDebug(), row => row.EntityId == localId); + Assert.Equal(1, fixture.Lifetime.Physics.Engine.ShadowObjects + .SuspendedRegistrationCount); + Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects + .PendingSetPositionDispatchCount); + + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), Array.Empty(), 0f, 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, generation, ready: true); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var ready)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(ready, out _)); + Assert.Contains(fixture.Lifetime.Physics.Engine.ShadowObjects + .AllEntriesForDebug(), row => row.EntityId == localId); + } + + [Fact] + public void DeferredDiscardTransfersSuspendedShadowToEntityForLaterReuse() + { + using var fixture = new Fixture( + shadowDisposition: + RuntimeLocalPlayerShadowDisposition.RegisteredAuthoredPayload); + uint localId = fixture.Record.Key!.Value.LocalEntityId; + ShadowObjectRegistry shadows = fixture.Lifetime.Physics.Engine + .ShadowObjects; + shadows.Register( + localId, SetupId, fixture.Command.Physics.Position, + Quaternion.Identity, 0.48f, 0f, 0f, + Cell & 0xFFFF0000u, ShadowCollisionType.Sphere, + state: (uint)fixture.Record.FinalPhysicsState, + seedCellId: Cell, isStatic: false); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out var token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var deferred)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell, + fixture.Owner.CommitActivation(deferred, out _)); + + Assert.True(fixture.Owner.DiscardActivation(token)); + + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + Assert.Equal(0, fixture.Lifetime.Physics.SetPosition + .CaptureOwnership().ActiveOperationCount); + Assert.Equal(1, shadows.SuspendedRegistrationCount); + Assert.Equal(0, shadows.PendingSetPositionDispatchCount); + Assert.DoesNotContain(shadows.AllEntriesForDebug(), + row => row.EntityId == localId); + + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), Array.Empty(), 0f, 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, generation, ready: true); + fixture.RepreparePlacement(); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out var retry)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(retry, out var ready)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(ready, out _)); + + Assert.Equal(0, shadows.SuspendedRegistrationCount); + Assert.Contains(shadows.AllEntriesForDebug(), + row => row.EntityId == localId); + } + + [Fact] + public void CollisionCallbackDeleteRetiresPrephaseWithoutShadowOrPlace() + { + using var fixture = new Fixture(residentWorld: true); + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + static (transition, phase, _, observed) => + { + if (phase is TransitionCellCollisionPhase.Environment) + transition.CollisionInfo.CollidedWithEnvironment = true; + return observed; + }; + var placements = new PlacementObserver(); + using IDisposable placementSubscription = fixture.Lifetime.Events + .SubscribePlacement(placements); + bool deleted = false; + var collisions = new PublicationCollisionObserver(ignoredReport => + { + if (deleted) + return; + deleted = true; + Assert.True(fixture.Lifetime.TryAcceptDelete( + new DeleteObject.Parsed( + fixture.Record.ServerGuid, + fixture.Record.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + fixture.Lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(fixture.Lifetime.RetireCanonicalOnly(fixture.Record)); + }); + using IDisposable collisionSubscription = fixture.Lifetime.Physics + .CollisionReports.Subscribe(collisions); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out var token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority, + fixture.Owner.CommitActivation(evaluation, out var projection)); + + Assert.True(deleted); + Assert.False(projection.IsValid); + Assert.DoesNotContain(placements.Deltas, + delta => delta.Placement.Kind is RuntimePlacementProjectionKind.Place); + Assert.Equal(0, fixture.Owner.CaptureOwnership().PendingActivationCount); + Assert.Null(fixture.Movement.Controller); + Assert.Equal(0, fixture.Lifetime.Physics.CollisionReports + .CaptureOwnership().PendingSetPositionDispatchCount); + Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects + .PendingSetPositionDispatchCount); + } + + [Fact] + public void CollisionCallbackVectorRefreshesDormantBodyWithRetailClamp() + { + using var fixture = new Fixture(residentWorld: true); + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + static (transition, phase, _, observed) => + { + if (phase is TransitionCellCollisionPhase.Environment) + transition.CollisionInfo.CollidedWithEnvironment = true; + return observed; + }; + bool updated = false; + Vector3 omega = new(1f, 2f, 3f); + var collisions = new PublicationCollisionObserver(ignoredReport => + { + if (updated) + return; + updated = true; + Assert.True(fixture.Lifetime.TryApplyVector( + new VectorUpdate.Parsed( + fixture.Record.ServerGuid, + new Vector3(100f, 0f, 0f), + omega, + InstanceSequence: fixture.Record.Incarnation, + VectorSequence: 2), + acknowledgeProjection: null, + out _)); + _ = ignoredReport; + }); + using IDisposable subscription = fixture.Lifetime.Physics + .CollisionReports.Subscribe(collisions); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out var token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + + Assert.True(updated); + Assert.Equal(new Vector3(50f, 0f, 0f), + fixture.Record.PhysicsBody!.Velocity); + Assert.Equal(omega, fixture.Record.PhysicsBody.Omega); + } + + [Fact] + public void CollisionCallbackAuthorityChangeRetiresInstalledTracking() + { + using var fixture = new Fixture(residentWorld: true); + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + static (transition, phase, _, observed) => + { + if (phase is TransitionCellCollisionPhase.Environment) + transition.CollisionInfo.CollidedWithEnvironment = true; + return observed; + }; + bool invalidated = false; + var collisions = new PublicationCollisionObserver(_ => + { + if (invalidated) + return; + invalidated = true; + fixture.Lifetime.Entities.AdvanceObjDescAuthority(fixture.Record); + }); + using IDisposable subscription = fixture.Lifetime.Physics + .CollisionReports.Subscribe(collisions); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out var token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority, + fixture.Owner.CommitActivation(evaluation, out _)); + + RuntimeCollisionReportingOwnershipSnapshot ownership = fixture.Lifetime + .Physics.CollisionReports.CaptureOwnership(); + Assert.True(invalidated); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.TrackedObjectCount); + Assert.Equal(0, ownership.ReversePeerCount); + Assert.Equal(0, ownership.PendingSetPositionDispatchCount); + } + + [Fact] + public void CollisionCallbackNewerPositionSuppressesReciprocalAndPreservesNewLease() + { + using var fixture = new Fixture(residentWorld: true); + RuntimeEntityRecord target = fixture.Lifetime.RegisterEntity( + Spawn(0x70003002u, incarnation: 1)).Canonical!; + fixture.Lifetime.Entities.SetFullCell( + target, Cell, Cell & 0xFFFF0000u); + fixture.Lifetime.Entities.SetFinalPhysicsState( + target, PhysicsStateFlags.ReportCollisions); + var targetBody = new PhysicsBody + { + Position = new Vector3(1f, 2f, 3f), + Orientation = Quaternion.Identity, + State = PhysicsStateFlags.ReportCollisions, + InWorld = true, + }; + targetBody.SnapToCell(Cell, targetBody.Position, targetBody.Position); + fixture.Lifetime.Entities.SetPhysicsBody(target, targetBody); + fixture.Lifetime.Physics.AcknowledgeSpatialProjection( + target, spatial: true); + fixture.Lifetime.Physics.Engine.ShadowObjects.Register( + target.Key!.Value.LocalEntityId, + SetupId, + targetBody.Position, + Quaternion.Identity, + 0.48f, + 0f, + 0f, + Cell & 0xFFFF0000u, + ShadowCollisionType.Sphere, + (uint)targetBody.State, + Cell, + isStatic: false); + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add( + target.Key.Value.LocalEntityId); + } + return observed; + }; + bool updated = false; + RuntimeEntityPlacementToken newer = default; + var reports = new List(); + var collisions = new PublicationCollisionObserver(report => + { + reports.Add(report); + if (updated || report.Kind is not RuntimeCollisionReportKind.ObjectCollision) + return; + updated = true; + Assert.True(fixture.Lifetime.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + fixture.Record.ServerGuid, + new CreateObject.ServerPosition( + Cell, 4f, 5f, 3f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: fixture.Record.Incarnation, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: true, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out _, + out _, + out _)); + newer = fixture.Lifetime.Physics.SetPosition.BeginAuthoredPlacement( + fixture.Record, + fixture.Record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative); + Assert.True(newer.IsValid); + }); + using IDisposable subscription = fixture.Lifetime.Physics + .CollisionReports.Subscribe(collisions); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out var token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedPlacement, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority, + fixture.Owner.CommitActivation(evaluation, out _)); + + Assert.True(updated); + Assert.DoesNotContain(reports, report => + report.Kind is RuntimeCollisionReportKind.ObjectCollision + && report.Recipient == target.Key); + Assert.Equal(1, fixture.Lifetime.Physics.SetPosition + .CaptureOwnership().ActiveOperationCount); + RuntimeCollisionReportingOwnershipSnapshot ownership = fixture.Lifetime + .Physics.CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.TrackedObjectCount); + Assert.Equal(0, ownership.ReversePeerCount); + } + + [Fact] + public void HitGroundReapplyStaysDormantUntilFinalActivationTail() + { + using var fixture = new Fixture(residentWorld: true); + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + static (transition, phase, _, observed) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, -3f), Cell, isWater: false); + } + return observed; + }; + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out var token)); + PlayerMovementController controller = fixture.Movement.Controller!; + bool observedDormant = false; + controller.BeginDormantSetPositionGroundPhase(); + controller.Motion.InterpretedState.ForwardCommand = + MotionCommand.RunForward; + controller.Motion.InterpretedState.ForwardSpeed = 1f; + controller.Motion.DefaultSink = new CallbackMotionSink(() => + { + observedDormant = true; + Assert.False(fixture.Record.PhysicsBody!.InWorld); + Assert.False(fixture.Record.PhysicsBody.TransientState + .HasFlag(TransientStateFlags.Active)); + Assert.Null(fixture.Record.PhysicsHost); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.False(fixture.Record.ObjectClock.IsActive); + }); + controller.EndDormantSetPositionGroundPhase(); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out var projection)); + + Assert.True(observedDormant); + Assert.True(projection.IsValid); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.NotNull(fixture.Record.PhysicsHost); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.True(fixture.Record.ObjectClock.IsActive); + Assert.True(controller.IsRuntimePublished); + } + + [Fact] + public void HitGroundInvalidationRetiresInstalledBatchBeforeDispatch() + { + using var fixture = new Fixture(residentWorld: true); + RuntimeEntityRecord target = fixture.Lifetime.RegisterEntity( + Spawn(0x70003003u, incarnation: 1)).Canonical!; + fixture.Lifetime.Entities.SetFullCell( + target, Cell, Cell & 0xFFFF0000u); + fixture.Lifetime.Entities.SetFinalPhysicsState( + target, PhysicsStateFlags.ReportCollisions); + var targetBody = new PhysicsBody + { + Position = new Vector3(100f, 100f, 3f), + Orientation = Quaternion.Identity, + State = PhysicsStateFlags.ReportCollisions, + InWorld = true, + TransientState = TransientStateFlags.Active, + }; + targetBody.SnapToCell(Cell, targetBody.Position, targetBody.Position); + fixture.Lifetime.Entities.SetPhysicsBody(target, targetBody); + fixture.Lifetime.Physics.AcknowledgeSpatialProjection(target, true); + fixture.Lifetime.Physics.Engine.ShadowObjects.Register( + target.Key!.Value.LocalEntityId, SetupId, targetBody.Position, + Quaternion.Identity, 0.48f, 0f, 0f, Cell & 0xFFFF0000u, + ShadowCollisionType.Sphere, (uint)targetBody.State, Cell, + isStatic: false); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit(fixture.Prepare(), out var token)); + PhysicsBody ownerBody = fixture.Record.PhysicsBody!; + ownerBody.InWorld = true; + ownerBody.TransientState |= TransientStateFlags.Active; + Assert.True(fixture.Lifetime.Physics.CollisionReports.HandleReports( + fixture.Record, ownerBody, 9d, false, false, false, + [target.Key.Value.LocalEntityId])); + ownerBody.InWorld = false; + ownerBody.TransientState &= ~TransientStateFlags.Active; + Assert.Equal(1, fixture.Lifetime.Physics.CollisionReports + .CaptureOwnership().TrackedObjectCount); + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + static (transition, phase, _, observed) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, -3f), Cell, isWater: false); + } + return observed; + }; + PlayerMovementController controller = fixture.Movement.Controller!; + bool invalidated = false; + controller.BeginDormantSetPositionGroundPhase(); + controller.Motion.InterpretedState.ForwardCommand = + MotionCommand.RunForward; + controller.Motion.DefaultSink = new CallbackMotionSink(() => + { + if (invalidated) + return; + invalidated = true; + fixture.Lifetime.Entities.AdvanceObjDescAuthority(fixture.Record); + }); + controller.EndDormantSetPositionGroundPhase(); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority, + fixture.Owner.CommitActivation(evaluation, out _)); + + Assert.True(invalidated); + RuntimeCollisionReportingOwnershipSnapshot ownership = fixture.Lifetime + .Physics.CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.TrackedObjectCount); + Assert.Equal(0, ownership.ReversePeerCount); + Assert.Equal(0, ownership.PendingSetPositionDispatchCount); + Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects + .PendingSetPositionDispatchCount); + Assert.Equal(0, fixture.Lifetime.Physics.SetPosition + .CaptureOwnership().ActiveOperationCount); + } + + [Fact] + public void HiddenNoDrawStateDoesNotCancelDormantActivation() + { + using var fixture = new Fixture(residentWorld: true); + PhysicsStateFlags hidden = fixture.Record.FinalPhysicsState + | PhysicsStateFlags.Hidden + | PhysicsStateFlags.NoDraw; + fixture.Lifetime.Entities.SetFinalPhysicsState(fixture.Record, hidden); + fixture.RepreparePlacement(); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out var projection)); + + Assert.True(projection.IsValid); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.True(fixture.Record.PhysicsBody.State.HasFlag( + PhysicsStateFlags.Hidden)); + Assert.True(fixture.Record.PhysicsBody.State.HasFlag( + PhysicsStateFlags.NoDraw)); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + } + [Fact] public void RejectedPlacementReceiptIsPureAndRetryableUnderSameLease() { @@ -813,6 +1616,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests fixture.Placement, fixture.Command, PlayerMovementConstructionOptions.Fallback, + fixture.ActivationPreparation, out _)); Assert.Equal(1, fixture.Owner.CaptureOwnership() .PendingActivationCount); @@ -820,12 +1624,14 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.True(fixture.Owner.DiscardActivation(token)); Assert.Equal(0, fixture.Owner.CaptureOwnership() .PendingActivationCount); + fixture.RepreparePlacement(); Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Prepared, fixture.Owner.Prepare( fixture.Record, fixture.Placement, fixture.Command, PlayerMovementConstructionOptions.Fallback, + fixture.ActivationPreparation, out _)); } @@ -1077,6 +1883,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests fixture.Placement, fixture.Command, PlayerMovementConstructionOptions.Fallback, + fixture.ActivationPreparation, out RuntimeLocalPlayerPhysicsPublicationToken token)); Assert.False(token.IsValid); Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); @@ -1095,6 +1902,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests fixture.Placement, fixture.Command, PlayerMovementConstructionOptions.Fallback, + fixture.ActivationPreparation, out _)); } @@ -1111,6 +1919,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests fixture.Placement, fixture.Command, PlayerMovementConstructionOptions.Fallback, + fixture.ActivationPreparation, out _)); } @@ -1360,7 +2169,14 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests internal Fixture( bool preparePlacement = true, - bool residentWorld = false) + bool residentWorld = false, + RuntimeLocalPlayerShadowDisposition shadowDisposition = + RuntimeLocalPlayerShadowDisposition.ProvenShapeless, + float terrainHeight = 0f, + Vector3? initialVelocity = null, + Vector3? initialOmega = null, + float? initialFriction = null, + float? initialElasticity = null) { if (residentWorld) { @@ -1370,7 +2186,9 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests }; engine.AddLandblock( Cell & 0xFFFF0000u, - new TerrainSurface(new byte[81], new float[256]), + new TerrainSurface( + new byte[81], + Enumerable.Repeat(terrainHeight, 256).ToArray()), Array.Empty(), Array.Empty(), worldOffsetX: 0f, @@ -1390,8 +2208,18 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Identity); Movement.AttachPhysicsPublication(Owner); Record = Lifetime.RegisterEntity( - Spawn(0x70003001u, incarnation: 1)).Canonical!; + Spawn( + 0x70003001u, + incarnation: 1, + initialVelocity, + initialOmega, + initialFriction, + initialElasticity)).Canonical!; Identity.ServerGuid = Record.ServerGuid; + ActivationPreparation = new( + Radius: 0.48f, + Height: 1.835f, + shadowDisposition); if (preparePlacement) RepreparePlacement(); } @@ -1403,6 +2231,8 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests internal RuntimeEntityRecord Record { get; } internal RuntimeEntityPlacementToken Placement { get; private set; } internal RuntimeSetPositionCommand Command { get; private set; } + internal RuntimeLocalPlayerPhysicsActivationPreparation + ActivationPreparation { get; } internal void RepreparePlacement() { @@ -1440,6 +2270,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Placement, Command, PlayerMovementConstructionOptions.Fallback, + ActivationPreparation, out RuntimeLocalPlayerPhysicsPublicationToken token)); Assert.Equal(Record.Key, token.Entity); Assert.Equal(Placement, token.Placement); @@ -1464,7 +2295,50 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests } } - private static WorldSession.EntitySpawn Spawn(uint guid, ushort incarnation) + private sealed class PlacementObserver( + Action? onPlacement = null) + : IRuntimePlacementObserver + { + internal List Deltas { get; } = []; + + public void OnPlacement(in RuntimePlacementDelta delta) + { + Deltas.Add(delta); + onPlacement?.Invoke(delta); + } + } + + private sealed class PublicationCollisionObserver( + Action onReport) + : IRuntimeCollisionReportObserver + { + public void OnCollisionReport(in RuntimeCollisionReport report) => + onReport(report); + } + + private sealed class CallbackMotionSink(Action onApply) + : IInterpretedMotionSink + { + public bool ApplyMotion(uint motion, float speed) + { + onApply(); + return true; + } + + public bool StopMotion(uint motion) + { + onApply(); + return true; + } + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation, + Vector3? initialVelocity = null, + Vector3? initialOmega = null, + float? initialFriction = null, + float? initialElasticity = null) { var position = new CreateObject.ServerPosition( Cell, @@ -1498,12 +2372,12 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Parent: null, Children: null, Scale: 1f, - Friction: null, - Elasticity: null, + Friction: initialFriction, + Elasticity: initialElasticity, Translucency: null, - Velocity: null, + Velocity: initialVelocity, Acceleration: null, - AngularVelocity: null, + AngularVelocity: initialOmega, DefaultScriptType: null, DefaultScriptIntensity: null, Timestamps: timestamps); @@ -1522,6 +2396,8 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests MotionTableId: 0x09000001u, PhysicsState: physics.RawState, ObjectDescriptionFlags: 0x8u, + Friction: initialFriction, + Elasticity: initialElasticity, InstanceSequence: incarnation, MovementSequence: 1, ServerControlSequence: 1, diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs index 90de9944..efabd2e4 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs @@ -13,6 +13,604 @@ public sealed class RuntimeCollisionReportingStateTests private const uint Landblock = 0xA9B40000u; private const uint Cell = Landblock | 0x0001u; + [Fact] + public void PreparedBatchDefersTrackingUntilOrderedDispatchAndDispatchesOnce() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70001F01u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70001F02u, + 1, + PhysicsStateFlags.ReportCollisions); + target.PhysicsBody!.TransientState |= TransientStateFlags.Contact; + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + physicsTime: 10d, + previousContact: false, + previousOnWalkable: false, + finalOnWalkable: false, + collidedWithEnvironment: false, + [target.Key!.Value.LocalEntityId], + out var prepared)); + Assert.NotNull(prepared); + Assert.Empty(observer.Reports); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + prepared!, + out var receipt)); + Assert.Empty(observer.Reports); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .PendingCollisionSetPositionDispatchCount); + + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt); + Assert.Equal(2, observer.Reports.Count); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + Assert.False(observer.Reports[0].RecipientWasInContact); + Assert.True(observer.Reports[0].OtherWasInContact); + Assert.True(observer.Reports[1].RecipientWasInContact); + Assert.False(observer.Reports[1].OtherWasInContact); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .PendingCollisionSetPositionDispatchCount); + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt); + Assert.Equal(2, observer.Reports.Count); + } + + [Fact] + public void CombinedPhysicsLedgerIncludesPendingShadowSetPositionReceipt() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, 0x70001F03u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + ShadowObjectRegistry shadows = lifetime.Physics.Engine.ShadowObjects; + Assert.True(shadows.TryPrepareSetPosition( + owner.Key!.Value.LocalEntityId, + new Vector3(13f, 12f, 7f), + Quaternion.Identity, + Cell, + 0f, + 0f, + PhysicsShadowCommitAction.Replace, + [Cell], + provenShapeless: false, + suspendOwner: false, + out var prepared)); + Assert.True(shadows.TryApplySetPosition(prepared!, out var receipt)); + + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .PendingShadowSetPositionDispatchCount); + + Assert.True(shadows.DiscardSetPositionCommit(receipt)); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .PendingShadowSetPositionDispatchCount); + } + + [Fact] + public void TwoOwnerPreparedBatchesDispatchExactlyOnceInReverseOrder() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord first = Entity( + lifetime, 0x70001E01u, 1, PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord second = Entity( + lifetime, 0x70001E02u, 1, PhysicsStateFlags.ReportCollisions); + const uint staticId = 0x00F01E01u; + RegisterShadow(lifetime, staticId, PhysicsStateFlags.Static, + isStatic: true); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + first, first.PhysicsBody!, 10d, false, false, false, false, + [staticId], out var firstPrepared)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + firstPrepared!, out var firstReceipt)); + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + second, second.PhysicsBody!, 10d, false, false, false, false, + [staticId], out var secondPrepared)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + secondPrepared!, out var secondReceipt)); + + Assert.True(lifetime.Physics.CollisionReports.DispatchSetPositionBatch( + secondReceipt)); + Assert.True(lifetime.Physics.CollisionReports.DispatchSetPositionBatch( + firstReceipt)); + Assert.False(lifetime.Physics.CollisionReports.DispatchSetPositionBatch( + secondReceipt)); + Assert.False(lifetime.Physics.CollisionReports.DispatchSetPositionBatch( + firstReceipt)); + + Assert.Equal(2, observer.Reports.Count); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .PendingSetPositionDispatchCount); + } + + [Fact] + public void OldBatchRetirementCannotRemoveBatchInstalledByItsCallback() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, 0x70001E03u, 1, PhysicsStateFlags.ReportCollisions); + const uint staticId = 0x00F01E03u; + RegisterShadow(lifetime, staticId, PhysicsStateFlags.Static, + isStatic: true); + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, owner.PhysicsBody!, 10d, false, false, false, false, + [staticId], out var oldPrepared)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + oldPrepared!, out var oldReceipt)); + RuntimeCollisionReportingState.SetPositionCollisionBatchReceipt + newerReceipt = default; + bool installedNewer = false; + var observer = new CollisionObserver(ignoredReport => + { + if (installedNewer) + return; + installedNewer = true; + Assert.True(lifetime.Physics.CollisionReports + .TryPrepareSetPositionBatch( + owner, owner.PhysicsBody!, 11d, false, false, false, true, + ImmutableArray.Empty, out var newerPrepared)); + Assert.True(lifetime.Physics.CollisionReports + .TryInstallSetPositionBatch(newerPrepared!, out newerReceipt)); + _ = lifetime.Physics.CollisionReports.DispatchSetPositionBatch( + newerReceipt); + _ = ignoredReport; + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(lifetime.Physics.CollisionReports.DispatchSetPositionBatch( + oldReceipt)); + Assert.True(installedNewer); + Assert.True(newerReceipt.IsValid); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .OwnerCount); + + lifetime.Physics.CollisionReports.RetireSetPositionBatchOwner( + oldReceipt); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .OwnerCount); + + lifetime.Physics.CollisionReports.RetireSetPositionBatchOwner( + newerReceipt); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .OwnerCount); + } + + [Fact] + public void PreparedBatchTracksHiddenIgnoredPeerButSuppressesCallbacks() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70001F11u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70001F12u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + 10d, + previousContact: false, + previousOnWalkable: false, + finalOnWalkable: false, + collidedWithEnvironment: false, + [target.Key!.Value.LocalEntityId], + out var prepared)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + prepared!, + out var receipt)); + + PhysicsStateFlags hidden = PhysicsStateFlags.Hidden + | PhysicsStateFlags.IgnoreCollisions; + lifetime.Entities.SetFinalPhysicsState(target, hidden); + target.PhysicsBody!.State = hidden; + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt); + + Assert.Empty(observer.Reports); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void PreparedBatchConvertsLiveReportAsEnvironmentAfterGroundEdge() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70001F21u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70001F22u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + 10d, + previousContact: false, + previousOnWalkable: false, + finalOnWalkable: false, + collidedWithEnvironment: false, + [target.Key!.Value.LocalEntityId], + out var prepared)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + prepared!, + out var receipt)); + + lifetime.Entities.SetFinalPhysicsState( + target, + PhysicsStateFlags.ReportAsEnvironment); + target.PhysicsBody!.State = PhysicsStateFlags.ReportAsEnvironment; + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt); + + Assert.Single(observer.Reports); + Assert.Equal(RuntimeCollisionReportKind.EnvironmentCollision, + observer.Reports[0].Kind); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void PreparedEnvironmentBatchClearsCapturedMissileWithoutReport() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70001F31u, + 1, + PhysicsStateFlags.Missile | PhysicsStateFlags.AlignPath); + RegisterDynamicShadow(lifetime, owner); + const uint staticId = 0x00F01F31u; + RegisterShadow( + lifetime, + staticId, + PhysicsStateFlags.Static, + isStatic: true); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + 10d, + previousContact: false, + previousOnWalkable: false, + finalOnWalkable: false, + collidedWithEnvironment: false, + [staticId], + out var prepared)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + prepared!, + out var receipt)); + + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt); + + Assert.Empty(observer.Reports); + Assert.False(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Missile)); + Assert.False(owner.PhysicsBody!.State.HasFlag(PhysicsStateFlags.Missile)); + } + + [Fact] + public void PreparedExpiredEndDegradesToMissingTargetAfterInstall() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70001F41u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70001F42u, + 1, + PhysicsStateFlags.ReportCollisions); + target.PhysicsBody!.TransientState |= TransientStateFlags.Contact; + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + 10d, + false, + false, + false, + false, + [target.Key!.Value.LocalEntityId], + out var start)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + start!, + out var startReceipt)); + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(startReceipt); + observer.Reports.Clear(); + + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + 11.0001d, + false, + false, + false, + false, + ImmutableArray.Empty, + out var end)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + end!, + out var endReceipt)); + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(target.ServerGuid, target.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(target)); + + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(endReceipt); + + RuntimeCollisionReport report = Assert.Single(observer.Reports); + Assert.Equal(RuntimeCollisionReportKind.ObjectCollisionEnd, report.Kind); + Assert.Equal(owner.Key, report.Recipient); + Assert.Equal(target.ServerGuid, report.OtherServerGuid); + Assert.False(report.OtherWasInContact); + } + + [Fact] + public void PreparedBatchPreservesPreLoopEnvironmentLatchUntilFinalSuffix() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70001F51u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70001F52u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(lifetime.Physics.CollisionReports.HandleReports( + owner, + owner.PhysicsBody!, + 9d, + previousContact: false, + previousOnWalkable: false, + collidedWithEnvironment: true, + ImmutableArray.Empty)); + observer.Reports.Clear(); + + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + 10d, + previousContact: false, + previousOnWalkable: false, + finalOnWalkable: false, + collidedWithEnvironment: false, + [target.Key!.Value.LocalEntityId], + out var prepared)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + prepared!, + out var receipt)); + lifetime.Entities.SetFinalPhysicsState( + target, + PhysicsStateFlags.ReportAsEnvironment); + target.PhysicsBody!.State = PhysicsStateFlags.ReportAsEnvironment; + + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt); + + Assert.Empty(observer.Reports); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + 11d, + previousContact: false, + previousOnWalkable: false, + finalOnWalkable: false, + collidedWithEnvironment: true, + ImmutableArray.Empty, + out var next)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + next!, + out var nextReceipt)); + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(nextReceipt); + + RuntimeCollisionReport report = Assert.Single(observer.Reports); + Assert.Equal(RuntimeCollisionReportKind.EnvironmentCollision, + report.Kind); + } + + [Fact] + public void PreparedEnvironmentBatchStopsMissileAddedByCallback() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70001F61u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, owner); + const uint staticId = 0x00F01F61u; + RegisterShadow( + lifetime, + staticId, + PhysicsStateFlags.Static, + isStatic: true); + var observer = new CollisionObserver(_ => + { + PhysicsStateFlags callbackState = owner.PhysicsBody!.State + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath; + lifetime.Entities.SetFinalPhysicsState(owner, callbackState); + owner.PhysicsBody.State = callbackState; + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + 10d, + previousContact: false, + previousOnWalkable: false, + finalOnWalkable: false, + collidedWithEnvironment: false, + [staticId], + out var prepared)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + prepared!, + out var receipt)); + + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt); + + Assert.Single(observer.Reports); + Assert.False(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Missile)); + Assert.False(owner.PhysicsBody!.State.HasFlag(PhysicsStateFlags.Missile)); + } + + [Fact] + public void PreparedExistingCollisionRefreshesLiveEtherealBeforeExpiry() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70001F71u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70001F72u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + DispatchPrepared( + lifetime, + owner, + physicsTime: 10d, + [target.Key!.Value.LocalEntityId]); + observer.Reports.Clear(); + PhysicsStateFlags ethereal = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Ethereal; + lifetime.Entities.SetFinalPhysicsState(target, ethereal); + target.PhysicsBody!.State = ethereal; + + DispatchPrepared( + lifetime, + owner, + physicsTime: 10.5d, + [target.Key!.Value.LocalEntityId]); + Assert.Empty(observer.Reports); + DispatchPrepared( + lifetime, + owner, + physicsTime: 10.5001d, + ImmutableArray.Empty); + + Assert.Equal(2, observer.Reports.Count); + Assert.All(observer.Reports, report => Assert.Equal( + RuntimeCollisionReportKind.ObjectCollisionEnd, + report.Kind)); + } + + [Fact] + public void PreparedExistingCollisionBecomingStaticKeepsOldAgeForSamePassEnd() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70001F81u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70001F82u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + DispatchPrepared( + lifetime, + owner, + physicsTime: 10d, + [target.Key!.Value.LocalEntityId]); + observer.Reports.Clear(); + + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + 11.0001d, + previousContact: false, + previousOnWalkable: false, + finalOnWalkable: false, + collidedWithEnvironment: false, + [target.Key!.Value.LocalEntityId], + out var prepared)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + prepared!, + out var receipt)); + PhysicsStateFlags becameStatic = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Static; + lifetime.Entities.SetFinalPhysicsState(target, becameStatic); + target.PhysicsBody!.State = becameStatic; + + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt); + + Assert.Equal(3, observer.Reports.Count); + Assert.Equal(RuntimeCollisionReportKind.EnvironmentCollision, + observer.Reports[0].Kind); + Assert.Equal(RuntimeCollisionReportKind.ObjectCollisionEnd, + observer.Reports[1].Kind); + Assert.Equal(RuntimeCollisionReportKind.ObjectCollisionEnd, + observer.Reports[2].Kind); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + [Fact] public void ExactObjectReportsAreOrderedDeduplicatedAndExpireAtRetailThreshold() { @@ -1656,6 +2254,28 @@ public sealed class RuntimeCollisionReportingStateTests owner.PhysicsBody.OnWalkable, collision); + private static void DispatchPrepared( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord owner, + double physicsTime, + ImmutableArray collidedObjectIds) + { + Assert.True(lifetime.Physics.CollisionReports + .TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + physicsTime, + previousContact: owner.PhysicsBody!.InContact, + previousOnWalkable: owner.PhysicsBody.OnWalkable, + finalOnWalkable: owner.PhysicsBody.OnWalkable, + collidedWithEnvironment: false, + collidedObjectIds, + out var prepared)); + Assert.True(lifetime.Physics.CollisionReports + .TryInstallSetPositionBatch(prepared!, out var receipt)); + lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt); + } + private static RuntimeSetPositionCommand PlacementCommand( RuntimeEntityRecord owner, Vector3 position) From ef43667872b3f49c908b806819cefb3d8d8bc90b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 14:31:39 +0200 Subject: [PATCH 34/73] feat(runtime): own placement projection acknowledgement --- .../RuntimePlacementProjectionSubscription.cs | 119 +++++ ...imePlacementProjectionSubscriptionTests.cs | 415 ++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs diff --git a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs new file mode 100644 index 00000000..bc48214c --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs @@ -0,0 +1,119 @@ +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Physics; + +/// +/// Presentation-only sink for canonical Runtime SetPosition receipts. +/// Implementations must apply an exact receipt idempotently: acknowledgement +/// can fail after a successful projection when a re-entrant Runtime mutation +/// revises the FIFO head, and the same immutable receipt may then be retried. +/// +public interface IRuntimePlacementProjectionSink +{ + bool TryApply(in RuntimePlacementProjectionSnapshot projection); +} + +/// +/// Shared graphical/no-window subscription which projects only the oldest +/// canonical receipt and acknowledges it only after the host sink succeeds. +/// Runtime remains the sole position, collision, residence, and lifetime +/// authority; this class owns only its observer subscription and one +/// idempotency token for a projection that succeeded before acknowledgement. +/// +public sealed class RuntimePlacementProjectionSubscription + : IRuntimePlacementObserver, + IDisposable +{ + private readonly RuntimePlacementProjectionChannel _channel; + private readonly Func _generation; + private readonly IRuntimePlacementProjectionSink _sink; + private IDisposable? _subscription; + private RuntimePlacementProjectionToken _appliedAwaitingAcknowledgement; + private bool _disposed; + + public RuntimePlacementProjectionSubscription( + GameRuntime runtime, + IRuntimePlacementProjectionSink sink) + : this( + runtime?.Placements + ?? throw new ArgumentNullException(nameof(runtime)), + () => runtime.Generation, + sink) + { + } + + internal RuntimePlacementProjectionSubscription( + RuntimePlacementProjectionChannel channel, + Func generation, + IRuntimePlacementProjectionSink sink) + { + _channel = channel ?? throw new ArgumentNullException(nameof(channel)); + _generation = generation + ?? throw new ArgumentNullException(nameof(generation)); + _sink = sink ?? throw new ArgumentNullException(nameof(sink)); + _subscription = _channel.Subscribe(this); + _ = RetryPending(); + } + + public bool HasAppliedReceiptAwaitingAcknowledgement => + _appliedAwaitingAcknowledgement.IsValid; + + /// + /// Republishes Runtime's complete still-pending FIFO. Later receipts are + /// ignored until the exact oldest receipt projects and acknowledges. + /// + public bool RetryPending() + { + ObjectDisposedException.ThrowIf(_disposed, this); + RuntimeGenerationToken generation = _generation(); + if (_appliedAwaitingAcknowledgement.IsValid + && (!_channel.TryPeek( + generation, + out RuntimePlacementProjectionSnapshot head) + || head.Token != _appliedAwaitingAcknowledgement)) + { + _appliedAwaitingAcknowledgement = default; + } + return _channel.RetryPending(generation); + } + + public void OnPlacement(in RuntimePlacementDelta delta) + { + if (_disposed + || !_channel.TryPeek( + delta.Stamp.Generation, + out RuntimePlacementProjectionSnapshot head) + || head != delta.Placement) + { + return; + } + + RuntimePlacementProjectionToken token = head.Token; + if (_appliedAwaitingAcknowledgement != token) + { + if (!_sink.TryApply(in head)) + return; + // A sink can synchronously tear down its host while applying a + // receipt. Leave that receipt pending for the replacement host; + // disposal is never permission to acknowledge afterward. + if (_disposed) + return; + _appliedAwaitingAcknowledgement = token; + } + + if (_channel.Acknowledge(delta.Stamp.Generation, token) + && _appliedAwaitingAcknowledgement == token) + { + _appliedAwaitingAcknowledgement = default; + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Interlocked.Exchange(ref _subscription, null)?.Dispose(); + _appliedAwaitingAcknowledgement = default; + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs new file mode 100644 index 00000000..541a0ac3 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs @@ -0,0 +1,415 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimePlacementProjectionSubscriptionTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = Landblock | 0x0001u; + + [Fact] + public void SuccessfulSinkAcknowledgesExactPlaceSynchronously() + { + using var fixture = new Fixture(); + var sink = new RecordingSink(); + using var subscription = fixture.Subscribe(sink); + + RuntimeSetPositionOutcome outcome = fixture.Place( + fixture.First, + new Vector3(12f, 18f, 7f)); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + RuntimePlacementProjectionSnapshot only = Assert.Single(sink.Applied); + Assert.Equal(RuntimePlacementProjectionKind.Place, only.Kind); + Assert.Equal(outcome.Projection, only.Token); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + } + + [Fact] + public void RejectedSinkRetainsHeadAndRetryAppliesItExactly() + { + using var fixture = new Fixture(); + bool ready = false; + var sink = new RecordingSink(_ => ready); + using var subscription = fixture.Subscribe(sink); + RuntimeSetPositionOutcome outcome = fixture.Place( + fixture.First, + new Vector3(13f, 18f, 7f)); + + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.Single(sink.Applied); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + + ready = true; + Assert.True(subscription.RetryPending()); + + Assert.Equal(2, sink.Applied.Count); + Assert.All(sink.Applied, + projection => Assert.Equal(outcome.Projection, projection.Token)); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + + [Fact] + public void ThrowingSinkLeavesReceiptRetryableAndRecordsDispatchFailure() + { + using var fixture = new Fixture(); + bool fail = true; + var sink = new RecordingSink(_ => + { + if (fail) + throw new InvalidOperationException("host unavailable"); + return true; + }); + using var subscription = fixture.Subscribe(sink); + _ = fixture.Place(fixture.First, new Vector3(14f, 18f, 7f)); + + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.Equal(1, fixture.Lifetime.Events.DispatchFailureCount); + Assert.IsType( + fixture.Lifetime.Events.LastDispatchFailure); + + fail = false; + Assert.True(subscription.RetryPending()); + + Assert.Equal(2, sink.Applied.Count); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + + [Fact] + public void LaterReceiptCannotProjectBeforeRejectedFifoHead() + { + using var fixture = new Fixture(secondEntity: true); + bool ready = false; + RuntimeEntityKey firstKey = fixture.First.Key!.Value; + var sink = new RecordingSink(projection => + ready || projection.Token.Entity != firstKey); + using var subscription = fixture.Subscribe(sink); + + _ = fixture.Place(fixture.First, new Vector3(15f, 18f, 7f)); + _ = fixture.Place(fixture.Second!, new Vector3(16f, 18f, 7f)); + + RuntimePlacementProjectionSnapshot initial = Assert.Single(sink.Applied); + Assert.Equal(firstKey, initial.Token.Entity); + Assert.Equal(2, fixture.Lifetime.Placements.PendingCount); + + ready = true; + Assert.True(subscription.RetryPending()); + + Assert.Equal( + [firstKey, firstKey, fixture.Second!.Key!.Value], + sink.Applied.Select(item => item.Token.Entity).ToArray()); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + + [Fact] + public void ReentrantReplacementProjectsRevisedDiscardBeforeAcknowledging() + { + using var fixture = new Fixture(); + RuntimeEntityPlacementToken replacement = default; + var sink = new RecordingSink(projection => + { + if (projection.Kind is RuntimePlacementProjectionKind.Place) + { + replacement = fixture.Lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + fixture.First, + fixture.First.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + } + return true; + }); + using var subscription = fixture.Subscribe(sink); + + RuntimeSetPositionOutcome original = fixture.Place( + fixture.First, + new Vector3(17f, 18f, 7f)); + + Assert.True(replacement.IsValid); + Assert.Equal(2, sink.Applied.Count); + Assert.Equal(RuntimePlacementProjectionKind.Place, + sink.Applied[0].Kind); + Assert.Equal(RuntimePlacementProjectionKind.Discard, + sink.Applied[1].Kind); + Assert.Equal(original.Projection.Sequence, + sink.Applied[1].Token.Sequence); + Assert.True(sink.Applied[1].Token.Revision + > original.Projection.Revision); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + + fixture.Lifetime.Physics.SetPosition.Forget( + fixture.First, + releasePreparedMover: true); + } + + [Fact] + public void ConstructorDrainsReceiptsCreatedBeforeSubscriptionInFifoOrder() + { + using var fixture = new Fixture(secondEntity: true); + RuntimeSetPositionOutcome first = fixture.Place( + fixture.First, + new Vector3(18f, 18f, 7f)); + RuntimeSetPositionOutcome second = fixture.Place( + fixture.Second!, + new Vector3(19f, 18f, 7f)); + Assert.Equal(2, fixture.Lifetime.Placements.PendingCount); + var sink = new RecordingSink(); + + using var subscription = fixture.Subscribe(sink); + + Assert.Equal( + [first.Projection, second.Projection], + sink.Applied.Select(item => item.Token).ToArray()); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + } + + [Fact] + public void SyntheticWithdrawalUsesTheSameExactSinkAndAckPath() + { + using var fixture = new Fixture(); + var sink = new RecordingSink(); + using var subscription = fixture.Subscribe(sink); + + Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( + fixture.First, + publishWithdrawal: true)); + + RuntimePlacementProjectionSnapshot withdrawal = Assert.Single( + sink.Applied); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawal.Kind); + Assert.Equal(fixture.First.Key, withdrawal.Token.Entity); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + } + + [Fact] + public void DisposeUnsubscribesWithoutConsumingPendingRuntimeReceipt() + { + using var fixture = new Fixture(); + var sink = new RecordingSink(_ => false); + var subscription = fixture.Subscribe(sink); + _ = fixture.Place(fixture.First, new Vector3(18f, 18f, 7f)); + Assert.Equal(1, fixture.Lifetime.Events.PlacementSubscriberCount); + + subscription.Dispose(); + + Assert.Equal(0, fixture.Lifetime.Events.PlacementSubscriberCount); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.Throws(() => + subscription.RetryPending()); + + var replacementSink = new RecordingSink(); + using var replacement = fixture.Subscribe(replacementSink); + Assert.Single(replacementSink.Applied); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + + [Fact] + public void ReentrantSinkDisposalNeverAcknowledgesAfterHostTeardown() + { + using var fixture = new Fixture(); + RuntimePlacementProjectionSubscription? subscription = null; + var sink = new RecordingSink(_ => + { + subscription!.Dispose(); + return true; + }); + subscription = fixture.Subscribe(sink); + + _ = fixture.Place(fixture.First, new Vector3(19f, 18f, 7f)); + + Assert.Single(sink.Applied); + Assert.Equal(0, fixture.Lifetime.Events.PlacementSubscriberCount); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + + var replacementSink = new RecordingSink(); + using var replacement = fixture.Subscribe(replacementSink); + Assert.Single(replacementSink.Applied); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + + private sealed class Fixture : IDisposable + { + private readonly RuntimeGenerationToken _generation = new(7UL); + + internal Fixture(bool secondEntity = false) + { + Lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + Lifetime.BindEventContext( + () => _generation, + static () => 11UL); + First = CreateRecord(Lifetime, 0x70002001u, 1); + AttachBody(Lifetime, First, new Vector3(10f, 20f, 7f)); + if (secondEntity) + { + Second = CreateRecord(Lifetime, 0x70002002u, 1); + AttachBody(Lifetime, Second, new Vector3(11f, 20f, 7f)); + } + } + + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal RuntimeEntityRecord First { get; } + internal RuntimeEntityRecord? Second { get; } + + internal RuntimePlacementProjectionSubscription Subscribe( + IRuntimePlacementProjectionSink sink) => new( + Lifetime.Placements, + () => _generation, + sink); + + internal RuntimeSetPositionOutcome Place( + RuntimeEntityRecord record, + Vector3 position) => Lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(position)); + + public void Dispose() => Lifetime.Dispose(); + } + + private sealed class RecordingSink( + Func? apply = null) + : IRuntimePlacementProjectionSink + { + internal List Applied { get; } = []; + + public bool TryApply( + in RuntimePlacementProjectionSnapshot projection) + { + Applied.Add(projection); + return apply?.Invoke(projection) ?? true; + } + } + + private static RuntimeSetPositionCommand Command(Vector3 position) => new( + new PhysicsSetPositionRequest( + position, + Quaternion.Identity, + Cell, + position, + ImmutableArray.Empty, + Scale: 1f, + StepUpHeight: 0.4f, + StepDownHeight: 0.4f, + Flags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide), + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 10d, + ExpectedVelocityAuthorityVersion: 0UL); + + private static RuntimeEntityRecord CreateRecord( + RuntimeEntityObjectLifetime lifetime, + uint guid, + ushort incarnation) + { + RuntimeEntityRecord record = lifetime.RegisterEntity( + Spawn(guid, incarnation)).Canonical!; + lifetime.Entities.SetFinalPhysicsState( + record, + PhysicsStateFlags.Gravity); + return record; + } + + private static void AttachBody( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record, + Vector3 position) + { + lifetime.Entities.SetFullCell(record, Cell, Landblock | 0xFFFFu); + var body = new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(Cell, position, position); + lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true); + } + + private static PhysicsEngine FlatEngine() + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return engine; + } + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) + { + var position = new CreateObject.ServerPosition( + Cell, 10f, 20f, 7f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Gravity, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "placement-projection-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.Gravity, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} From 74c9b155bdd434e01d2e072fe2966ecff49913bc Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 15:00:49 +0200 Subject: [PATCH 35/73] feat(app): project canonical runtime placements --- src/AcDream.App/World/LiveEntityRuntime.cs | 308 +++++++ .../World/RuntimePlacementPresentationSink.cs | 175 ++++ .../World/RuntimeWorldTransitState.cs | 27 + .../RuntimePlacementPresentationSinkTests.cs | 813 ++++++++++++++++++ .../World/RuntimeWorldTransitStateTests.cs | 72 ++ 5 files changed, 1395 insertions(+) create mode 100644 src/AcDream.App/World/RuntimePlacementPresentationSink.cs create mode 100644 tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 5bac67ef..36650ef7 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -5,6 +5,7 @@ using AcDream.Core.Physics; using AcDream.Core.World; using AcDream.Runtime; using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; using System.Numerics; using System.Runtime.ExceptionServices; @@ -386,6 +387,15 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource private readonly Dictionary _spatialAnimations = new(); private readonly List _spatialRootCanonicalScratch = new(); private readonly List _spatialRemoteCanonicalScratch = new(); + // Runtime placement receipts are presentation observations of an already + // committed canonical SetPosition transaction. GpuWorldState still emits + // its ordinary visibility callback while that observation rebuckets or + // withdraws a sidecar, so pin the exact incarnation while the callback is + // in flight. A depth (rather than a bool) preserves nested/re-entrant + // projection mutations without ever turning graphical visibility into a + // Runtime physics/workset mutation. + private readonly Dictionary + _presentationOnlySpatialMutationDepth = new(); private bool _isClearing; private bool _sessionClearPendingFinalization; private bool _isRegisteringResources; @@ -446,6 +456,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource _projections.VisibleRecords; internal IReadOnlyCollection CanonicalRecords => _directory.ActiveRecords; + internal ulong SessionLifetimeVersion => _directory.SessionLifetimeVersion; internal RuntimePhysicsState Physics => _physics; public IReadOnlyDictionary Snapshots => _directory.Snapshots; internal int AnimationRuntimeCount @@ -863,6 +874,269 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return true; } + /// + /// Applies one canonical Runtime placement receipt to the graphical + /// sidecar only. Runtime has already committed identity, position, + /// collision residence, object-clock state, and simulation worksets. + /// + internal bool TryApplyRuntimePlacementProjection( + in RuntimePlacementProjectionSnapshot projection) + { + if (projection.Kind is RuntimePlacementProjectionKind.Discard) + { + // Discard cancels only an unacknowledged observation. If its Place + // was already projected, retail keeps that last committed frame + // visible until a later canonical Place or Withdraw supersedes it. + return true; + } + + RuntimePlacementProjectionToken token = projection.Token; + if (!TryGetRuntimePlacementProjectionRecord( + token, + requirePlacementVersions: + projection.Kind is RuntimePlacementProjectionKind.Place, + out LiveEntityRecord? record) + || record.WorldEntity is not { } entity) + { + return false; + } + if (projection.Kind is RuntimePlacementProjectionKind.Place + && !_spatial.IsLoaded( + (token.ExactCellId & 0xFFFF0000u) | 0xFFFFu)) + { + // A canonical SetPosition receipt is not permission to create a + // pending graphical bucket. Keep the FIFO head unacknowledged + // until the destination backend exists, otherwise GpuWorldState's + // later pending-drain edge escapes this exact receipt transaction. + return false; + } + + return projection.Kind switch + { + RuntimePlacementProjectionKind.Place => + TryApplyRuntimePlacementPlace(in projection, record, entity), + RuntimePlacementProjectionKind.Withdraw => + TryApplyRuntimePlacementWithdrawal(token, record, entity), + _ => false, + }; + } + + private bool TryApplyRuntimePlacementPlace( + in RuntimePlacementProjectionSnapshot projection, + LiveEntityRecord record, + WorldEntity entity) + { + RuntimePlacementProjectionToken token = projection.Token; + RuntimeEntityKey key = token.Entity; + ulong projectionOperation = ++record.ProjectionMutationVersion; + + entity.SetPosition(projection.WorldPosition); + entity.Rotation = projection.Orientation; + entity.ParentCellId = token.ExactCellId; + entity.EffectCellId = token.ExactCellId; + record.IsSpatiallyProjected = true; + + Exception? spatialNotificationFailure = null; + uint priorRebucketingGuid = _rebucketingGuid; + _rebucketingGuid = record.ServerGuid; + BeginPresentationOnlySpatialMutation(key); + try + { + try + { + _spatial.RebucketLiveEntity(key, entity, token.ExactCellId); + } + catch (AggregateException error) + { + spatialNotificationFailure = error; + } + } + finally + { + EndPresentationOnlySpatialMutation(key); + _rebucketingGuid = priorRebucketingGuid; + } + + if (!IsCurrentProjectionOperation( + record.ServerGuid, + record, + projectionOperation) + || !TryGetRuntimePlacementProjectionRecord( + token, + requirePlacementVersions: true, + out LiveEntityRecord? current) + || !ReferenceEquals(current, record) + || !ReferenceEquals(current.WorldEntity, entity)) + { + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } + + bool visible = _spatial.IsLiveEntityProjectionResident(key); + record.IsSpatiallyVisible = visible; + RefreshSpatialPresentationIndexes(record); + RefreshPresentation(record); + + if (!IsCurrentProjectionOperation( + record.ServerGuid, + record, + projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } + + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return true; + } + + private bool TryApplyRuntimePlacementWithdrawal( + in RuntimePlacementProjectionToken token, + LiveEntityRecord record, + WorldEntity entity) + { + RuntimeEntityKey key = token.Entity; + ulong projectionOperation = ++record.ProjectionMutationVersion; + record.IsSpatiallyProjected = false; + + Exception? spatialNotificationFailure = null; + uint priorRebucketingGuid = _rebucketingGuid; + _rebucketingGuid = record.ServerGuid; + BeginPresentationOnlySpatialMutation(key); + try + { + try + { + _spatial.RemoveLiveEntityProjection(entity); + } + catch (AggregateException error) + { + spatialNotificationFailure = error; + } + } + finally + { + EndPresentationOnlySpatialMutation(key); + _rebucketingGuid = priorRebucketingGuid; + } + + if (!IsCurrentProjectionOperation( + record.ServerGuid, + record, + projectionOperation) + || !TryGetRuntimePlacementProjectionRecord( + token, + requirePlacementVersions: false, + out LiveEntityRecord? current) + || !ReferenceEquals(current, record) + || !ReferenceEquals(current.WorldEntity, entity)) + { + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } + + record.IsSpatiallyVisible = false; + RefreshSpatialPresentationIndexes(record); + RefreshPresentation(record); + + if (!IsCurrentProjectionOperation( + record.ServerGuid, + record, + projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } + + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return true; + } + + private bool TryGetRuntimePlacementProjectionRecord( + in RuntimePlacementProjectionToken token, + bool requirePlacementVersions, + out LiveEntityRecord record) + { + if (!token.IsValid + || token.SessionLifetimeVersion != _directory.SessionLifetimeVersion + || !_projections.TryGet(token.Entity, out record!) + || !_directory.IsCurrent(record.Canonical) + || record.Canonical.Key != token.Entity + || RequireProjectionKey(record) != token.Entity + || !IsValidPortalPlacementAuthority(token)) + { + record = null!; + return false; + } + + if (requirePlacementVersions + && (record.Canonical.PositionAuthorityVersion + != token.PositionAuthorityVersion + || record.Canonical.SpatialAuthorityVersion + != token.SpatialAuthorityVersion + || record.Canonical.PlacementCommitVersion + != token.PlacementCommitVersion + || record.Canonical.FullCellId != token.ExactCellId)) + { + record = null!; + return false; + } + + return true; + } + + private static bool IsValidPortalPlacementAuthority( + in RuntimePlacementProjectionToken token) + { + RuntimePortalPlacementAuthority portal = token.Portal; + if (!portal.Present) + return portal.IsEmpty; + + return portal.IsValid + && portal.Projection.DestinationCell == token.ExactCellId; + } + + private void BeginPresentationOnlySpatialMutation(RuntimeEntityKey key) + { + _presentationOnlySpatialMutationDepth.TryGetValue(key, out int depth); + _presentationOnlySpatialMutationDepth[key] = checked(depth + 1); + } + + private void EndPresentationOnlySpatialMutation(RuntimeEntityKey key) + { + if (!_presentationOnlySpatialMutationDepth.TryGetValue( + key, + out int depth) + || depth <= 0) + { + throw new InvalidOperationException( + "Presentation-only spatial mutation depth was not balanced."); + } + + if (depth == 1) + _presentationOnlySpatialMutationDepth.Remove(key); + else + _presentationOnlySpatialMutationDepth[key] = depth - 1; + } + /// /// Removes only the render-bucket reference. The logical record and every /// create-time resource remain alive for later projection/rebucketing. @@ -2588,6 +2862,33 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource _physics.AcknowledgeSpatialProjection(record.Canonical, spatial); + RefreshSpatialPresentationIndexes(record, current, spatial, key); + } + + private void RefreshSpatialPresentationIndexes(LiveEntityRecord record) + { + bool current = IsCurrentRecord(record); + bool spatial = current && HasSpatialRuntimeProjection(record); + if (record.ProjectionKey is not { } key) + { + if (record.WorldEntity is not null) + { + throw new InvalidOperationException( + $"Materialized live entity 0x{record.ServerGuid:X8}/{record.Generation} has no exact projection key."); + } + return; + } + + RefreshSpatialPresentationIndexes(record, current, spatial, key); + } + + private void RefreshSpatialPresentationIndexes( + LiveEntityRecord record, + bool current, + bool spatial, + RuntimeEntityKey key) + { + if (record.WorldEntity is not null) { if (spatial && record.AnimationRuntime is { } animation) @@ -2641,6 +2942,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource bool wasVisible = record.IsSpatiallyVisible; if (RequireProjectionKey(record) != key) return; + if (_presentationOnlySpatialMutationDepth.ContainsKey(key)) + { + record.IsSpatiallyVisible = visible; + RefreshSpatialPresentationIndexes(record); + RefreshPresentation(record); + return; + } bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical); record.IsSpatiallyVisible = visible; bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World diff --git a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs new file mode 100644 index 00000000..1bef262f --- /dev/null +++ b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs @@ -0,0 +1,175 @@ +using AcDream.Runtime.Physics; +using AcDream.Runtime.World; +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Plugins; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.World; + +/// +/// Graphical projection sink for canonical Runtime SetPosition receipts. It +/// owns only App-facing world, effect-pose, cached-local-shadow, selection, +/// and renderer/VFX visibility projections. Runtime physics, shadows, body +/// state, clocks, and worksets were committed before this sink is invoked. +/// +/// This adapter deliberately owns no subscription: production composition +/// activates the shared observer only after both graphical and no-window hosts +/// implement the same presentation-only contract. +/// +internal sealed class RuntimePlacementPresentationSink + : IRuntimePlacementProjectionSink +{ + private readonly LiveEntityRuntime _liveEntities; + private readonly RuntimeWorldTransitState _transit; + private readonly WorldGameState _worldState; + private readonly WorldEvents _worldEvents; + private readonly EntityEffectPoseRegistry _effectPoses; + private readonly LocalPlayerShadowState _localPlayerShadow; + private readonly Func _localPlayerGuid; + private readonly Action _clearSelectionForUnavailableEntity; + private readonly Action[] _visibilitySinks; + + public RuntimePlacementPresentationSink( + LiveEntityRuntime liveEntities, + RuntimeWorldTransitState transit, + WorldGameState worldState, + WorldEvents worldEvents, + EntityEffectPoseRegistry effectPoses, + LocalPlayerShadowState localPlayerShadow, + Func localPlayerGuid, + Action clearSelectionForUnavailableEntity, + IEnumerable>? visibilitySinks = null) + { + _liveEntities = liveEntities + ?? throw new ArgumentNullException(nameof(liveEntities)); + _transit = transit ?? throw new ArgumentNullException(nameof(transit)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents)); + _effectPoses = effectPoses + ?? throw new ArgumentNullException(nameof(effectPoses)); + _localPlayerShadow = localPlayerShadow + ?? throw new ArgumentNullException(nameof(localPlayerShadow)); + _localPlayerGuid = localPlayerGuid + ?? throw new ArgumentNullException(nameof(localPlayerGuid)); + _clearSelectionForUnavailableEntity = clearSelectionForUnavailableEntity + ?? throw new ArgumentNullException( + nameof(clearSelectionForUnavailableEntity)); + _visibilitySinks = visibilitySinks?.ToArray() + ?? Array.Empty>(); + if (_visibilitySinks.Any(static sink => sink is null)) + throw new ArgumentException( + "Presentation visibility sinks cannot contain null.", + nameof(visibilitySinks)); + } + + public bool TryApply(in RuntimePlacementProjectionSnapshot projection) + { + if (projection.Kind is RuntimePlacementProjectionKind.Place + && !_transit.IsCurrentPlacementAuthority( + projection.Token.Portal, + projection.Token.ExactCellId)) + { + return false; + } + + if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection)) + return false; + if (projection.Kind is RuntimePlacementProjectionKind.Discard) + return true; + if (!_liveEntities.TryGetRecord( + projection.Token.Entity, + out LiveEntityRecord record) + || record.WorldEntity is not { } entity) + { + return false; + } + + return projection.Kind switch + { + RuntimePlacementProjectionKind.Place => + TryPublishPlace(record, entity), + RuntimePlacementProjectionKind.Withdraw => + TryPublishWithdrawal(record, entity), + _ => false, + }; + } + + private bool TryPublishPlace(LiveEntityRecord record, WorldEntity entity) + { + if (!IsCurrent(record, entity)) + return false; + + WorldEntitySnapshot snapshot = Snapshot(entity); + _worldState.Add(snapshot); + if (!IsCurrent(record, entity)) + return false; + _worldEvents.UpsertCurrent(snapshot); + if (!IsCurrent(record, entity)) + return false; + _effectPoses.PublishMeshRefs(entity); + if (!IsCurrent(record, entity)) + return false; + + if (record.ServerGuid == _localPlayerGuid()) + { + _localPlayerShadow.Set( + entity.Position, + entity.Rotation, + record.FullCellId); + } + + for (int i = 0; i < _visibilitySinks.Length; i++) + { + _visibilitySinks[i](record, true); + if (!IsCurrent(record, entity)) + return false; + } + return true; + } + + private bool TryPublishWithdrawal( + LiveEntityRecord record, + WorldEntity entity) + { + if (!IsCurrent(record, entity)) + return false; + + for (int i = 0; i < _visibilitySinks.Length; i++) + { + _visibilitySinks[i](record, false); + if (!IsCurrent(record, entity)) + return false; + } + + _worldState.RemoveById(entity.Id); + if (!IsCurrent(record, entity)) + return false; + _worldEvents.ForgetEntity(entity.Id); + if (!IsCurrent(record, entity)) + return false; + _effectPoses.Remove(entity.Id); + if (!IsCurrent(record, entity)) + return false; + if (record.ServerGuid == _localPlayerGuid()) + _localPlayerShadow.Clear(); + if (!IsCurrent(record, entity)) + return false; + _clearSelectionForUnavailableEntity(record.ServerGuid); + return IsCurrent(record, entity); + } + + private bool IsCurrent(LiveEntityRecord record, WorldEntity entity) => + _liveEntities.TryGetRecord( + record.ProjectionKey!.Value, + out LiveEntityRecord current) + && ReferenceEquals(current, record) + && ReferenceEquals(current.WorldEntity, entity); + + private static WorldEntitySnapshot Snapshot(WorldEntity entity) => new( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation); +} diff --git a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs index ebcad65d..9a6a889c 100644 --- a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs +++ b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs @@ -1,4 +1,5 @@ using System.Globalization; +using AcDream.Runtime.Physics; namespace AcDream.Runtime.World; @@ -247,6 +248,32 @@ public sealed class RuntimeWorldTransitState return false; } + /// + /// Validates the portal suffix carried by a canonical SetPosition + /// projection receipt against this instance's live transit ownership. + /// Shape equality alone is insufficient: a superseded, cancelled, or + /// completed host token can remain structurally valid after it has lost + /// authority to reveal the destination. + /// + public bool IsCurrentPlacementAuthority( + in RuntimePortalPlacementAuthority authority, + uint exactCellId) + { + if (!authority.Present) + return authority.IsEmpty; + + return authority.IsValid + && authority.Projection.DestinationCell == exactCellId + && IsCurrentPortalDestination( + authority.RevealGeneration, + authority.TeleportSequence, + exactCellId) + && TryGetHostProjection( + authority.Projection, + out RuntimeWorldHostProjectionSnapshot host) + && !host.IsSuperseding; + } + /// /// Marks the graphical reservation-release callback as due before the /// host executes it. A thrown callback therefore leaves an exact pending diff --git a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs new file mode 100644 index 00000000..18ac4a06 --- /dev/null +++ b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs @@ -0,0 +1,813 @@ +using System.Numerics; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.World; +using AcDream.Runtime; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.World; + +public sealed class RuntimePlacementPresentationSinkTests +{ + private const uint SourceCell = 0x01010001u; + private const uint DestinationCell = 0x01020001u; + private const uint Guid = 0x7000A101u; + + [Fact] + public void Place_ReframesAndRebucketsExactSidecarWithoutMutatingRuntimePhysics() + { + Fixture fixture = Fixture.Create(twoLandblocks: true); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + WorldEntity entity = Assert.IsType(record.WorldEntity); + + // Model the already-committed canonical SetPosition edge whose receipt + // the dormant graphical observer is now projecting. + record.FullCellId = DestinationCell; + record.CanonicalLandblockId = + (DestinationCell & 0xFFFF0000u) | 0xFFFFu; + record.Canonical.AdvancePlacementCommit(); + RuntimePlacementProjectionSnapshot place = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(44f, 55f, 66f), + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f)); + + RuntimeOwnershipSnapshot before = RuntimeOwnershipSnapshot.Capture( + fixture.Runtime, + record); + int genericVisibilityCount = 0; + fixture.Runtime.ProjectionVisibilityChanged += (_, _) => + genericVisibilityCount++; + + Assert.True(fixture.Sink.TryApply(in place)); + + Assert.Equal(place.WorldPosition, entity.Position); + Assert.Equal(place.Orientation, entity.Rotation); + Assert.Equal(DestinationCell, entity.ParentCellId); + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.Contains(record, fixture.Runtime.VisibleRecords); + Assert.True(fixture.Spatial.IsLiveEntityProjectionResident( + record.ProjectionKey!.Value)); + Assert.Equal(before, RuntimeOwnershipSnapshot.Capture( + fixture.Runtime, + record)); + Assert.Equal(0, genericVisibilityCount); + Assert.Equal( + place.WorldPosition, + Assert.Single(fixture.WorldState.Entities).Position); + Assert.Equal((record, true), Assert.Single(fixture.Visibility)); + Assert.Equal( + new LocalPlayerShadowState.Snapshot( + place.WorldPosition, + place.Orientation, + DestinationCell), + fixture.LocalShadow.Current); + + fixture.Spatial.RemoveLandblock(SourceCell | 0xFFFFu); + Assert.True(fixture.Spatial.IsLiveEntityProjectionResident( + record.ProjectionKey.Value)); + } + + [Fact] + public void Withdraw_RemovesOnlyPresentationAndRetainsLogicalRuntimeOwnership() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + RuntimePlacementProjectionSnapshot withdraw = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Withdraw, + record.WorldEntity!.Position, + record.WorldEntity.Rotation); + RuntimeOwnershipSnapshot before = RuntimeOwnershipSnapshot.Capture( + fixture.Runtime, + record); + int genericVisibilityCount = 0; + fixture.Runtime.ProjectionVisibilityChanged += (_, _) => + genericVisibilityCount++; + + Assert.True(fixture.Sink.TryApply(in withdraw)); + + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord current)); + Assert.Same(record, current); + Assert.NotNull(record.WorldEntity); + Assert.True(record.ResourcesRegistered); + Assert.False(record.IsSpatiallyProjected); + Assert.False(record.IsSpatiallyVisible); + Assert.DoesNotContain(record, fixture.Runtime.VisibleRecords); + Assert.False(fixture.Spatial.IsLiveEntityProjectionResident( + record.ProjectionKey!.Value)); + Assert.Equal(before, RuntimeOwnershipSnapshot.Capture( + fixture.Runtime, + record)); + Assert.Equal(0, genericVisibilityCount); + Assert.Empty(fixture.WorldState.Entities); + Assert.Equal(0, fixture.EffectPoses.Count); + Assert.Null(fixture.LocalShadow.Current); + Assert.Equal((record, false), Assert.Single(fixture.Visibility)); + Assert.Equal(Guid, Assert.Single(fixture.ClearedSelection)); + } + + [Fact] + public void Discard_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + WorldEntity entity = record.WorldEntity!; + RuntimePlacementProjectionSnapshot discard = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Discard, + new Vector3(900f), + Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1f)) with + { + Token = Placement(fixture, record, + RuntimePlacementProjectionKind.Place, + Vector3.Zero, + Quaternion.Identity).Token with + { + SessionLifetimeVersion = ulong.MaxValue, + PositionAuthorityVersion = ulong.MaxValue, + ExactCellId = 0xDEAD0001u, + }, + }; + Vector3 priorPosition = entity.Position; + Quaternion priorRotation = entity.Rotation; + bool priorVisible = record.IsSpatiallyVisible; + + Assert.True(fixture.Sink.TryApply(in discard)); + + Assert.Equal(priorPosition, entity.Position); + Assert.Equal(priorRotation, entity.Rotation); + Assert.Equal(priorVisible, record.IsSpatiallyVisible); + } + + [Fact] + public void Place_RejectsStaleCanonicalVersionsWithoutChangingSidecar() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + WorldEntity entity = record.WorldEntity!; + RuntimePlacementProjectionSnapshot stale = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(90f, 91f, 92f), + Quaternion.CreateFromAxisAngle(Vector3.UnitY, 1f)); + record.Canonical.AdvancePlacementCommit(); + Vector3 priorPosition = entity.Position; + Quaternion priorRotation = entity.Rotation; + + Assert.False(fixture.Sink.TryApply(in stale)); + + Assert.Equal(priorPosition, entity.Position); + Assert.Equal(priorRotation, entity.Rotation); + Assert.True(record.IsSpatiallyVisible); + } + + [Fact] + public void Place_WithoutMaterializedSidecarRemainsPendingForRetry() + { + Fixture fixture = Fixture.Create(); + LiveEntityRegistrationResult registration = fixture.Runtime.RegisterLiveEntity( + Spawn(Guid, 1, SourceCell)); + RuntimeEntityRecord canonical = Assert.IsType( + registration.Canonical); + RuntimeEntityKey missingKey = new(0x60000042u, canonical.Incarnation); + var token = new RuntimePlacementProjectionToken( + Sequence: 1, + Revision: 1, + Entity: missingKey, + PositionAuthorityVersion: canonical.PositionAuthorityVersion, + SpatialAuthorityVersion: canonical.SpatialAuthorityVersion, + PlacementCommitVersion: canonical.PlacementCommitVersion, + SessionLifetimeVersion: fixture.Runtime.SessionLifetimeVersion, + ExactCellId: canonical.FullCellId, + CollisionGeneration: 1, + Portal: default); + var place = new RuntimePlacementProjectionSnapshot( + token, + RuntimePlacementProjectionKind.Place, + new Vector3(1f, 2f, 3f), + Quaternion.Identity, + Vector3.Zero, + InContact: false, + OnWalkable: false); + + Assert.False(fixture.Sink.TryApply(in place)); + Assert.Empty(fixture.Spatial.Entities); + } + + [Fact] + public void Place_WithoutLoadedDestinationBackendRemainsPendingAtPriorProjection() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + WorldEntity entity = record.WorldEntity!; + Vector3 priorPosition = entity.Position; + record.FullCellId = DestinationCell; + record.CanonicalLandblockId = + (DestinationCell & 0xFFFF0000u) | 0xFFFFu; + record.Canonical.AdvancePlacementCommit(); + RuntimePlacementProjectionSnapshot place = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(70f, 71f, 72f), + Quaternion.Identity); + + Assert.False(fixture.Sink.TryApply(in place)); + + Assert.Equal(priorPosition, entity.Position); + Assert.True(record.IsSpatiallyVisible); + Assert.True(fixture.Spatial.IsLiveEntityProjectionResident( + record.ProjectionKey!.Value)); + Assert.Empty(fixture.Visibility); + } + + [Fact] + public void Place_UsesExactIncarnationAndCannotMutateSameGuidReplacement() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord old = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + RuntimePlacementProjectionSnapshot stale = Placement( + fixture, + old, + RuntimePlacementProjectionKind.Place, + new Vector3(88f, 77f, 66f), + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.25f)); + + LiveEntityRecord replacement = fixture.Materialize( + Spawn(Guid, 2, SourceCell)); + WorldEntity replacementEntity = replacement.WorldEntity!; + Vector3 priorPosition = replacementEntity.Position; + Quaternion priorRotation = replacementEntity.Rotation; + + Assert.False(fixture.Sink.TryApply(in stale)); + + Assert.Equal(priorPosition, replacementEntity.Position); + Assert.Equal(priorRotation, replacementEntity.Rotation); + Assert.True(replacement.IsSpatiallyVisible); + } + + [Fact] + public void PortalPlace_RequiresExactCurrentTransitHostAndSequence() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + RuntimePlacementProjectionSnapshot ordinary = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(21f, 22f, 23f), + Quaternion.Identity); + RuntimePortalPlacementAuthority authority = fixture.BeginPortal( + SourceCell, + teleportSequence: 9); + RuntimePlacementProjectionSnapshot current = ordinary with + { + Token = ordinary.Token with { Portal = authority }, + }; + + Assert.True(fixture.Sink.TryApply(in current)); + Assert.Equal(current.WorldPosition, record.WorldEntity!.Position); + + Assert.True(fixture.Transit.BeginHostProjectionSupersession( + authority.Projection)); + RuntimePlacementProjectionSnapshot superseded = current with + { + WorldPosition = new Vector3(80f, 81f, 82f), + }; + Assert.False(fixture.Sink.TryApply(in superseded)); + Assert.Equal(current.WorldPosition, record.WorldEntity.Position); + + RuntimePlacementProjectionSnapshot wrongSequence = current with + { + Token = current.Token with + { + Portal = authority with { TeleportSequence = 10 }, + }, + WorldPosition = new Vector3(90f, 91f, 92f), + }; + Assert.False(fixture.Sink.TryApply(in wrongSequence)); + Assert.Equal(current.WorldPosition, record.WorldEntity.Position); + } + + [Fact] + public void PlaceAndWithdraw_DoNotMutateRemoteBodyOrRuntimeOwnership() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + RemoteMotion remote = fixture.Runtime.GetOrCreateRemoteMotionRuntime(Guid); + remote.Body.Position = new Vector3(4f, 5f, 6f); + remote.Body.Orientation = Quaternion.CreateFromAxisAngle( + Vector3.UnitY, + 0.4f); + remote.Body.State = PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions; + remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + remote.Body.InWorld = true; + remote.Body.LastUpdateTime = 42.5; + PhysicsBodySnapshot bodyBefore = PhysicsBodySnapshot.Capture(remote.Body); + RuntimePhysicsOwnershipSnapshot runtimeBefore = + fixture.Runtime.Physics.CaptureOwnership(); + RuntimePlacementProjectionSnapshot place = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(30f, 31f, 32f), + Quaternion.Identity); + + Assert.True(fixture.Sink.TryApply(in place)); + Assert.Equal(bodyBefore, PhysicsBodySnapshot.Capture(remote.Body)); + Assert.Equal(runtimeBefore, fixture.Runtime.Physics.CaptureOwnership()); + + RuntimePlacementProjectionSnapshot withdraw = place with + { + Kind = RuntimePlacementProjectionKind.Withdraw, + }; + Assert.True(fixture.Sink.TryApply(in withdraw)); + Assert.Equal(bodyBefore, PhysicsBodySnapshot.Capture(remote.Body)); + Assert.Equal(runtimeBefore, fixture.Runtime.Physics.CaptureOwnership()); + } + + [Fact] + public void Withdraw_DoesNotMutateProjectileBodyShadowOrWorksetOwnership() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + PhysicsBody body = fixture.Runtime.GetOrCreatePhysicsBody( + Guid, + _ => new PhysicsBody()); + body.Position = new Vector3(7f, 8f, 9f); + body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.6f); + body.State = PhysicsStateFlags.Missile + | PhysicsStateFlags.ReportCollisions; + body.TransientState = TransientStateFlags.Active; + body.InWorld = true; + body.LastUpdateTime = 99.25; + fixture.Runtime.BindProjectileRuntime( + Guid, + body, + new ProjectileCollisionSphere(Vector3.Zero, 0.25f)); + PhysicsBodySnapshot bodyBefore = PhysicsBodySnapshot.Capture(body); + RuntimePhysicsOwnershipSnapshot runtimeBefore = + fixture.Runtime.Physics.CaptureOwnership(); + RuntimePlacementProjectionSnapshot withdraw = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Withdraw, + record.WorldEntity!.Position, + record.WorldEntity.Rotation); + + Assert.True(fixture.Sink.TryApply(in withdraw)); + + Assert.Equal(bodyBefore, PhysicsBodySnapshot.Capture(body)); + Assert.Equal(runtimeBefore, fixture.Runtime.Physics.CaptureOwnership()); + Assert.Same(body, record.ProjectileRuntime!.Body); + } + + [Fact] + public void FailedPresentationTail_RetriesSameReceiptIdempotently() + { + using var fixture = SubscriptionFixture.Create(); + fixture.VisibilityFailuresRemaining = 1; + using var subscription = new RuntimePlacementProjectionSubscription( + fixture.Lifetime.Placements, + () => fixture.Generation, + fixture.Sink); + + Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( + fixture.Record.Canonical, + publishWithdrawal: true)); + + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.Equal(1, fixture.Lifetime.Events.DispatchFailureCount); + Assert.IsType( + fixture.Lifetime.Events.LastDispatchFailure); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + + Assert.True(subscription.RetryPending()); + + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.False(fixture.Record.IsSpatiallyProjected); + Assert.Empty(fixture.WorldState.Entities); + Assert.Equal(0, fixture.EffectPoses.Count); + Assert.Equal(2, fixture.Visibility.Count); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + } + + private static RuntimePlacementProjectionSnapshot Placement( + Fixture fixture, + LiveEntityRecord record, + RuntimePlacementProjectionKind kind, + Vector3 position, + Quaternion orientation) + { + RuntimeEntityRecord canonical = record.Canonical; + var token = new RuntimePlacementProjectionToken( + Sequence: 1, + Revision: 1, + Entity: record.ProjectionKey!.Value, + PositionAuthorityVersion: canonical.PositionAuthorityVersion, + SpatialAuthorityVersion: canonical.SpatialAuthorityVersion, + PlacementCommitVersion: canonical.PlacementCommitVersion, + SessionLifetimeVersion: fixture.Runtime.SessionLifetimeVersion, + ExactCellId: canonical.FullCellId, + CollisionGeneration: 1, + Portal: default); + return new RuntimePlacementProjectionSnapshot( + token, + kind, + position, + orientation, + CellLocalPosition: position, + InContact: false, + OnWalkable: false); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort instance, + uint cell) + { + var position = new CreateObject.ServerPosition( + cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private readonly record struct RuntimeOwnershipSnapshot( + ulong PositionAuthorityVersion, + ulong SpatialAuthorityVersion, + ulong PlacementCommitVersion, + ulong ObjectClockEpoch, + bool ObjectClockIsActive, + int SpatialRootCount, + int SpatialRemoteCount, + int SpatialProjectileCount, + RuntimePhysicsOwnershipSnapshot Physics) + { + internal static RuntimeOwnershipSnapshot Capture( + LiveEntityRuntime runtime, + LiveEntityRecord record) => new( + record.Canonical.PositionAuthorityVersion, + record.Canonical.SpatialAuthorityVersion, + record.Canonical.PlacementCommitVersion, + record.ObjectClockEpoch, + record.ObjectClock.IsActive, + runtime.SpatialRootObjectCount, + runtime.SpatialRemoteMotionRuntimeCount, + runtime.SpatialProjectileRuntimeCount, + runtime.Physics.CaptureOwnership()); + } + + private readonly record struct PhysicsBodySnapshot( + Vector3 Position, + Quaternion Orientation, + PhysicsStateFlags State, + TransientStateFlags TransientState, + bool InWorld, + double LastUpdateTime) + { + internal static PhysicsBodySnapshot Capture(PhysicsBody body) => new( + body.Position, + body.Orientation, + body.State, + body.TransientState, + body.InWorld, + body.LastUpdateTime); + } + + private sealed class Fixture + { + private Fixture( + GpuWorldState spatial, + LiveEntityRuntime runtime, + RuntimeWorldTransitState transit, + WorldGameState worldState, + WorldEvents worldEvents, + EntityEffectPoseRegistry effectPoses, + LocalPlayerShadowState localShadow) + { + Spatial = spatial; + Runtime = runtime; + Transit = transit; + WorldState = worldState; + WorldEvents = worldEvents; + EffectPoses = effectPoses; + LocalShadow = localShadow; + Sink = new RuntimePlacementPresentationSink( + runtime, + transit, + worldState, + worldEvents, + effectPoses, + localShadow, + () => Guid, + ClearedSelection.Add, + [ + (record, visible) => + { + Visibility.Add((record, visible)); + if (VisibilityFailuresRemaining > 0) + { + VisibilityFailuresRemaining--; + throw new InvalidOperationException( + "fixture presentation failure"); + } + }, + ]); + } + + internal GpuWorldState Spatial { get; } + internal LiveEntityRuntime Runtime { get; } + internal RuntimeWorldTransitState Transit { get; } + internal WorldGameState WorldState { get; } + internal WorldEvents WorldEvents { get; } + internal EntityEffectPoseRegistry EffectPoses { get; } + internal LocalPlayerShadowState LocalShadow { get; } + internal List<(LiveEntityRecord Record, bool Visible)> Visibility { get; } = []; + internal List ClearedSelection { get; } = []; + internal int VisibilityFailuresRemaining { get; set; } + internal RuntimePlacementPresentationSink Sink { get; } + + internal static Fixture Create(bool twoLandblocks = false) + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(SourceCell | 0xFFFFu)); + if (twoLandblocks) + spatial.AddLandblock(EmptyLandblock(DestinationCell | 0xFFFFu)); + var resources = new RecordingResources(); + LiveEntityRuntime runtime = LiveEntityRuntimeFixture.Create( + spatial, + resources); + return new Fixture( + spatial, + runtime, + new RuntimeWorldTransitState(), + new WorldGameState(), + new WorldEvents(), + new EntityEffectPoseRegistry(), + new LocalPlayerShadowState()); + } + + internal LiveEntityRecord Materialize(WorldSession.EntitySpawn spawn) + { + LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(spawn); + Assert.True(record.ResourcesRegistered); + WorldEntity entity = record.WorldEntity!; + var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation); + WorldState.Add(snapshot); + WorldEvents.UpsertCurrent(snapshot); + EffectPoses.PublishMeshRefs(entity); + if (record.ServerGuid == Guid) + { + LocalShadow.Set( + entity.Position, + entity.Rotation, + record.FullCellId); + } + return record; + } + + internal RuntimePortalPlacementAuthority BeginPortal( + uint cell, + ushort teleportSequence) + { + Assert.True(Transit.TryQueueTeleportStart(teleportSequence)); + Assert.True(Transit.ActivateQueuedTeleport()); + Assert.True(Transit.OfferTeleportDestination( + new RuntimeTeleportDestination( + Guid, + InstanceSequence: 1, + PositionSequence: 1, + TeleportSequence: teleportSequence, + ForcePositionSequence: 1, + new Position( + cell, + new Vector3(1f, 2f, 3f), + Quaternion.Identity)), + teleportTimestampAdvanced: true)); + Assert.True(Transit.TryBeginPortalReveal( + teleportSequence, + cell, + out long generation)); + Assert.True(Transit.TryRegisterHostProjection( + generation, + cell, + out RuntimeWorldHostProjectionToken host)); + return new RuntimePortalPlacementAuthority( + true, + generation, + teleportSequence, + host); + } + + private static LoadedLandblock EmptyLandblock(uint canonicalId) => + new(canonicalId, new LandBlock(), Array.Empty()); + } + + private sealed class SubscriptionFixture : IDisposable + { + private SubscriptionFixture( + RuntimeEntityObjectLifetime lifetime, + LiveEntityRuntime runtime, + LiveEntityRecord record, + RuntimePlacementPresentationSink sink, + WorldGameState worldState, + EntityEffectPoseRegistry effectPoses, + List<(LiveEntityRecord Record, bool Visible)> visibility) + { + Lifetime = lifetime; + Runtime = runtime; + Record = record; + Sink = sink; + WorldState = worldState; + EffectPoses = effectPoses; + Visibility = visibility; + } + + internal RuntimeGenerationToken Generation { get; } = new(7UL); + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal LiveEntityRuntime Runtime { get; } + internal LiveEntityRecord Record { get; } + internal RuntimePlacementPresentationSink Sink { get; } + internal WorldGameState WorldState { get; } + internal EntityEffectPoseRegistry EffectPoses { get; } + internal List<(LiveEntityRecord Record, bool Visible)> Visibility { get; } + internal int VisibilityFailuresRemaining { get; set; } + + internal static SubscriptionFixture Create() + { + PhysicsEngine engine = FlatEngine(); + var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeGenerationToken generation = new(7UL); + lifetime.BindEventContext(() => generation, static () => 11UL); + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + SourceCell | 0xFFFFu, + new LandBlock(), + Array.Empty())); + var runtime = new LiveEntityRuntime( + spatial, + new RecordingResources(), + lifetime); + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection( + Spawn(Guid, 1, SourceCell)); + PhysicsBody body = runtime.GetOrCreatePhysicsBody( + Guid, + _ => new PhysicsBody + { + Position = new Vector3(10f, 10f, 5f), + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.Active, + }); + body.SnapToCell( + SourceCell, + body.Position, + body.Position); + + var worldState = new WorldGameState(); + var worldEvents = new WorldEvents(); + var effectPoses = new EntityEffectPoseRegistry(); + var localShadow = new LocalPlayerShadowState(); + WorldEntity entity = record.WorldEntity!; + var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation); + worldState.Add(snapshot); + worldEvents.UpsertCurrent(snapshot); + effectPoses.PublishMeshRefs(entity); + localShadow.Set(entity.Position, entity.Rotation, record.FullCellId); + var visibility = new List<(LiveEntityRecord Record, bool Visible)>(); + SubscriptionFixture? fixture = null; + var sink = new RuntimePlacementPresentationSink( + runtime, + new RuntimeWorldTransitState(), + worldState, + worldEvents, + effectPoses, + localShadow, + () => Guid, + _ => { }, + [ + (candidate, visible) => + { + visibility.Add((candidate, visible)); + if (fixture!.VisibilityFailuresRemaining > 0) + { + fixture.VisibilityFailuresRemaining--; + throw new InvalidOperationException( + "fixture presentation failure"); + } + }, + ]); + fixture = new SubscriptionFixture( + lifetime, + runtime, + record, + sink, + worldState, + effectPoses, + visibility); + return fixture; + } + + public void Dispose() + { + Runtime.Clear(); + Lifetime.Dispose(); + } + + private static PhysicsEngine FlatEngine() + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + engine.AddLandblock( + SourceCell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return engine; + } + } + + private sealed class RecordingResources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) { } + public void Unregister(WorldEntity entity) { } + } +} diff --git a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs index b4c5d0ac..37561c95 100644 --- a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs +++ b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.Core.Physics; +using AcDream.Runtime.Physics; using AcDream.Runtime.World; namespace AcDream.Runtime.Tests.World; @@ -9,6 +10,77 @@ public sealed class RuntimeWorldTransitStateTests private const uint OutdoorCell = 0x11340021u; private const uint OtherCell = 0x3032001Cu; + [Fact] + public void PlacementAuthority_RequiresCurrentPortalHostSequenceAndReveal() + { + var state = new RuntimeWorldTransitState(); + long generation = BeginPortal(state, OutdoorCell, sequence: 7); + RuntimeWorldHostProjectionToken host = + RegisterHost(state, generation, OutdoorCell); + RuntimePortalPlacementAuthority authority = new( + Present: true, + RevealGeneration: generation, + TeleportSequence: 7, + Projection: host); + + Assert.True(state.IsCurrentPlacementAuthority( + authority, + OutdoorCell)); + Assert.True(state.IsCurrentPlacementAuthority(default, OutdoorCell)); + Assert.False(state.IsCurrentPlacementAuthority( + authority with { TeleportSequence = 8 }, + OutdoorCell)); + Assert.False(state.IsCurrentPlacementAuthority( + authority, + OtherCell)); + + Assert.True(state.BeginHostProjectionSupersession(host)); + Assert.False(state.IsCurrentPlacementAuthority( + authority, + OutdoorCell)); + } + + [Fact] + public void PlacementAuthority_RejectsCancelledAndCompletedReveals() + { + var cancelled = new RuntimeWorldTransitState(); + long cancelledGeneration = BeginPortal(cancelled, OutdoorCell); + RuntimeWorldHostProjectionToken cancelledHost = + RegisterHost(cancelled, cancelledGeneration, OutdoorCell); + RuntimePortalPlacementAuthority cancelledAuthority = new( + true, + cancelledGeneration, + 1, + cancelledHost); + Assert.True(cancelled.Cancel(cancelledGeneration)); + Assert.False(cancelled.IsCurrentPlacementAuthority( + cancelledAuthority, + OutdoorCell)); + + var completed = new RuntimeWorldTransitState(); + long completedGeneration = BeginPortal(completed, OutdoorCell); + RuntimeWorldHostProjectionToken completedHost = + RegisterHost(completed, completedGeneration, OutdoorCell); + RuntimePortalPlacementAuthority completedAuthority = new( + true, + completedGeneration, + 1, + completedHost); + Assert.True(completed.AcknowledgeDestinationReadiness( + Ready(completedGeneration, OutdoorCell))); + Assert.True(completed.AcknowledgePortalMaterialized( + completedGeneration, + 1, + OutdoorCell)); + Assert.True(completed.AcknowledgeWorldViewportVisible( + completedGeneration)); + Assert.True(completed.Complete(completedGeneration)); + + Assert.False(completed.IsCurrentPlacementAuthority( + completedAuthority, + OutdoorCell)); + } + [Fact] public void BeginReveal_OwnsGenerationDestinationAndSimulationGate() { From 378ca95a6772f4a78785ef4ca25bb372f1712d17 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 15:10:03 +0200 Subject: [PATCH 36/73] feat(headless): observe canonical placement receipts --- .../HeadlessRuntimePlacementProjectionSink.cs | 76 +++++ .../Hosting/HeadlessSessionEventRoute.cs | 67 +++++ .../Hosting/HeadlessSessionHost.cs | 9 +- .../Runtime/RuntimePhysicsOwnershipTests.cs | 40 ++- .../HeadlessSessionHostTests.cs | 276 ++++++++++++++++++ 5 files changed, 445 insertions(+), 23 deletions(-) create mode 100644 src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs create mode 100644 src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs diff --git a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs new file mode 100644 index 00000000..51f3d156 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs @@ -0,0 +1,76 @@ +using AcDream.Runtime; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime.World; + +namespace AcDream.Headless.Hosting; + +/// +/// Validation-only no-window observer for canonical Runtime SetPosition +/// receipts. A headless host has no graphical sidecar to move or hide, so a +/// valid receipt is acknowledged without re-running placement or mutating +/// Runtime's body, controller, shadows, clocks, or worksets. +/// +internal sealed class HeadlessRuntimePlacementProjectionSink + : IRuntimePlacementProjectionSink +{ + private readonly GameRuntime _runtime; + + internal HeadlessRuntimePlacementProjectionSink(GameRuntime runtime) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + } + + public bool TryApply( + in RuntimePlacementProjectionSnapshot projection) + { + if (projection.Kind is RuntimePlacementProjectionKind.Discard) + { + // Discard cancels only an unacknowledged observation. It is valid + // even after its entity/session authority has been superseded. + return true; + } + + RuntimePlacementProjectionToken token = projection.Token; + RuntimeEntityDirectory directory = _runtime.EntityObjects.Entities; + if (!token.IsValid + || token.SessionLifetimeVersion + != directory.SessionLifetimeVersion + || !directory.TryGetByLocalId( + token.Entity.LocalEntityId, + out RuntimeEntityRecord record) + || !directory.IsCurrent(record) + || record.Key != token.Entity + || !HasValidPortalShape(token)) + { + return false; + } + + if (projection.Kind is RuntimePlacementProjectionKind.Withdraw) + return true; + if (projection.Kind is not RuntimePlacementProjectionKind.Place) + return false; + + return record.PositionAuthorityVersion + == token.PositionAuthorityVersion + && record.SpatialAuthorityVersion + == token.SpatialAuthorityVersion + && record.PlacementCommitVersion + == token.PlacementCommitVersion + && record.FullCellId == token.ExactCellId + && _runtime.TransitOwner.IsCurrentPlacementAuthority( + token.Portal, + token.ExactCellId); + } + + private static bool HasValidPortalShape( + in RuntimePlacementProjectionToken token) + { + RuntimePortalPlacementAuthority portal = token.Portal; + if (!portal.Present) + return portal.IsEmpty; + + return portal.IsValid + && portal.Projection.DestinationCell == token.ExactCellId; + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs new file mode 100644 index 00000000..a07d56a9 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs @@ -0,0 +1,67 @@ +using AcDream.Runtime; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Hosting; + +/// +/// Owns the inbound network route and the canonical placement observer for +/// one exact headless session lifetime. Placement detaches first so no +/// receipt can reach a retiring no-window projection while the network route +/// is being removed or Runtime is being reset. +/// +internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting +{ + private readonly ILiveSessionEventRouting _events; + private readonly GameRuntime _runtime; + private readonly IRuntimePlacementProjectionSink _placements; + private RuntimePlacementProjectionSubscription? _subscription; + private bool _attachStarted; + private bool _eventsDisposed; + private bool _disposed; + + internal HeadlessSessionEventRoute( + ILiveSessionEventRouting events, + GameRuntime runtime, + IRuntimePlacementProjectionSink placements) + { + _events = events ?? throw new ArgumentNullException(nameof(events)); + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _placements = placements + ?? throw new ArgumentNullException(nameof(placements)); + } + + public void Attach() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_attachStarted) + return; + + // Mark the attempt before the fallible call. If Attach partially + // succeeds and throws, LiveSessionHost's retryable rollback still + // invokes Dispose on the underlying route. + _attachStarted = true; + _events.Attach(); + _subscription = new RuntimePlacementProjectionSubscription( + _runtime, + _placements); + } + + public void Dispose() + { + if (_disposed) + return; + + // Subscription disposal is idempotent and deliberately precedes the + // network route. A still-pending FIFO head remains Runtime-owned for + // the replacement route to drain. + Interlocked.Exchange(ref _subscription, null)?.Dispose(); + if (!_eventsDisposed) + { + _events.Dispose(); + _eventsDisposed = true; + } + + _disposed = true; + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 8378933c..5c55b088 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -4,6 +4,7 @@ using AcDream.Headless.Diagnostics; using AcDream.Headless.Policies; using AcDream.Runtime; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; using AcDream.Runtime.Session; namespace AcDream.Headless.Hosting; @@ -521,7 +522,7 @@ internal sealed class HeadlessSessionHost : IDisposable } } - private LiveSessionEventRouter CreateEventRoute( + private ILiveSessionEventRouting CreateEventRoute( AcDream.Core.Net.WorldSession session) { IRuntimeDirectWorldProjection? worldProjection = @@ -536,7 +537,7 @@ internal sealed class HeadlessSessionHost : IDisposable message, Runtime.Generation.Value), worldProjection); - return new LiveSessionEventRouter( + var route = new LiveSessionEventRouter( session, entities.CreateSink(), new LiveEnvironmentSessionSink( @@ -579,6 +580,10 @@ internal sealed class HeadlessSessionHost : IDisposable Runtime.CommunicationOwner.TurbineChat, Runtime.CommunicationOwner.Friends, Runtime.CommunicationOwner.Squelch)); + return new HeadlessSessionEventRoute( + route, + Runtime, + new HeadlessRuntimePlacementProjectionSink(Runtime)); } private static LiveSessionCharacterSelector MapCharacterSelector( diff --git a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs index 5c64367d..5ac5862f 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs @@ -5,30 +5,28 @@ namespace AcDream.App.Tests.Runtime; public sealed class RuntimePhysicsOwnershipTests { [Fact] - public void PlacementProjectionChannelRemainsDormantInProductionHosts() + public void GraphicalPlacementProjectionRemainsDormantUntilCoordinatedCutover() { string root = FindRepositoryRoot(); - foreach (string relative in new[] - { - Path.Combine("src", "AcDream.App"), - Path.Combine("src", "AcDream.Headless"), - }) + string relative = Path.Combine("src", "AcDream.App"); + foreach (string file in Directory.EnumerateFiles( + Path.Combine(root, relative), + "*.cs", + SearchOption.AllDirectories)) { - foreach (string file in Directory.EnumerateFiles( - Path.Combine(root, relative), - "*.cs", - SearchOption.AllDirectories)) - { - string source = File.ReadAllText(file); - Assert.DoesNotContain( - ".Placements.", - source, - StringComparison.Ordinal); - Assert.DoesNotContain( - "RuntimePlacementProjectionChannel", - source, - StringComparison.Ordinal); - } + string source = File.ReadAllText(file); + Assert.DoesNotContain( + ".Placements.", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "RuntimePlacementProjectionChannel", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "RuntimePlacementProjectionSubscription", + source, + StringComparison.Ordinal); } } diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index ffb9e650..7bcb7114 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -374,6 +374,231 @@ public sealed class HeadlessSessionHostTests Assert.Equal(2, collision.CenterCount); } + [Fact] + public void PlacementReceiptValidationDoesNotRegainMovementOrPhysicsAuthority() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + const uint player = 0x50000004u; + runtime.PlayerIdentity.ServerGuid = player; + AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntity(Spawn(player)) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + record, + record.CreateIntegrationVersion, + record.Snapshot, + replaceGeneration: false)); + var directProjection = new HeadlessSessionWorldProjection( + runtime, + new FixtureCollisionNeighborhood()); + directProjection.ProjectSpawn(record, isLocalPlayer: true); + PlayerMovementController controller = Assert.IsType< + PlayerMovementController>(runtime.MovementOwner.Controller); + Vector3 positionBefore = controller.Position; + Quaternion orientationBefore = controller.BodyOrientation; + RuntimePhysicsOwnershipSnapshot physicsBefore = + runtime.EntityObjects.Physics.CaptureOwnership(); + RuntimePlacementProjectionSnapshot receipt = Placement( + runtime, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(600f, 601f, 602f), + Quaternion.CreateFromAxisAngle(Vector3.UnitY, 1.2f)); + + var receiptSink = new HeadlessRuntimePlacementProjectionSink(runtime); + Assert.True(receiptSink.TryApply(in receipt)); + + Assert.Equal(positionBefore, controller.Position); + Assert.Equal(orientationBefore, controller.BodyOrientation); + Assert.Equal( + physicsBefore, + runtime.EntityObjects.Physics.CaptureOwnership()); + } + + [Fact] + public void PlacementReceiptUsesExactIncarnationAndDiscardIsAckOnly() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntity(Spawn(0x50000005u)) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + record, + record.CreateIntegrationVersion, + record.Snapshot, + replaceGeneration: false)); + var projection = new HeadlessRuntimePlacementProjectionSink(runtime); + RuntimePlacementProjectionSnapshot place = Placement( + runtime, + record, + RuntimePlacementProjectionKind.Place, + Vector3.One, + Quaternion.Identity); + RuntimePlacementProjectionSnapshot stale = place with + { + Token = place.Token with + { + Entity = place.Token.Entity with + { + Incarnation = unchecked((ushort)( + place.Token.Entity.Incarnation + 1)), + }, + }, + }; + RuntimePlacementProjectionSnapshot discard = stale with + { + Kind = RuntimePlacementProjectionKind.Discard, + Token = stale.Token with + { + SessionLifetimeVersion = ulong.MaxValue, + }, + }; + + Assert.False(projection.TryApply(in stale)); + Assert.True(projection.TryApply(in discard)); + } + + [Fact] + public void SessionEventRouteOwnsOneObserverAndUnsubscribesBeforeNetworkDetach() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + int subscriberCountDuringDetach = -1; + var inner = new FixtureEventRoute( + onDispose: () => subscriberCountDuringDetach = + runtime.EntityObjects.Events.PlacementSubscriberCount); + var placements = new HeadlessRuntimePlacementProjectionSink(runtime); + var route = new HeadlessSessionEventRoute( + inner, + runtime, + placements); + + Assert.Equal( + 0, + runtime.EntityObjects.Events.PlacementSubscriberCount); + route.Attach(); + route.Attach(); + Assert.Equal( + 1, + runtime.EntityObjects.Events.PlacementSubscriberCount); + Assert.Equal(1, inner.AttachCount); + + route.Dispose(); + route.Dispose(); + + Assert.Equal(0, subscriberCountDuringDetach); + Assert.Equal( + 0, + runtime.EntityObjects.Events.PlacementSubscriberCount); + Assert.Equal(1, inner.DisposeCount); + } + + [Fact] + public void ReplacementSessionEventRouteGetsOneFreshObserver() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + var placements = new HeadlessRuntimePlacementProjectionSink(runtime); + + var first = new HeadlessSessionEventRoute( + new FixtureEventRoute(), + runtime, + placements); + first.Attach(); + Assert.Equal( + 1, + runtime.EntityObjects.Events.PlacementSubscriberCount); + first.Dispose(); + Assert.Equal( + 0, + runtime.EntityObjects.Events.PlacementSubscriberCount); + + var replacement = new HeadlessSessionEventRoute( + new FixtureEventRoute(), + runtime, + placements); + replacement.Attach(); + Assert.Equal( + 1, + runtime.EntityObjects.Events.PlacementSubscriberCount); + replacement.Dispose(); + Assert.Equal( + 0, + runtime.EntityObjects.Events.PlacementSubscriberCount); + } + + [Fact] + public void SessionEventRouteRetryDoesNotRestorePlacementObserver() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + var inner = new FixtureEventRoute + { + DisposeFailuresRemaining = 1, + }; + var route = new HeadlessSessionEventRoute( + inner, + runtime, + new HeadlessRuntimePlacementProjectionSink(runtime)); + route.Attach(); + + Assert.Throws(route.Dispose); + Assert.Equal( + 0, + runtime.EntityObjects.Events.PlacementSubscriberCount); + Assert.Equal(1, inner.DisposeCount); + + route.Dispose(); + + Assert.Equal(2, inner.DisposeCount); + Assert.Equal( + 0, + runtime.EntityObjects.Events.PlacementSubscriberCount); + } + [Fact] public void CollisionTransactionCancelsPostAdmissionFaultWithoutWithdrawingActiveWorld() { @@ -587,6 +812,36 @@ public sealed class HeadlessSessionHostTests Physics: physics); } + private static RuntimePlacementProjectionSnapshot Placement( + GameRuntime runtime, + RuntimeEntityRecord record, + RuntimePlacementProjectionKind kind, + Vector3 position, + Quaternion orientation) + { + RuntimeEntityKey key = Assert.IsType(record.Key); + var token = new RuntimePlacementProjectionToken( + Sequence: 1, + Revision: 1, + Entity: key, + PositionAuthorityVersion: record.PositionAuthorityVersion, + SpatialAuthorityVersion: record.SpatialAuthorityVersion, + PlacementCommitVersion: record.PlacementCommitVersion, + SessionLifetimeVersion: + runtime.EntityObjects.Entities.SessionLifetimeVersion, + ExactCellId: record.FullCellId, + CollisionGeneration: 1, + Portal: default); + return new RuntimePlacementProjectionSnapshot( + token, + kind, + position, + orientation, + CellLocalPosition: position, + InContact: false, + OnWalkable: false); + } + private static uint ActionOpcode(byte[] body) => BinaryPrimitives.ReadUInt32LittleEndian( body.AsSpan(8, sizeof(uint))); @@ -687,4 +942,25 @@ public sealed class HeadlessSessionHostTests fullCellId == LastCell; } + private sealed class FixtureEventRoute( + Action? onDispose = null) : ILiveSessionEventRouting + { + public int AttachCount { get; private set; } + public int DisposeCount { get; private set; } + public int DisposeFailuresRemaining { get; set; } + + public void Attach() => AttachCount++; + + public void Dispose() + { + DisposeCount++; + onDispose?.Invoke(); + if (DisposeFailuresRemaining > 0) + { + DisposeFailuresRemaining--; + throw new IOException("fixture route detach failure"); + } + } + } + } From f05ed5c3cd59dfc38c43590638560248cfb25a3b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 15:22:52 +0200 Subject: [PATCH 37/73] feat(app): observe canonical placement receipts --- .../Composition/FrameRootComposition.cs | 3 +- .../LivePresentationComposition.cs | 33 +++++ .../Composition/SessionPlayerComposition.cs | 9 +- .../Net/GraphicalSessionEventRoute.cs | 102 ++++++++++++++ .../Net/LiveSessionRuntimeFactory.cs | 13 +- .../RuntimePlacementProjectionRetrySlot.cs | 87 ++++++++++++ .../World/RetailLiveFrameCoordinator.cs | 13 +- .../World/RuntimePlacementPresentationSink.cs | 6 +- .../RuntimePlacementProjectionSubscription.cs | 32 ++++- .../GameWindowSlice8BoundaryTests.cs | 2 +- .../Runtime/RuntimePhysicsOwnershipTests.cs | 42 +++--- .../RuntimePlacementPresentationSinkTests.cs | 125 ++++++++++++++++++ .../World/UpdateFrameOrchestratorTests.cs | 81 ++++++++++++ ...imePlacementProjectionSubscriptionTests.cs | 28 +++- 14 files changed, 545 insertions(+), 31 deletions(-) create mode 100644 src/AcDream.App/Net/GraphicalSessionEventRoute.cs create mode 100644 src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 7248f712..d661dc3a 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -585,7 +585,8 @@ internal sealed class FrameRootCompositionPhase session.LocalPlayerFrame, session.LiveSpatialReconciler, live.WorldAvailability, - live.RenderSceneShadow?.LiveProjections); + live.RenderSceneShadow?.LiveProjections, + session.PlacementProjectionRetry); var cameraFrame = new CameraFrameController( host.CameraController, d.InputCapture, diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 784b44c4..7a4be2fd 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -96,6 +96,7 @@ internal sealed record LivePresentationResult( GpuWorldState WorldState, RenderSceneShadowRuntime? RenderSceneShadow, LiveEntityRuntime LiveEntities, + RuntimePlacementPresentationSink PlacementProjection, ProjectileController ProjectileController, LiveEntityProjectionWithdrawalController ProjectionWithdrawal, LiveEntityLightController Lights, @@ -451,6 +452,35 @@ internal sealed class LivePresentationCompositionPhase liveEntities, particleVisibility, "particle projection visibility"); + var placementVisibilitySinks = new List< + Action>(3) + { + wbVisibility, + }; + if (liveRenderProjections is not null) + { + placementVisibilitySinks.Add( + liveRenderProjections.OnProjectionVisibilityChanged); + } + placementVisibilitySinks.Add(particleVisibility); + var placementProjection = new RuntimePlacementPresentationSink( + liveEntities, + worldTransit, + d.WorldGameState, + d.WorldEvents, + d.EffectPoses, + d.LocalPlayerShadow, + () => d.PlayerIdentity.ServerGuid, + guid => + { + if (d.Selection.SelectedObjectId == guid) + { + d.Selection.Clear( + SelectionChangeSource.System, + SelectionChangeReason.SelectedObjectRemoved); + } + }, + placementVisibilitySinks); Fault(LivePresentationCompositionPoint.ProjectionVisibilityBound); var projectileController = new ProjectileController( @@ -619,6 +649,7 @@ internal sealed class LivePresentationCompositionPhase renderSceneShadow, renderSceneShadowLease, liveEntities, + placementProjection, projectileController, projectionWithdrawal, lightsLease, @@ -668,6 +699,7 @@ internal sealed class LivePresentationCompositionPhase CompositionAcquisitionScope.CompositionAcquisitionLease< RenderSceneShadowRuntime>? renderSceneShadowLease, LiveEntityRuntime liveEntities, + RuntimePlacementPresentationSink placementProjection, ProjectileController projectileController, LiveEntityProjectionWithdrawalController projectionWithdrawal, CompositionAcquisitionScope.CompositionAcquisitionLease lightsLease, @@ -1147,6 +1179,7 @@ internal sealed class LivePresentationCompositionPhase worldState, renderSceneShadow, liveEntities, + placementProjection, projectileController, projectionWithdrawal, lightsLease.Resource, diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index d7ae0328..2702f7b9 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -122,6 +122,7 @@ internal sealed record SessionPlayerResult( PlayerModeAutoEntry PlayerModeAutoEntry, LocalPlayerTeleportController LocalTeleport, LiveSessionHost SessionHost, + RuntimePlacementProjectionRetrySlot PlacementProjectionRetry, CurrentGameRuntimeAdapter GameRuntime, GameplayInputActionRouter? GameplayActions, SessionPlayerRuntimeBindings RuntimeBindings); @@ -832,6 +833,9 @@ internal sealed class SessionPlayerCompositionPhase AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? vitals = interaction.RetainedUi?.Vitals; + var placementProjectionRetry = + new RuntimePlacementProjectionRetrySlot( + () => d.Runtime.Generation); var sessionRuntimeFactory = new LiveSessionRuntimeFactory( new LiveSessionPlayerRuntime( d.PlayerIdentity, @@ -878,7 +882,9 @@ internal sealed class SessionPlayerCompositionPhase content.AnimationHookFrames, live.Presentation, d.RemoteMovementObservations, - live.RenderSceneShadow), + live.RenderSceneShadow, + live.PlacementProjection, + placementProjectionRetry), liveSessionCommands, d.Log); LiveSessionHost sessionHost = sessionRuntimeFactory.Create( @@ -1015,6 +1021,7 @@ internal sealed class SessionPlayerCompositionPhase playerModeAutoEntry, teleportLease.Resource, sessionHost, + placementProjectionRetry, gameRuntime, gameplayActionsLease?.Resource, bindings); diff --git a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs new file mode 100644 index 00000000..e2411320 --- /dev/null +++ b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs @@ -0,0 +1,102 @@ +using AcDream.Runtime; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; + +namespace AcDream.App.Net; + +/// +/// Owns the inbound route, canonical placement observer, and update-thread +/// retry lease for one exact graphical session generation. +/// +internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting +{ + private readonly ILiveSessionEventRouting _events; + private readonly Func + _createSubscription; + private readonly Func _generation; + private readonly RuntimePlacementProjectionRetrySlot _retries; + private RuntimePlacementProjectionSubscription? _subscription; + private IDisposable? _retryLease; + private bool _attachStarted; + private bool _eventsDisposed; + private bool _disposed; + + internal GraphicalSessionEventRoute( + ILiveSessionEventRouting events, + GameRuntime runtime, + IRuntimePlacementProjectionSink placements, + RuntimePlacementProjectionRetrySlot retries) + : this( + events, + () => new RuntimePlacementProjectionSubscription( + runtime, + placements, + retryPendingOnSubscribe: false), + () => runtime.Generation, + retries) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(placements); + } + + internal GraphicalSessionEventRoute( + ILiveSessionEventRouting events, + Func createSubscription, + Func generation, + RuntimePlacementProjectionRetrySlot retries) + { + _events = events ?? throw new ArgumentNullException(nameof(events)); + _createSubscription = createSubscription + ?? throw new ArgumentNullException(nameof(createSubscription)); + _generation = generation + ?? throw new ArgumentNullException(nameof(generation)); + _retries = retries ?? throw new ArgumentNullException(nameof(retries)); + } + + public void Attach() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_attachStarted) + return; + + _attachStarted = true; + _events.Attach(); + + RuntimePlacementProjectionSubscription? subscription = null; + IDisposable? retryLease = null; + try + { + subscription = _createSubscription(); + retryLease = _retries.BindOwned( + _generation(), + subscription.RetryPending); + _subscription = subscription; + _retryLease = retryLease; + _ = subscription.RetryPending(); + } + catch + { + retryLease?.Dispose(); + subscription?.Dispose(); + throw; + } + } + + public void Dispose() + { + if (_disposed) + return; + + // Unpublish the frame callback before detaching the observer. A frame + // can therefore never retry a retired generation or disposed route. + Interlocked.Exchange(ref _retryLease, null)?.Dispose(); + Interlocked.Exchange(ref _subscription, null)?.Dispose(); + if (!_eventsDisposed) + { + _events.Dispose(); + _eventsDisposed = true; + } + + _disposed = true; + } +} diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index fb683ea5..a300012e 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -83,7 +83,9 @@ internal sealed record LiveSessionWorldRuntime( AnimationHookFrameQueue AnimationHookFrames, LiveEntityPresentationController Presentation, RemoteMovementObservationTracker RemoteMovementObservations, - RenderSceneShadowRuntime? RenderSceneShadow); + RenderSceneShadowRuntime? RenderSceneShadow, + RuntimePlacementPresentationSink PlacementProjection, + RuntimePlacementProjectionRetrySlot PlacementRetries); /// /// Builds the exact per-generation route/reset graph for the canonical live @@ -225,7 +227,7 @@ internal sealed class LiveSessionRuntimeFactory _player.WorldOrigin.Reset(); } - private LiveSessionEventRouter CreateEventRouter(WorldSession session) + private ILiveSessionEventRouting CreateEventRouter(WorldSession session) { SkillTable? skillTable = _world.Dats.Get(0x0E000004u); if (_ui.CharacterSheet is not null) @@ -235,7 +237,7 @@ internal sealed class LiveSessionRuntimeFactory CharacterSheetProvider.LoadExperienceTable(_world.Dats, _log); } - return new LiveSessionEventRouter( + var route = new LiveSessionEventRouter( session, _world.EntitySession.CreateSink(), new LiveEnvironmentSessionSink( @@ -248,6 +250,11 @@ internal sealed class LiveSessionRuntimeFactory _domain.Communication.TurbineChat, _domain.Communication.Friends, _domain.Communication.Squelch)); + return new GraphicalSessionEventRoute( + route, + _domain.Runtime, + _world.PlacementProjection, + _world.PlacementRetries); } private LiveInventorySessionBindings CreateInventoryBindings() => new( diff --git a/src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs b/src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs new file mode 100644 index 00000000..7a2de84d --- /dev/null +++ b/src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs @@ -0,0 +1,87 @@ +using AcDream.Runtime; + +namespace AcDream.App.Net; + +internal interface IRuntimePlacementProjectionRetryPhase +{ + void RetryPending(); +} + +/// +/// Publishes the retry callback owned by the exact active graphical session +/// route. The frame thread may only reach the binding whose Runtime +/// generation is still current; disposing an older lease cannot unbind a +/// replacement route. +/// +internal sealed class RuntimePlacementProjectionRetrySlot + : IRuntimePlacementProjectionRetryPhase +{ + private sealed record Binding( + long Id, + RuntimeGenerationToken Generation, + Func Retry); + + private readonly Func _currentGeneration; + private Binding? _current; + private long _nextBindingId; + + internal RuntimePlacementProjectionRetrySlot( + Func currentGeneration) + { + _currentGeneration = currentGeneration + ?? throw new ArgumentNullException(nameof(currentGeneration)); + } + + internal int BindingCount => _current is null ? 0 : 1; + + internal IDisposable BindOwned( + RuntimeGenerationToken generation, + Func retry) + { + if (generation.Value == 0UL) + { + throw new ArgumentException( + "A placement retry route requires a live Runtime generation.", + nameof(generation)); + } + ArgumentNullException.ThrowIfNull(retry); + if (_current is not null) + { + throw new InvalidOperationException( + "A graphical placement retry route is already bound."); + } + + var binding = new Binding( + checked(++_nextBindingId), + generation, + retry); + _current = binding; + return new DelegateDisposable(() => Unbind(binding)); + } + + public void RetryPending() + { + Binding? binding = _current; + if (binding is null + || binding.Generation != _currentGeneration()) + { + return; + } + + _ = binding.Retry(); + } + + private void Unbind(Binding binding) + { + if (ReferenceEquals(_current, binding)) + _current = null; + } + + private sealed class DelegateDisposable(Action dispose) : IDisposable + { + private Action? _dispose = dispose + ?? throw new ArgumentNullException(nameof(dispose)); + + public void Dispose() => Interlocked.Exchange(ref _dispose, null)?.Invoke(); + } +} diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs index a6b61d94..697ba41c 100644 --- a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs +++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs @@ -27,6 +27,8 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase private readonly ILiveSpatialReconcilePhase _spatialReconciler; private readonly IWorldGenerationAvailability _availability; private readonly IRenderProjectionSyncPhase? _renderProjectionSync; + private readonly IRuntimePlacementProjectionRetryPhase? + _placementProjectionRetry; public RetailLiveFrameCoordinator( ILiveObjectFramePhase objects, @@ -35,7 +37,8 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase IPostNetworkCommandFramePhase localPlayer, ILiveSpatialReconcilePhase spatialReconciler, IWorldGenerationAvailability? availability = null, - IRenderProjectionSyncPhase? renderProjectionSync = null) + IRenderProjectionSyncPhase? renderProjectionSync = null, + IRuntimePlacementProjectionRetryPhase? placementProjectionRetry = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); @@ -45,6 +48,7 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase ?? throw new ArgumentNullException(nameof(spatialReconciler)); _availability = availability ?? AlwaysAvailableWorldGeneration.Instance; _renderProjectionSync = renderProjectionSync; + _placementProjectionRetry = placementProjectionRetry; } public void Tick(float deltaSeconds) @@ -53,7 +57,14 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase if (_availability.IsWorldAvailable) _objects.Tick(frameDelta); using (_worldState.BeginMutationBatch()) + { _session.Tick(); + // Streaming publication precedes this coordinator, while inbound + // network dispatch completes immediately above. A graphical + // receipt that previously lacked its destination backend can now + // retry against both readiness edges on the update thread. + _placementProjectionRetry?.RetryPending(); + } _localPlayer.RunPostNetworkCommandPhase(); if (_availability.IsWorldAvailable) _spatialReconciler.Reconcile(); diff --git a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs index 1bef262f..79e23e9c 100644 --- a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs +++ b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs @@ -14,9 +14,9 @@ namespace AcDream.App.World; /// and renderer/VFX visibility projections. Runtime physics, shadows, body /// state, clocks, and worksets were committed before this sink is invoked. /// -/// This adapter deliberately owns no subscription: production composition -/// activates the shared observer only after both graphical and no-window hosts -/// implement the same presentation-only contract. +/// This adapter deliberately owns no subscription. The exact graphical +/// session route owns the shared Runtime observer and its generation-scoped +/// update-thread retry lease. /// internal sealed class RuntimePlacementPresentationSink : IRuntimePlacementProjectionSink diff --git a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs index bc48214c..622cea4e 100644 --- a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs +++ b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs @@ -34,11 +34,25 @@ public sealed class RuntimePlacementProjectionSubscription public RuntimePlacementProjectionSubscription( GameRuntime runtime, IRuntimePlacementProjectionSink sink) + : this(runtime, sink, retryPendingOnSubscribe: true) + { + } + + /// + /// Subscribes before optionally draining the pending FIFO. A session route + /// which must publish its own disposal/retry ownership first passes + /// false, stores those owners, then calls . + /// + public RuntimePlacementProjectionSubscription( + GameRuntime runtime, + IRuntimePlacementProjectionSink sink, + bool retryPendingOnSubscribe) : this( runtime?.Placements ?? throw new ArgumentNullException(nameof(runtime)), () => runtime.Generation, - sink) + sink, + retryPendingOnSubscribe) { } @@ -46,13 +60,27 @@ public sealed class RuntimePlacementProjectionSubscription RuntimePlacementProjectionChannel channel, Func generation, IRuntimePlacementProjectionSink sink) + : this( + channel, + generation, + sink, + retryPendingOnSubscribe: true) + { + } + + internal RuntimePlacementProjectionSubscription( + RuntimePlacementProjectionChannel channel, + Func generation, + IRuntimePlacementProjectionSink sink, + bool retryPendingOnSubscribe) { _channel = channel ?? throw new ArgumentNullException(nameof(channel)); _generation = generation ?? throw new ArgumentNullException(nameof(generation)); _sink = sink ?? throw new ArgumentNullException(nameof(sink)); _subscription = _channel.Subscribe(this); - _ = RetryPending(); + if (retryPendingOnSubscribe) + _ = RetryPending(); } public bool HasAppliedReceiptAwaitingAcknowledgement => diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 9faeb3e5..0310a39f 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -162,7 +162,7 @@ public sealed class GameWindowSlice8BoundaryTests "LiveSessionRuntimeFactory.cs")); string sessionFactory = Slice( sessionFactorySource, - "private LiveSessionEventRouter CreateEventRouter(", + "private ILiveSessionEventRouting CreateEventRouter(", "private LiveInventorySessionBindings CreateInventoryBindings()"); string worldPhase = File.ReadAllText(Path.Combine( FindRepoRoot(), diff --git a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs index 5ac5862f..861a19ee 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs @@ -5,28 +5,36 @@ namespace AcDream.App.Tests.Runtime; public sealed class RuntimePhysicsOwnershipTests { [Fact] - public void GraphicalPlacementProjectionRemainsDormantUntilCoordinatedCutover() + public void ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel() { string root = FindRepositoryRoot(); - string relative = Path.Combine("src", "AcDream.App"); - foreach (string file in Directory.EnumerateFiles( - Path.Combine(root, relative), - "*.cs", - SearchOption.AllDirectories)) + foreach (string relative in new[] + { + Path.Combine("src", "AcDream.App"), + Path.Combine("src", "AcDream.Headless"), + }) { - string source = File.ReadAllText(file); + string[] sources = Directory.EnumerateFiles( + Path.Combine(root, relative), + "*.cs", + SearchOption.AllDirectories) + .Select(File.ReadAllText) + .ToArray(); Assert.DoesNotContain( - ".Placements.", - source, - StringComparison.Ordinal); + sources, + source => source.Contains( + ".Placements.", + StringComparison.Ordinal)); Assert.DoesNotContain( - "RuntimePlacementProjectionChannel", - source, - StringComparison.Ordinal); - Assert.DoesNotContain( - "RuntimePlacementProjectionSubscription", - source, - StringComparison.Ordinal); + sources, + source => source.Contains( + "RuntimePlacementProjectionChannel", + StringComparison.Ordinal)); + Assert.Contains( + sources, + source => source.Contains( + "RuntimePlacementProjectionSubscription", + StringComparison.Ordinal)); } } diff --git a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs index 18ac4a06..7c6e252f 100644 --- a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.Streaming; using AcDream.App.World; +using AcDream.App.Net; using AcDream.App.Physics; using AcDream.App.Rendering.Vfx; using AcDream.Core.Net; @@ -11,6 +12,7 @@ using AcDream.Core.World; using AcDream.Runtime; using AcDream.Runtime.Entities; using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using AcDream.Runtime.World; using DatReaderWriter.DBObjs; @@ -409,6 +411,108 @@ public sealed class RuntimePlacementPresentationSinkTests Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); } + [Fact] + public void GraphicalRoute_ReentrantInitialDrainTeardownLeavesHeadForReplacement() + { + using var fixture = SubscriptionFixture.Create(); + Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( + fixture.Record.Canonical, + publishWithdrawal: true)); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + + RuntimeGenerationToken generation = fixture.Generation; + var retries = new RuntimePlacementProjectionRetrySlot( + () => generation); + var firstEvents = new RecordingEventRoute(); + GraphicalSessionEventRoute? first = null; + int applications = 0; + var sink = new DelegatePlacementSink( + (in RuntimePlacementProjectionSnapshot projection) => + { + applications++; + bool applied = fixture.Sink.TryApply(in projection); + if (applications == 1) + first!.Dispose(); + return applied; + }); + first = new GraphicalSessionEventRoute( + firstEvents, + () => new RuntimePlacementProjectionSubscription( + fixture.Lifetime.Placements, + () => generation, + sink, + retryPendingOnSubscribe: false), + () => generation, + retries); + + first.Attach(); + + Assert.Equal(1, applications); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.Equal(0, retries.BindingCount); + Assert.Equal(0, fixture.Lifetime.Events.PlacementSubscriberCount); + Assert.Equal(1, firstEvents.DisposeCount); + + var replacementEvents = new RecordingEventRoute(); + var replacement = new GraphicalSessionEventRoute( + replacementEvents, + () => new RuntimePlacementProjectionSubscription( + fixture.Lifetime.Placements, + () => generation, + sink, + retryPendingOnSubscribe: false), + () => generation, + retries); + replacement.Attach(); + + Assert.Equal(2, applications); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.Equal(1, retries.BindingCount); + Assert.Equal(1, fixture.Lifetime.Events.PlacementSubscriberCount); + + replacement.Dispose(); + + Assert.Equal(0, retries.BindingCount); + Assert.Equal(0, fixture.Lifetime.Events.PlacementSubscriberCount); + Assert.Equal(1, replacementEvents.DisposeCount); + } + + [Fact] + public void RetrySlot_InvokesOnlyExactCurrentGenerationBinding() + { + RuntimeGenerationToken generation = new(7UL); + var retries = new RuntimePlacementProjectionRetrySlot( + () => generation); + int firstCalls = 0; + IDisposable first = retries.BindOwned( + generation, + () => + { + firstCalls++; + return true; + }); + + retries.RetryPending(); + generation = new RuntimeGenerationToken(8UL); + retries.RetryPending(); + first.Dispose(); + + int replacementCalls = 0; + using IDisposable replacement = retries.BindOwned( + generation, + () => + { + replacementCalls++; + return true; + }); + first.Dispose(); + retries.RetryPending(); + + Assert.Equal(1, firstCalls); + Assert.Equal(1, replacementCalls); + Assert.Equal(1, retries.BindingCount); + } + private static RuntimePlacementProjectionSnapshot Placement( Fixture fixture, LiveEntityRecord record, @@ -805,6 +909,27 @@ public sealed class RuntimePlacementPresentationSinkTests } } + private sealed class DelegatePlacementSink( + PlacementApply apply) : IRuntimePlacementProjectionSink + { + public bool TryApply( + in RuntimePlacementProjectionSnapshot projection) => + apply(in projection); + } + + private delegate bool PlacementApply( + in RuntimePlacementProjectionSnapshot projection); + + private sealed class RecordingEventRoute : ILiveSessionEventRouting + { + public int AttachCount { get; private set; } + public int DisposeCount { get; private set; } + + public void Attach() => AttachCount++; + + public void Dispose() => DisposeCount++; + } + private sealed class RecordingResources : ILiveEntityResourceLifecycle { public void Register(WorldEntity entity) { } diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 30aeae28..4adcb828 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -1,9 +1,11 @@ using System.Reflection; using AcDream.App.Rendering; +using AcDream.App.Net; using AcDream.App.Streaming; using AcDream.App.Update; using AcDream.App.World; using AcDream.Runtime; +using AcDream.Runtime.Session; using AcDream.Runtime.World; namespace AcDream.App.Tests.World; @@ -387,6 +389,65 @@ public sealed class UpdateFrameOrchestratorTests Assert.Equal(1, CountOccurrences(source, "_scripts.Tick(")); } + [Fact] + public void PlacementRetryRunsAfterStreamingAndInboundBeforeCommandReconcile() + { + string root = FindRepoRoot(); + string outer = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Update", + "UpdateFrameOrchestrator.cs")); + string live = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "World", + "RetailLiveFrameCoordinator.cs")); + + AssertAppearsInOrder( + outer, + "_streaming.Tick();", + "_liveFrame.Tick("); + AssertAppearsInOrder( + live, + "_session.Tick();", + "_placementProjectionRetry?.RetryPending();", + "_localPlayer.RunPostNetworkCommandPhase();", + "_spatialReconciler.Reconcile();"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void LiveFrameRetriesPlacementAfterInboundEvenWhenWorldIsQuiesced( + bool worldAvailable) + { + var calls = new List(); + var phases = new RecordingLivePhases(calls) + { + IsWorldAvailable = worldAvailable, + }; + var frame = new RetailLiveFrameCoordinator( + phases, + new GpuWorldState(), + phases, + phases, + phases, + phases, + phases, + phases); + + frame.Tick(1f / 60f); + + Assert.Equal( + worldAvailable + ? ["objects", "network", "placement-retry", "commands", "reconcile"] + : ["network", "placement-retry", "commands", "render-sync"], + calls); + } + [Fact] public void GameWindow_ComposesTheLiveFrameOwnersWithoutOwningTheirBodies() { @@ -1048,6 +1109,26 @@ public sealed class UpdateFrameOrchestratorTests } } + private sealed class RecordingLivePhases(List calls) : + ILiveObjectFramePhase, + IRuntimeLiveSessionFramePhase, + IPostNetworkCommandFramePhase, + ILiveSpatialReconcilePhase, + IRenderProjectionSyncPhase, + IWorldGenerationAvailability, + IRuntimePlacementProjectionRetryPhase + { + public bool IsWorldAvailable { get; set; } + public long QuiescedGeneration => IsWorldAvailable ? 0L : 1L; + + public void Tick(float deltaSeconds) => calls.Add("objects"); + public void Tick() => calls.Add("network"); + public void RunPostNetworkCommandPhase() => calls.Add("commands"); + public void Reconcile() => calls.Add("reconcile"); + public void SynchronizeActiveSources() => calls.Add("render-sync"); + public void RetryPending() => calls.Add("placement-retry"); + } + private sealed class RecordingCommit(List calls) : IUpdateFrameCommitPhase { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs index 541a0ac3..c3bcd7be 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs @@ -173,6 +173,28 @@ public sealed class RuntimePlacementProjectionSubscriptionTests Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); } + [Fact] + public void SubscribeWithoutDrainPublishesOnlyAfterExplicitRetry() + { + using var fixture = new Fixture(); + RuntimeSetPositionOutcome pending = fixture.Place( + fixture.First, + new Vector3(18.5f, 18f, 7f)); + var sink = new RecordingSink(); + + using var subscription = fixture.Subscribe( + sink, + retryPendingOnSubscribe: false); + + Assert.Empty(sink.Applied); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + + Assert.True(subscription.RetryPending()); + + Assert.Equal(pending.Projection, Assert.Single(sink.Applied).Token); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + [Fact] public void SyntheticWithdrawalUsesTheSameExactSinkAndAckPath() { @@ -263,10 +285,12 @@ public sealed class RuntimePlacementProjectionSubscriptionTests internal RuntimeEntityRecord? Second { get; } internal RuntimePlacementProjectionSubscription Subscribe( - IRuntimePlacementProjectionSink sink) => new( + IRuntimePlacementProjectionSink sink, + bool retryPendingOnSubscribe = true) => new( Lifetime.Placements, () => _generation, - sink); + sink, + retryPendingOnSubscribe); internal RuntimeSetPositionOutcome Place( RuntimeEntityRecord record, From 99bf1751bbb0e6b2cd5b95e08a2f3f42e3a05c5a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 16:05:46 +0200 Subject: [PATCH 38/73] feat(runtime): quiesce collision prefix replacements --- .../Physics/RuntimePhysicsState.cs | 74 +- .../Physics/RuntimeSetPositionState.cs | 826 ++++++++++++- .../RuntimeCollisionPrefixQuiescenceTests.cs | 1037 +++++++++++++++++ 3 files changed, 1897 insertions(+), 40 deletions(-) create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 67fd2f26..7b3c2e06 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -35,6 +35,8 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( int PendingCollisionSetPositionDispatchCount, int PendingShadowSetPositionDispatchCount, bool IsCollisionReportDispatching, + int CollisionPrefixQuiescenceCount, + int PendingCollisionPrefixProjectionCount, int CollisionAdmissionCount, int CollisionGenerationCount, bool OwnsProductionDataCache, @@ -71,6 +73,8 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( && PendingCollisionSetPositionDispatchCount == 0 && PendingShadowSetPositionDispatchCount == 0 && !IsCollisionReportDispatching + && CollisionPrefixQuiescenceCount == 0 + && PendingCollisionPrefixProjectionCount == 0 && CollisionAdmissionCount == 0 && CollisionGenerationCount == 0 && OwnsProductionDataCache; @@ -1169,6 +1173,8 @@ public sealed class RuntimePhysicsState : IDisposable collisionReports.PendingSetPositionDispatchCount, Engine.ShadowObjects.PendingSetPositionDispatchCount, collisionReports.IsDispatching, + setPosition.CollisionPrefixQuiescenceCount, + setPosition.PendingQuiescenceProjectionCount, _collisionAdmissions.Count, _collisionGenerations.Count, ReferenceEquals(Engine.DataCache, DataCache), @@ -1901,6 +1907,64 @@ public sealed class RuntimePhysicsState : IDisposable _spatialRoots.Clear(); } + internal RuntimeCollisionPrefixQuiescenceToken + BeginCollisionPrefixQuiescence( + uint landblockId, + ulong collisionGeneration, + bool includeOutdoorCells) + { + EnsureNotDisposed(); + EnsureCollisionMutationThread(); + uint canonical = CanonicalLandblock(landblockId); + if (canonical == 0u) + throw new ArgumentOutOfRangeException(nameof(landblockId)); + return SetPosition.BeginCollisionPrefixQuiescence( + canonical, + collisionGeneration, + includeOutdoorCells); + } + + internal bool TryAcquireCollisionPrefixMutationPermission( + in RuntimeCollisionPrefixQuiescenceToken token, + out RuntimeCollisionPrefixMutationPermission permission) + { + EnsureNotDisposed(); + EnsureCollisionMutationThread(); + return SetPosition.TryAcquireCollisionPrefixMutationPermission( + token, + out permission); + } + + internal bool IsCollisionPrefixMutationPermissionCurrent( + in RuntimeCollisionPrefixMutationPermission permission) + { + EnsureNotDisposed(); + EnsureCollisionMutationThread(); + return SetPosition.IsCollisionPrefixMutationPermissionCurrent( + permission); + } + + internal bool CancelCollisionPrefixQuiescence( + in RuntimeCollisionPrefixQuiescenceToken token, + ulong successorGeneration = 0UL, + bool successorReady = false) + { + EnsureNotDisposed(); + EnsureCollisionMutationThread(); + return SetPosition.CancelCollisionPrefixQuiescence( + token, + successorGeneration, + successorReady); + } + + internal bool CompleteCollisionPrefixQuiescence( + in RuntimeCollisionPrefixMutationPermission permission) + { + EnsureNotDisposed(); + EnsureCollisionMutationThread(); + return SetPosition.CompleteCollisionPrefixQuiescence(permission); + } + public RuntimeCollisionAdmission BeginCollisionAdmission( uint landblockId) { @@ -2598,6 +2662,9 @@ public sealed class RuntimePhysicsState : IDisposable private void AdvanceCollisionWorldAuthority() => _collisionWorldAuthority = checked(_collisionWorldAuthority + 1UL); + internal void AdvanceCollisionQuiescenceAuthority() => + AdvanceCollisionWorldAuthority(); + internal bool TrySealCollisionEvaluationAuthority( in PhysicsSetPositionResult result, ulong expectedCollisionWorldAuthority, @@ -2639,7 +2706,8 @@ public sealed class RuntimePhysicsState : IDisposable } foreach (uint prefix in prefixes) { - if (_collisionAdmissions.ContainsKey(prefix)) + if (_collisionAdmissions.ContainsKey(prefix) + || SetPosition.IsCollisionPrefixQuiescing(prefix)) return false; } @@ -2680,6 +2748,8 @@ public sealed class RuntimePhysicsState : IDisposable { if (generation.LandblockId == 0u || _collisionAdmissions.ContainsKey(generation.LandblockId) + || SetPosition.IsCollisionPrefixQuiescing( + generation.LandblockId) || CollisionGenerationAuthority(generation.LandblockId) != generation.Generation) { @@ -2706,6 +2776,8 @@ public sealed class RuntimePhysicsState : IDisposable { if (generation.LandblockId == 0u || _collisionAdmissions.ContainsKey(generation.LandblockId) + || SetPosition.IsCollisionPrefixQuiescing( + generation.LandblockId) || CollisionGenerationAuthority(generation.LandblockId) != generation.Generation) { diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index dbe197f2..f651eb92 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -30,6 +30,7 @@ internal enum RuntimeEntityPlacementStage AwaitingPreparation, AwaitingWithdrawalAcknowledgement, AwaitingCell, + QuiescenceHeld, AwaitingFinalShadowPreparation, AwaitingCommitAcknowledgement, CancelledAwaitingAcknowledgement, @@ -89,6 +90,26 @@ internal readonly record struct RuntimeCollisionGenerationAuthority( uint LandblockId, ulong Generation); +internal readonly record struct RuntimeCollisionPrefixQuiescenceToken( + ulong SessionLifetimeVersion, + uint LandblockPrefix, + ulong CollisionGeneration, + ulong OperationId) +{ + internal bool IsValid => LandblockPrefix != 0u + && (LandblockPrefix & 0xFFFFu) == 0u + && CollisionGeneration != 0UL + && OperationId != 0UL; +} + +internal readonly record struct RuntimeCollisionPrefixMutationPermission( + RuntimeCollisionPrefixQuiescenceToken Quiescence, + ImmutableArray Withdrawals) +{ + internal bool IsValid => Quiescence.IsValid + && !Withdrawals.IsDefault; +} + internal readonly record struct RuntimeCollisionEvaluationAuthority( ulong CollisionWorldAuthority, ulong ShadowWorldAuthority, @@ -229,7 +250,9 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot( int UnboundDeferredCellCount, int UnboundDeferredCellOrderCount, int PreparedMoverCount, - int MoverPreparationAuthorityCount) + int MoverPreparationAuthorityCount, + int CollisionPrefixQuiescenceCount, + int PendingQuiescenceProjectionCount) { internal bool IndexesConsistent => LostDeadlineCount == LostDeadlineNodeCount @@ -253,7 +276,9 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot( && UnboundDeferredCellCount == 0 && UnboundDeferredCellOrderCount == 0 && PreparedMoverCount == 0 - && MoverPreparationAuthorityCount == 0; + && MoverPreparationAuthorityCount == 0 + && CollisionPrefixQuiescenceCount == 0 + && PendingQuiescenceProjectionCount == 0; } /// @@ -273,8 +298,13 @@ internal sealed class RuntimeSetPositionState : IDisposable private readonly record struct CellGenerationKey( uint CellId, + uint CollisionPrefix, ulong CollisionGeneration); + private readonly record struct UnboundCellKey( + uint CellId, + uint CollisionPrefix); + private readonly record struct MoverPreparationAuthority( ulong OperationId, CreateObject.ServerPosition AcceptedPosition, @@ -307,8 +337,10 @@ internal sealed class RuntimeSetPositionState : IDisposable internal ulong PlacementCommitVersion { get; set; } internal uint ExactCellId { get; set; } internal ulong CollisionGeneration { get; set; } + internal uint CollisionPrefix { get; set; } internal bool WithdrawalAcknowledged { get; set; } internal bool CollisionGenerationReady { get; set; } + internal bool CollisionQuiescenceHeld { get; set; } internal ulong ProjectionSequence { get; set; } internal bool WakeableLostCell { get; set; } internal RuntimeEntityPlacementStage Stage { get; set; } @@ -327,6 +359,24 @@ internal sealed class RuntimeSetPositionState : IDisposable } } + private sealed class CollisionPrefixQuiescence + { + internal required RuntimeCollisionPrefixQuiescenceToken Token + { get; init; } + internal required bool IncludeOutdoorCells { get; init; } + internal required ulong ProjectionBarrierSequence { get; init; } + internal SortedDictionary + PendingWithdrawals { get; } = []; + internal SortedDictionary + PendingRestorePlacements { get; } = []; + internal List RetainedWithdrawals + { get; } = []; + internal bool ResidentsParked { get; set; } + internal bool PermissionIssued { get; set; } + internal bool AbortReleaseInProgress { get; set; } + internal ulong AbortRestoreGeneration { get; set; } + } + private sealed class ContactCommitGuard( RuntimeSetPositionState owner, Operation operation, @@ -354,9 +404,9 @@ internal sealed class RuntimeSetPositionState : IDisposable private SortedDictionary _pendingProjection = []; private readonly List _deferredBucketOrder = []; - private readonly Dictionary> + private readonly Dictionary> _unboundDeferredByCell = []; - private readonly List _unboundDeferredCellOrder = []; + private readonly List _unboundDeferredCellOrder = []; private readonly Dictionary _lostDeadlines = []; private readonly List _lostDeadlineNodes = []; private readonly Dictionary @@ -365,11 +415,14 @@ internal sealed class RuntimeSetPositionState : IDisposable _preparedMovers = []; private readonly Dictionary _moverPreparationAuthorities = []; + private readonly Dictionary + _collisionPrefixQuiescence = []; private readonly LinkedList _expiredLostCells = []; private readonly Dictionary> _expiredLostCellNodes = []; private ulong _nextProjectionSequence; private ulong _nextOperationId; + private ulong _nextCollisionPrefixQuiescenceOperationId; private ulong _nextLostDeadlineSequence; private RuntimeEntityObjectEventStream? _events; private bool _disposed; @@ -401,6 +454,14 @@ internal sealed class RuntimeSetPositionState : IDisposable if (operation.WakeableLostCell) deferred++; } + int pendingQuiescenceProjections = 0; + foreach (CollisionPrefixQuiescence quiescence + in _collisionPrefixQuiescence.Values) + { + pendingQuiescenceProjections += + quiescence.PendingWithdrawals.Count + + quiescence.PendingRestorePlacements.Count; + } return new RuntimeSetPositionOwnershipSnapshot( _operations.Count, awaitingPreparation, @@ -416,11 +477,217 @@ internal sealed class RuntimeSetPositionState : IDisposable _unboundDeferredByCell.Count, _unboundDeferredCellOrder.Count, _preparedMovers.Count, - _moverPreparationAuthorities.Count); + _moverPreparationAuthorities.Count, + _collisionPrefixQuiescence.Count, + pendingQuiescenceProjections); } internal int PendingProjectionCount => _pendingProjection.Count; + internal bool IsCollisionPrefixQuiescing(uint landblockId) => + _collisionPrefixQuiescence.ContainsKey( + landblockId & 0xFFFF0000u); + + internal RuntimeCollisionPrefixQuiescenceToken + BeginCollisionPrefixQuiescence( + uint landblockId, + ulong collisionGeneration, + bool includeOutdoorCells) + { + EnsureNotDisposed(); + if (collisionGeneration == 0UL) + throw new ArgumentOutOfRangeException(nameof(collisionGeneration)); + uint prefix = landblockId & 0xFFFF0000u; + if (prefix == 0u) + throw new ArgumentOutOfRangeException(nameof(landblockId)); + + if (_collisionPrefixQuiescence.TryGetValue( + prefix, + out CollisionPrefixQuiescence? active) + && active.AbortReleaseInProgress) + { + throw new InvalidOperationException( + $"Collision quiescence 0x{prefix:X8}/{active.Token.OperationId} is restoring its retained generation."); + } + _collisionPrefixQuiescence.Remove( + prefix, + out CollisionPrefixQuiescence? superseded); + + var token = new RuntimeCollisionPrefixQuiescenceToken( + _entities.SessionLifetimeVersion, + prefix, + collisionGeneration, + checked(++_nextCollisionPrefixQuiescenceOperationId)); + var replacement = new CollisionPrefixQuiescence + { + Token = token, + IncludeOutdoorCells = includeOutdoorCells, + ProjectionBarrierSequence = superseded is null + ? _nextProjectionSequence + : Math.Max( + _nextProjectionSequence, + superseded.ProjectionBarrierSequence), + ResidentsParked = superseded?.ResidentsParked ?? false, + }; + if (superseded is not null) + { + foreach ((ulong sequence, RuntimePlacementProjectionToken pending) + in superseded.PendingWithdrawals) + replacement.PendingWithdrawals.Add(sequence, pending); + replacement.RetainedWithdrawals.AddRange( + superseded.RetainedWithdrawals); + } + _collisionPrefixQuiescence.Add(prefix, replacement); + if (superseded is not null) + { + RebindQuiescedDeferredOperations( + superseded.Token, + collisionGeneration, + ready: false); + } + _physics.AdvanceCollisionQuiescenceAuthority(); + return token; + } + + internal bool TryAcquireCollisionPrefixMutationPermission( + in RuntimeCollisionPrefixQuiescenceToken token, + out RuntimeCollisionPrefixMutationPermission permission) + { + EnsureNotDisposed(); + permission = default; + if (!TryGetCurrentQuiescence(token, out CollisionPrefixQuiescence? state)) + return false; + CollisionPrefixQuiescence current = state!; + + if (HasPendingProjectionThrough(current.ProjectionBarrierSequence)) + return false; + if (HasOldPrefixPlacementDebt(current)) + return false; + + if (!current.ResidentsParked) + { + ParkCollisionResidentsForQuiescence(current); + current.ResidentsParked = true; + return false; + } + else if (HasAffectedCollisionResident( + token.LandblockPrefix, + current.IncludeOutdoorCells)) + { + ParkCollisionResidentsForQuiescence(current); + return false; + } + + RemoveRetiredQuiescenceWithdrawals(current); + if (current.PendingWithdrawals.Count != 0 + || HasAffectedCollisionResident( + token.LandblockPrefix, + current.IncludeOutdoorCells) + || HasOldPrefixPlacementDebt(current) + || HasCollisionDispatchDebt()) + { + return false; + } + + current.PermissionIssued = true; + permission = new RuntimeCollisionPrefixMutationPermission( + current.Token, + current.RetainedWithdrawals.ToImmutableArray()); + return true; + } + + internal bool IsCollisionPrefixMutationPermissionCurrent( + in RuntimeCollisionPrefixMutationPermission permission) + { + EnsureNotDisposed(); + return permission.IsValid + && TryGetCurrentQuiescence( + permission.Quiescence, + out CollisionPrefixQuiescence? state) + && state!.PermissionIssued + && state.PendingWithdrawals.Count == 0 + && !HasAffectedCollisionResident( + state.Token.LandblockPrefix, + state.IncludeOutdoorCells) + && !HasOldPrefixPlacementDebt(state) + && !HasCollisionDispatchDebt(); + } + + internal bool CancelCollisionPrefixQuiescence( + in RuntimeCollisionPrefixQuiescenceToken token, + ulong successorGeneration = 0UL, + bool successorReady = false) + { + EnsureNotDisposed(); + if (!TryGetCurrentQuiescence(token, out CollisionPrefixQuiescence? state)) + return false; + + CollisionPrefixQuiescence current = state!; + if (!current.ResidentsParked + && current.PendingWithdrawals.Count == 0 + && current.PendingRestorePlacements.Count == 0 + && !HasQuiescedDeferredOperations(token.LandblockPrefix)) + { + bool removedBeforePark = _collisionPrefixQuiescence.Remove( + token.LandblockPrefix); + if (removedBeforePark) + _physics.AdvanceCollisionQuiescenceAuthority(); + return removedBeforePark; + } + + if (successorGeneration == 0UL + || !successorReady + || current.PendingWithdrawals.Count != 0) + { + return false; + } + + if (!current.AbortReleaseInProgress) + { + current.AbortReleaseInProgress = true; + current.AbortRestoreGeneration = successorGeneration; + current.PermissionIssued = false; + RebindQuiescedDeferredOperations( + token, + successorGeneration, + ready: true); + } + else if (current.AbortRestoreGeneration != successorGeneration) + { + return false; + } + + // A re-entrant/network placement may have joined the still-closed + // prefix after abort release started. Transfer every exact newcomer + // on each poll before deciding the barrier can open. + RebindQuiescedDeferredOperations( + token, + successorGeneration, + ready: true); + + if (current.PendingRestorePlacements.Count != 0 + || HasQuiescedDeferredOperations(token.LandblockPrefix)) + return false; + bool removed = _collisionPrefixQuiescence.Remove( + token.LandblockPrefix); + if (removed) + _physics.AdvanceCollisionQuiescenceAuthority(); + return removed; + } + + internal bool CompleteCollisionPrefixQuiescence( + in RuntimeCollisionPrefixMutationPermission permission) + { + EnsureNotDisposed(); + if (!IsCollisionPrefixMutationPermissionCurrent(permission)) + return false; + bool removed = _collisionPrefixQuiescence.Remove( + permission.Quiescence.LandblockPrefix); + if (removed) + _physics.AdvanceCollisionQuiescenceAuthority(); + return removed; + } + internal void BindEventStream(RuntimeEntityObjectEventStream events) { EnsureNotDisposed(); @@ -923,6 +1190,7 @@ internal sealed class RuntimeSetPositionState : IDisposable { var bucketKey = new CellGenerationKey( result.CellId, + result.CellId & 0xFFFF0000u, deferredCollisionGeneration); if (_deferredByCellGeneration.TryGetValue( bucketKey, @@ -1064,6 +1332,7 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.WakeableLostCell = true; operation.CollisionGeneration = prepared .DeferredCollisionGeneration; + operation.CollisionPrefix = result.CellId & 0xFFFF0000u; operation.CollisionGenerationReady = false; operation.Stage = RuntimeEntityPlacementStage.AwaitingCell; _preparedMovers[operation.Key] = prepared.Evaluation.Command.Physics; @@ -1071,6 +1340,7 @@ internal sealed class RuntimeSetPositionState : IDisposable { var bucketKey = new CellGenerationKey( result.CellId, + operation.CollisionPrefix, prepared.DeferredCollisionGeneration); if (prepared.DeferredBucketIsNew) { @@ -1712,6 +1982,41 @@ internal sealed class RuntimeSetPositionState : IDisposable return Outcome(RuntimeSetPositionStatus.Rejected, invalid, default); } + if (TryGetBlockingQuiescence( + canonicalRequest, + out CollisionPrefixQuiescence? quiescence)) + { + var deferred = new PhysicsSetPositionResult( + PhysicsSetPositionError.Ok, + PhysicsResidenceDisposition.DeferredCell, + canonicalRequest.Position, + canonicalRequest.Orientation, + canonicalRequest.CellId, + canonicalRequest.CellLocalPosition, + InContact: operation.PreviousContact, + OnWalkable: operation.PreviousOnWalkable, + ContactPlane: body.ContactPlane, + ContactPlaneCellId: body.ContactPlaneCellId, + ContactPlaneIsWater: body.ContactPlaneIsWater, + SlidingNormalValid: body.SlidingNormal != Vector3.Zero, + SlidingNormal: body.SlidingNormal, + FramesStationaryFall: body.FramesStationaryFall, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: ImmutableArray.Empty, + QueriedCellIds: ImmutableArray.Empty); + operation.Result = deferred; + operation.RequiresPreparation = false; + operation.ExactCellId = deferred.CellId; + _preparedMovers[operation.Key] = canonicalRequest; + return ParkDeferred( + operation, + deferred, + collisionGenerationOverride: + quiescence!.Token.CollisionGeneration, + collisionPrefixOverride: + quiescence.Token.LandblockPrefix); + } + PhysicsSetPositionResult result = _physics.Engine.SetPosition( canonicalRequest, @@ -1726,6 +2031,27 @@ internal sealed class RuntimeSetPositionState : IDisposable report)); if (!IsCurrent(operation)) return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); + if (result.IsSuccessful + && TryGetBlockingQuiescence( + result, + out CollisionPrefixQuiescence? queriedQuiescence)) + { + PhysicsSetPositionResult held = result with + { + Residence = PhysicsResidenceDisposition.DeferredCell, + }; + operation.Result = held; + operation.RequiresPreparation = false; + operation.ExactCellId = held.CellId; + _preparedMovers[operation.Key] = canonicalRequest; + return ParkDeferred( + operation, + held, + collisionGenerationOverride: + queriedQuiescence!.Token.CollisionGeneration, + collisionPrefixOverride: + queriedQuiescence.Token.LandblockPrefix); + } operation.Result = result; if (!result.IsSuccessful) { @@ -1791,6 +2117,7 @@ internal sealed class RuntimeSetPositionState : IDisposable if (pending.Kind is RuntimePlacementProjectionKind.Discard) { _pendingProjection.Remove(token.Sequence); + RetireQuiescenceProjectionSequence(token.Sequence); return true; } if (!_operations.TryGetValue( @@ -1805,6 +2132,7 @@ internal sealed class RuntimeSetPositionState : IDisposable } _pendingProjection.Remove(token.Sequence); + RetireQuiescenceProjectionSequence(token.Sequence); operation.ProjectionSequence = 0UL; if (pending.Kind is RuntimePlacementProjectionKind.Place) { @@ -1823,7 +2151,9 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.Stage = operation.RequiresPreparation || operation.InheritedLostDeadline ? RuntimeEntityPlacementStage.AwaitingPreparation - : RuntimeEntityPlacementStage.AwaitingCell; + : operation.CollisionQuiescenceHeld + ? RuntimeEntityPlacementStage.QuiescenceHeld + : RuntimeEntityPlacementStage.AwaitingCell; if (operation.PreparedCommandAwaitingWithdrawalAck is { } prepared) { operation.PreparedCommandAwaitingWithdrawalAck = null; @@ -2004,20 +2334,26 @@ internal sealed class RuntimeSetPositionState : IDisposable uint prefix = landblockId & 0xFFFF0000u; var roots = new List(); _physics.CopySpatialRootsTo(roots); - for (int index = 0; index < roots.Count; index++) + RuntimeEntityRecord[] affected = roots + .Where(record => IsAffectedCollisionResident( + record, + prefix, + includeOutdoorCells)) + .ToArray(); + for (int index = 0; index < affected.Length; index++) { - RuntimeEntityRecord record = roots[index]; - if (IsAffectedCollisionResident( - record, - prefix, - includeOutdoorCells) - && record.Key is { } key + if (affected[index].Key is { } key && _operations.ContainsKey(key)) { throw new InvalidOperationException( - $"Collision retirement for 0x{prefix:X8} cannot overlap active placement for 0x{record.ServerGuid:X8}/{record.Incarnation}."); + $"Collision retirement for 0x{prefix:X8} cannot overlap active placement for 0x{affected[index].ServerGuid:X8}/{affected[index].Incarnation}."); } } + // Close every exact collision-report owner before the first force-end + // callback can re-enter. The prefix admission barrier is already in + // place for transactional callers, so a callback cannot evaluate a + // new placement against the retiring rows. + _physics.CollisionReports.LeaveWorldBatch(affected); var stagedWithdrawals = new List( roots.Count); for (int index = 0; index < roots.Count; index++) @@ -2029,12 +2365,15 @@ internal sealed class RuntimeSetPositionState : IDisposable prefix, includeOutdoorCells) || record.Key is not { } key - || record.PhysicsBody is not { } body) + || record.PhysicsBody is not { } body + || _operations.ContainsKey(key) + || !affected.Any(candidate => + ReferenceEquals(candidate, record) + && candidate.Key == key)) { continue; } - PublishCancellation(CancelCore(key)); bool hasPrepared = _preparedMovers.TryGetValue( key, out PhysicsSetPositionRequest prepared); @@ -2116,10 +2455,16 @@ internal sealed class RuntimeSetPositionState : IDisposable body.Orientation), prepared: hasPrepared, command); + CollisionPrefixQuiescence? quiescence = + _collisionPrefixQuiescence.GetValueOrDefault(prefix); RuntimeSetPositionOutcome parked = ParkDeferred( operation, result, - publishImmediately: false); + publishImmediately: false, + collisionGenerationOverride: + quiescence?.Token.CollisionGeneration ?? 0UL, + collisionPrefixOverride: + quiescence?.Token.LandblockPrefix ?? 0u); if (_pendingProjection.TryGetValue( parked.Projection.Sequence, out RuntimePlacementProjectionSnapshot staged)) @@ -2157,13 +2502,289 @@ internal sealed class RuntimeSetPositionState : IDisposable bool includeOutdoorCells) { uint cellId = record.FullCellId; - return (cellId & 0xFFFF0000u) == prefix - && (includeOutdoorCells || (cellId & 0xFFFFu) >= 0x0100u) + bool exactResidence = (cellId & 0xFFFF0000u) == prefix + && (includeOutdoorCells || (cellId & 0xFFFFu) >= 0x0100u); + return exactResidence + && record.Key is not null + && _entities.IsCurrent(record) + && record.PhysicsBody is not null + && _physics.IsSpatialRoot(record) && (record.FinalPhysicsState & PhysicsStateFlags.Static) == 0 && !_entities.ParentAttachments.HasCommittedParent( record.ServerGuid); } + private void ParkCollisionResidentsForQuiescence( + CollisionPrefixQuiescence state) + { + if (!TryGetCurrentQuiescence(state.Token, out CollisionPrefixQuiescence? current) + || !ReferenceEquals(current, state)) + { + return; + } + ParkCollisionResidents( + state.Token.LandblockPrefix, + state.IncludeOutdoorCells); + } + + private bool HasAffectedCollisionResident( + uint prefix, + bool includeOutdoorCells) + { + var roots = new List(); + _physics.CopySpatialRootsTo(roots); + for (int index = 0; index < roots.Count; index++) + { + if (IsAffectedCollisionResident( + roots[index], + prefix, + includeOutdoorCells)) + { + return true; + } + } + return false; + } + + private bool TryGetCurrentQuiescence( + in RuntimeCollisionPrefixQuiescenceToken token, + out CollisionPrefixQuiescence? state) + { + if (token.IsValid + && token.SessionLifetimeVersion == _entities.SessionLifetimeVersion + && _collisionPrefixQuiescence.TryGetValue( + token.LandblockPrefix, + out state) + && state.Token == token) + { + return true; + } + state = null; + return false; + } + + private bool HasPendingProjectionThrough(ulong barrierSequence) => + barrierSequence != 0UL + && _pendingProjection.Count != 0 + && _pendingProjection.First().Key <= barrierSequence; + + private bool HasCollisionDispatchDebt() + { + RuntimeCollisionReportingOwnershipSnapshot reports = + _physics.CollisionReports.CaptureOwnership(); + return reports.PendingReportCount != 0 + || reports.LeavingOwnerCount != 0 + || reports.AdmissionBlockedOwnerCount != 0 + || reports.PendingSetPositionDispatchCount != 0 + || reports.IsDispatching + || _physics.Engine.ShadowObjects + .PendingSetPositionDispatchCount != 0; + } + + private bool HasOldPrefixPlacementDebt(CollisionPrefixQuiescence state) + { + uint prefix = state.Token.LandblockPrefix; + foreach (Operation operation in _operations.Values) + { + if (operation.WakeableLostCell) + continue; + if (PlacementTouchesPrefix(operation.Command.Physics, prefix) + || ResultTouchesPrefix(operation.Result, prefix) + || _moverPreparationAuthorities.TryGetValue( + operation.Key, + out MoverPreparationAuthority preparation) + && preparation.OperationId + == operation.Token.OperationId + && (preparation.AcceptedPosition.LandblockId + & 0xFFFF0000u) == prefix + || IsAffectedCollisionResident( + operation.Record, + prefix, + state.IncludeOutdoorCells)) + { + return true; + } + } + return false; + } + + private static bool PlacementTouchesPrefix( + in PhysicsSetPositionRequest request, + uint prefix) => + (request.CellId & 0xFFFF0000u) == prefix + || request.CurrentCellId is uint current + && (current & 0xFFFF0000u) == prefix; + + private static bool ResultTouchesPrefix( + in PhysicsSetPositionResult result, + uint prefix) + { + if ((result.CellId & 0xFFFF0000u) == prefix) + return true; + if (!result.QueriedCellIds.IsDefaultOrEmpty) + { + foreach (uint cellId in result.QueriedCellIds) + { + if ((cellId & 0xFFFF0000u) == prefix) + return true; + } + } + return false; + } + + private bool TryGetBlockingQuiescence( + in PhysicsSetPositionRequest request, + out CollisionPrefixQuiescence? state) + { + state = null; + foreach (CollisionPrefixQuiescence candidate + in _collisionPrefixQuiescence.Values) + { + if (PlacementTouchesPrefix( + request, + candidate.Token.LandblockPrefix) + && (state is null + || candidate.Token.OperationId + < state.Token.OperationId)) + { + state = candidate; + } + } + return state is not null; + } + + private bool TryGetBlockingQuiescence( + in PhysicsSetPositionResult result, + out CollisionPrefixQuiescence? state, + in RuntimeCollisionPrefixQuiescenceToken excluded = default) + { + state = null; + foreach (CollisionPrefixQuiescence candidate + in _collisionPrefixQuiescence.Values) + { + if (excluded.IsValid && candidate.Token == excluded) + continue; + if (ResultTouchesPrefix( + result, + candidate.Token.LandblockPrefix) + && (state is null + || candidate.Token.OperationId + < state.Token.OperationId)) + { + state = candidate; + } + } + return state is not null; + } + + private void TrackQuiescenceWithdrawal( + Operation operation, + in RuntimePlacementProjectionSnapshot snapshot) + { + if (snapshot.Kind is not RuntimePlacementProjectionKind.Withdraw + || !_collisionPrefixQuiescence.TryGetValue( + operation.CollisionPrefix, + out CollisionPrefixQuiescence? state) + || snapshot.Token.CollisionGeneration + != state.Token.CollisionGeneration) + { + return; + } + state.PendingWithdrawals[snapshot.Token.Sequence] = snapshot.Token; + state.RetainedWithdrawals.Add(snapshot.Token); + state.PermissionIssued = false; + } + + private void TrackQuiescenceRestorePlacement( + Operation operation, + in RuntimePlacementProjectionSnapshot snapshot) + { + if (snapshot.Kind is not RuntimePlacementProjectionKind.Place + || !_collisionPrefixQuiescence.TryGetValue( + operation.CollisionPrefix, + out CollisionPrefixQuiescence? state) + || !state.AbortReleaseInProgress) + { + return; + } + state.PendingRestorePlacements[snapshot.Token.Sequence] = + snapshot.Token; + } + + private void RetireQuiescenceProjectionSequence(ulong sequence) + { + foreach (CollisionPrefixQuiescence state + in _collisionPrefixQuiescence.Values) + { + if (state.PendingWithdrawals.TryGetValue( + sequence, + out _)) + { + state.PendingWithdrawals.Remove(sequence); + state.PermissionIssued = false; + return; + } + if (state.PendingRestorePlacements.Remove(sequence)) + return; + } + } + + private void RemoveRetiredQuiescenceWithdrawals( + CollisionPrefixQuiescence state) + { + if (state.PendingWithdrawals.Count == 0) + return; + ulong[] stale = state.PendingWithdrawals + .Where(pair => !_pendingProjection.ContainsKey(pair.Key)) + .Select(pair => pair.Key) + .ToArray(); + for (int index = 0; index < stale.Length; index++) + state.PendingWithdrawals.Remove(stale[index]); + } + + private void RebindQuiescedDeferredOperations( + in RuntimeCollisionPrefixQuiescenceToken token, + ulong successorGeneration, + bool ready) + { + foreach (Operation operation in _operations.Values.ToArray()) + { + if (!operation.WakeableLostCell + || operation.CollisionGeneration != token.CollisionGeneration + || operation.CollisionPrefix != token.LandblockPrefix) + { + continue; + } + UnindexDeferred(operation); + operation.CollisionGeneration = successorGeneration; + operation.CollisionGenerationReady = ready + && successorGeneration != 0UL; + if (successorGeneration != 0UL) + IndexDeferred(operation); + else + IndexUnboundDeferred(operation); + if (operation.CollisionGenerationReady + && operation.WithdrawalAcknowledged) + { + RetryDeferred(operation); + } + } + } + + private bool HasQuiescedDeferredOperations(uint prefix) + { + foreach (Operation operation in _operations.Values) + { + if (operation.WakeableLostCell + && operation.CollisionQuiescenceHeld + && operation.CollisionPrefix == prefix) + { + return true; + } + } + return false; + } + internal void BeginCollisionGeneration(uint landblockId, ulong generation) { EnsureNotDisposed(); @@ -2172,17 +2793,20 @@ internal sealed class RuntimeSetPositionState : IDisposable uint prefix = landblockId & 0xFFFF0000u; for (int index = 0; index < _unboundDeferredCellOrder.Count;) { - uint cellId = _unboundDeferredCellOrder[index]; - if ((cellId & 0xFFFF0000u) != prefix + UnboundCellKey unboundKey = _unboundDeferredCellOrder[index]; + if (unboundKey.CollisionPrefix != prefix || !_unboundDeferredByCell.Remove( - cellId, + unboundKey, out List? retained)) { index++; continue; } _unboundDeferredCellOrder.RemoveAt(index); - var bucket = new CellGenerationKey(cellId, generation); + var bucket = new CellGenerationKey( + unboundKey.CellId, + unboundKey.CollisionPrefix, + generation); var rebound = new List(retained.Count); for (int entityIndex = 0; entityIndex < retained.Count; @@ -2191,7 +2815,8 @@ internal sealed class RuntimeSetPositionState : IDisposable RuntimeEntityKey entity = retained[entityIndex]; if (_operations.TryGetValue(entity, out Operation? operation) && operation.WakeableLostCell - && operation.ExactCellId == cellId + && operation.ExactCellId == unboundKey.CellId + && operation.CollisionPrefix == prefix && operation.CollisionGeneration == 0UL) { operation.CollisionGeneration = generation; @@ -2224,7 +2849,7 @@ internal sealed class RuntimeSetPositionState : IDisposable { if (!operation.WakeableLostCell || operation.CollisionGeneration != 0UL - || (operation.ExactCellId & 0xFFFF0000u) != prefix) + || operation.CollisionPrefix != prefix) { continue; } @@ -2241,7 +2866,7 @@ internal sealed class RuntimeSetPositionState : IDisposable uint prefix = landblockId & 0xFFFF0000u; CellGenerationKey[] matching = _deferredBucketOrder .Where(key => key.CollisionGeneration == generation - && (key.CellId & 0xFFFF0000u) == prefix) + && key.CollisionPrefix == prefix) .ToArray(); for (int index = 0; index < matching.Length; index++) { @@ -2264,7 +2889,7 @@ internal sealed class RuntimeSetPositionState : IDisposable { CellGenerationKey key = _deferredBucketOrder[index]; if (key.CollisionGeneration == generation - && (key.CellId & 0xFFFF0000u) == prefix) + && key.CollisionPrefix == prefix) { cells.Add(key); } @@ -2291,6 +2916,7 @@ internal sealed class RuntimeSetPositionState : IDisposable out Operation? operation) || !operation.WakeableLostCell || operation.ExactCellId != cell.CellId + || operation.CollisionPrefix != prefix || operation.CollisionGeneration != generation) { continue; @@ -2323,6 +2949,7 @@ internal sealed class RuntimeSetPositionState : IDisposable _lostDeadlineNodeIndex.Clear(); _preparedMovers.Clear(); _moverPreparationAuthorities.Clear(); + _collisionPrefixQuiescence.Clear(); _pendingProjection.Clear(); _expiredLostCells.Clear(); _expiredLostCellNodes.Clear(); @@ -2331,7 +2958,9 @@ internal sealed class RuntimeSetPositionState : IDisposable private RuntimeSetPositionOutcome ParkDeferred( Operation operation, in PhysicsSetPositionResult result, - bool publishImmediately = true) + bool publishImmediately = true, + ulong collisionGenerationOverride = 0UL, + uint collisionPrefixOverride = 0u) { PhysicsBody body = operation.Body!; body.Orientation = result.Orientation; @@ -2366,8 +2995,13 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.WakeableLostCell = true; operation.EnteringWorldFromCelllessResidence = true; ArmLostFamilyDeadlines(operation); - operation.CollisionGeneration = _physics - .ExpectedCollisionGeneration(result.CellId); + operation.CollisionGeneration = collisionGenerationOverride != 0UL + ? collisionGenerationOverride + : _physics.ExpectedCollisionGeneration(result.CellId); + operation.CollisionPrefix = collisionPrefixOverride != 0u + ? collisionPrefixOverride + : result.CellId & 0xFFFF0000u; + operation.CollisionQuiescenceHeld = collisionPrefixOverride != 0u; operation.Command = operation.Command with { Physics = operation.Command.Physics with @@ -2398,7 +3032,9 @@ internal sealed class RuntimeSetPositionState : IDisposable IndexDeferred(operation); operation.Stage = operation.RequiresPreparation ? RuntimeEntityPlacementStage.AwaitingPreparation - : RuntimeEntityPlacementStage.AwaitingWithdrawalAcknowledgement; + : operation.CollisionQuiescenceHeld + ? RuntimeEntityPlacementStage.QuiescenceHeld + : RuntimeEntityPlacementStage.AwaitingWithdrawalAcknowledgement; RuntimePlacementProjectionToken projection = PublishProjection( operation, RuntimePlacementProjectionKind.Withdraw, @@ -2432,7 +3068,36 @@ internal sealed class RuntimeSetPositionState : IDisposable if (!IsDeferredWakePreparationCurrent(operation)) return; + RuntimeCollisionPrefixQuiescenceToken restoringQuiescence = default; + if (TryGetBlockingQuiescence( + operation.Command.Physics, + out CollisionPrefixQuiescence? blocking)) + { + if (blocking!.AbortReleaseInProgress + && operation.CollisionQuiescenceHeld + && operation.CollisionPrefix + == blocking.Token.LandblockPrefix) + { + // The old collision generation remains active. Keep the + // admission barrier closed to new commands while this exact + // parked operation restores and its Place receipt drains. + restoringQuiescence = blocking.Token; + } + else + { + UnindexDeferred(operation); + operation.CollisionPrefix = blocking.Token.LandblockPrefix; + operation.CollisionGeneration = blocking.Token.CollisionGeneration; + operation.CollisionGenerationReady = false; + operation.CollisionQuiescenceHeld = true; + operation.Stage = RuntimeEntityPlacementStage.QuiescenceHeld; + IndexDeferred(operation); + return; + } + } + UnindexDeferred(operation); + operation.CollisionQuiescenceHeld = false; operation.Command = operation.Command with { GameTime = _physics.PlacementSimulationTime( @@ -2456,11 +3121,34 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.CollisionGenerationReady = false; if (!IsCurrent(operation)) return; + if (result.IsSuccessful + && TryGetBlockingQuiescence( + result, + out CollisionPrefixQuiescence? queriedQuiescence, + restoringQuiescence)) + { + PhysicsSetPositionResult held = result with + { + Residence = PhysicsResidenceDisposition.DeferredCell, + }; + operation.Result = held; + operation.ExactCellId = held.CellId; + operation.CollisionPrefix = + queriedQuiescence!.Token.LandblockPrefix; + operation.CollisionGeneration = + queriedQuiescence.Token.CollisionGeneration; + operation.CollisionQuiescenceHeld = true; + operation.Stage = RuntimeEntityPlacementStage.QuiescenceHeld; + _preparedMovers[operation.Key] = operation.Command.Physics; + IndexDeferred(operation); + return; + } if (result.IsDeferred) { operation.Result = result; operation.ExactCellId = result.CellId; _preparedMovers[operation.Key] = operation.Command.Physics; + operation.CollisionPrefix = result.CellId & 0xFFFF0000u; operation.CollisionGeneration = _physics .ExpectedCollisionGeneration(result.CellId); IndexDeferred(operation); @@ -2473,6 +3161,8 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.RequiresPreparation = true; operation.Stage = RuntimeEntityPlacementStage .AwaitingPreparation; + operation.CollisionPrefix = operation.ExactCellId + & 0xFFFF0000u; operation.CollisionGeneration = _physics .ExpectedCollisionGeneration(operation.ExactCellId); IndexDeferred(operation); @@ -2480,6 +3170,8 @@ internal sealed class RuntimeSetPositionState : IDisposable else { operation.Stage = RuntimeEntityPlacementStage.AwaitingCell; + operation.CollisionPrefix = operation.ExactCellId + & 0xFFFF0000u; operation.CollisionGeneration = _physics .ExpectedCollisionGeneration(operation.ExactCellId); IndexDeferred(operation); @@ -2778,6 +3470,8 @@ internal sealed class RuntimeSetPositionState : IDisposable result.OnWalkable); operation.ProjectionSequence = sequence; _pendingProjection.Add(sequence, snapshot); + TrackQuiescenceWithdrawal(operation, snapshot); + TrackQuiescenceRestorePlacement(operation, snapshot); if (publishImmediately) PublishPlacement(snapshot); return token; @@ -3091,6 +3785,32 @@ internal sealed class RuntimeSetPositionState : IDisposable Kind = RuntimePlacementProjectionKind.Discard, }; _pendingProjection[operation.ProjectionSequence] = cancelled; + if (pending.Kind is RuntimePlacementProjectionKind.Withdraw) + { + foreach (CollisionPrefixQuiescence state + in _collisionPrefixQuiescence.Values) + { + if (state.PendingWithdrawals.ContainsKey( + operation.ProjectionSequence)) + { + state.PendingWithdrawals[ + operation.ProjectionSequence] = cancelled.Token; + for (int retainedIndex = 0; + retainedIndex < state.RetainedWithdrawals.Count; + retainedIndex++) + { + if (state.RetainedWithdrawals[retainedIndex].Sequence + == operation.ProjectionSequence) + { + state.RetainedWithdrawals[retainedIndex] = + cancelled.Token; + break; + } + } + break; + } + } + } operation.Stage = RuntimeEntityPlacementStage .CancelledAwaitingAcknowledgement; discard = cancelled; @@ -3126,6 +3846,7 @@ internal sealed class RuntimeSetPositionState : IDisposable } var bucket = new CellGenerationKey( operation.ExactCellId, + operation.CollisionPrefix, operation.CollisionGeneration); if (!_deferredByCellGeneration.TryGetValue( bucket, @@ -3139,6 +3860,25 @@ internal sealed class RuntimeSetPositionState : IDisposable entities.Add(operation.Key); } + private void IndexUnboundDeferred(Operation operation) + { + if (operation.ExactCellId == 0u) + return; + var key = new UnboundCellKey( + operation.ExactCellId, + operation.CollisionPrefix); + if (!_unboundDeferredByCell.TryGetValue( + key, + out List? entities)) + { + entities = []; + _unboundDeferredByCell.Add(key, entities); + _unboundDeferredCellOrder.Add(key); + } + if (!entities.Contains(operation.Key)) + entities.Add(operation.Key); + } + private void UnindexDeferred(Operation operation) { if (operation.ExactCellId == 0u) @@ -3147,8 +3887,11 @@ internal sealed class RuntimeSetPositionState : IDisposable } if (operation.CollisionGeneration == 0UL) { + var key = new UnboundCellKey( + operation.ExactCellId, + operation.CollisionPrefix); if (_unboundDeferredByCell.TryGetValue( - operation.ExactCellId, + key, out List? unbound)) { int unboundIndex = unbound.IndexOf(operation.Key); @@ -3160,14 +3903,15 @@ internal sealed class RuntimeSetPositionState : IDisposable } if (unbound.Count == 0) { - _unboundDeferredByCell.Remove(operation.ExactCellId); - _unboundDeferredCellOrder.Remove(operation.ExactCellId); + _unboundDeferredByCell.Remove(key); + _unboundDeferredCellOrder.Remove(key); } } return; } var bucket = new CellGenerationKey( operation.ExactCellId, + operation.CollisionPrefix, operation.CollisionGeneration); if (_deferredByCellGeneration.TryGetValue( bucket, @@ -3194,13 +3938,16 @@ internal sealed class RuntimeSetPositionState : IDisposable return; } RemoveDeferredBucket(bucket); + var unboundKey = new UnboundCellKey( + bucket.CellId, + bucket.CollisionPrefix); if (!_unboundDeferredByCell.TryGetValue( - bucket.CellId, + unboundKey, out List? unbound)) { unbound = []; - _unboundDeferredByCell.Add(bucket.CellId, unbound); - _unboundDeferredCellOrder.Add(bucket.CellId); + _unboundDeferredByCell.Add(unboundKey, unbound); + _unboundDeferredCellOrder.Add(unboundKey); } for (int index = 0; index < entities.Count; index++) { @@ -3208,6 +3955,7 @@ internal sealed class RuntimeSetPositionState : IDisposable if (!_operations.TryGetValue(key, out Operation? operation) || !operation.WakeableLostCell || operation.ExactCellId != bucket.CellId + || operation.CollisionPrefix != bucket.CollisionPrefix || operation.CollisionGeneration != bucket.CollisionGeneration) { continue; @@ -3219,8 +3967,8 @@ internal sealed class RuntimeSetPositionState : IDisposable } if (unbound.Count == 0) { - _unboundDeferredByCell.Remove(bucket.CellId); - _unboundDeferredCellOrder.Remove(bucket.CellId); + _unboundDeferredByCell.Remove(unboundKey); + _unboundDeferredCellOrder.Remove(unboundKey); } } diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs new file mode 100644 index 00000000..b49bc996 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs @@ -0,0 +1,1037 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimeCollisionPrefixQuiescenceTests +{ + private const uint PrefixP = 0xA9B40000u; + private const uint CellP = PrefixP | 0x0001u; + private const uint PrefixQ = 0xAAB40000u; + private const uint CellQ = PrefixQ | 0x0001u; + + [Fact] + public void EarlierUnrelatedFifoHeadDrainsBeforeEveryResidentWithdraw() + { + using var fixture = new Fixture(); + RuntimeEntityRecord unrelated = fixture.Add( + 0x70003001u, + 1, + CellQ, + new Vector3(10f, 20f, 7f)); + RuntimeEntityRecord first = fixture.Add( + 0x70003002u, + 1, + CellP, + new Vector3(11f, 20f, 7f)); + RuntimeEntityRecord second = fixture.Add( + 0x70003003u, + 1, + CellP, + new Vector3(12f, 20f, 7f)); + RuntimeSetPositionOutcome unrelatedPlace = fixture.Place( + unrelated, + CellQ, + new Vector3(13f, 20f, 7f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + Assert.False(fixture.TryAcquire(token, out _)); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(first)); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(second)); + + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(unrelatedPlace.Projection)); + Assert.False(fixture.TryAcquire(token, out _)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(first)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(second)); + + var withdrawals = new List(); + while (fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot pending)) + { + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, pending.Kind); + withdrawals.Add(pending.Token); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(pending.Token)); + } + + Assert.Equal(2, withdrawals.Count); + Assert.True(fixture.TryAcquire(token, out var permission)); + Assert.Equal(withdrawals, permission.Withdrawals); + Assert.True(fixture.Lifetime.Physics + .IsCollisionPrefixMutationPermissionCurrent(permission)); + Assert.True(fixture.Lifetime.Physics + .CompleteCollisionPrefixQuiescence(permission)); + Assert.False(fixture.Lifetime.Physics + .IsCollisionPrefixMutationPermissionCurrent(permission)); + } + + [Fact] + public void ThrowAndFalseSinkKeepExactWithdrawRetryableUntilAck() + { + using var fixture = new Fixture(bindGeneration: true); + _ = fixture.Add( + 0x70003004u, + 1, + CellP, + new Vector3(10f, 21f, 7f)); + int attempt = 0; + var sink = new RecordingSink(_ => ++attempt switch + { + 1 => throw new InvalidOperationException("host unavailable"), + 2 => false, + _ => true, + }); + using var subscription = fixture.Subscribe(sink); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + Assert.False(fixture.TryAcquire(token, out _)); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.False(fixture.TryAcquire(token, out _)); + Assert.Equal(1, fixture.Lifetime.Events.DispatchFailureCount); + + Assert.True(subscription.RetryPending()); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.True(subscription.RetryPending()); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + + Assert.True(fixture.TryAcquire(token, out var permission)); + Assert.Single(permission.Withdrawals); + Assert.Equal(3, sink.Applied.Count); + Assert.All(sink.Applied, + projection => Assert.Equal( + permission.Withdrawals[0].Sequence, + projection.Token.Sequence)); + } + + [Fact] + public void SourceToOutsidePlacementIsHeldThenRestoredBeforeBarrierOpens() + { + using var fixture = new Fixture(); + RuntimeEntityRecord record = fixture.Add( + 0x70003005u, + 1, + CellP, + new Vector3(10f, 22f, 7f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + RuntimeSetPositionOutcome held = fixture.Place( + record, + CellQ, + new Vector3(14f, 22f, 7f), + currentCell: CellP); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, held.Status); + Assert.Equal(2UL, held.Projection.CollisionGeneration); + Assert.Equal(CellQ, held.ExactCellId); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(record)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(held.Projection)); + Assert.False(fixture.TryAcquire(token, out _)); + Assert.True(fixture.TryAcquire(token, out _)); + + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + Assert.Equal(CellQ, restored.Token.ExactCellId); + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(restored.Token)); + Assert.True(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.Equal(CellQ, record.FullCellId); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(record)); + } + + [Fact] + public void OutsideToTargetPlacementIsHeldWithoutEvaluatingRetiringRows() + { + using var fixture = new Fixture(); + RuntimeEntityRecord record = fixture.Add( + 0x70003006u, + 1, + CellQ, + new Vector3(10f, 23f, 7f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + RuntimeSetPositionOutcome held = fixture.Place( + record, + CellP, + new Vector3(15f, 23f, 7f), + currentCell: CellQ); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, held.Status); + Assert.Equal(CellP, held.ExactCellId); + Assert.Equal(2UL, held.Projection.CollisionGeneration); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(held.Projection)); + Assert.False(fixture.TryAcquire(token, out _)); + Assert.True(fixture.TryAcquire(token, out _)); + + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(CellP, restored.Token.ExactCellId); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(restored.Token)); + Assert.True(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.Equal(restored.Token.ExactCellId, record.FullCellId); + } + + [Fact] + public void SupersessionTransfersParkedReceiptAndOnlySuccessorCanAcquire() + { + using var fixture = new Fixture(); + _ = fixture.Add( + 0x70003007u, + 1, + CellP, + new Vector3(10f, 24f, 7f)); + RuntimeCollisionPrefixQuiescenceToken first = fixture.Begin(2UL); + Assert.False(fixture.TryAcquire(first, out _)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + + RuntimeCollisionPrefixQuiescenceToken successor = fixture.Begin(3UL); + + Assert.False(fixture.TryAcquire(first, out _)); + Assert.False(fixture.TryAcquire(successor, out _)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(withdrawal.Token)); + Assert.True(fixture.TryAcquire(successor, out var permission)); + Assert.Equal(successor, permission.Quiescence); + Assert.Single(permission.Withdrawals); + Assert.Equal(withdrawal.Token.Sequence, + permission.Withdrawals[0].Sequence); + } + + [Fact] + public void ResetAndDisposeConvergePrefixBarrierOwnership() + { + var fixture = new Fixture(); + _ = fixture.Add( + 0x70003008u, + 1, + CellP, + new Vector3(10f, 25f, 7f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + Assert.False(fixture.TryAcquire(token, out _)); + Assert.Equal(1, fixture.Lifetime.Physics.CaptureOwnership() + .CollisionPrefixQuiescenceCount); + + fixture.Lifetime.Physics.SetPosition.ResetSession(); + + RuntimePhysicsOwnershipSnapshot reset = + fixture.Lifetime.Physics.CaptureOwnership(); + Assert.Equal(0, reset.CollisionPrefixQuiescenceCount); + Assert.Equal(0, reset.PendingCollisionPrefixProjectionCount); + fixture.Dispose(); + Assert.True(fixture.Lifetime.Physics.CaptureOwnership().IsConverged); + } + + [Fact] + public void RevisedDiscardRetainsExactBarrierDebtUntilDiscardAck() + { + using var fixture = new Fixture(); + RuntimeEntityRecord record = fixture.Add( + 0x70003009u, + 1, + CellP, + new Vector3(10f, 26f, 7f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + Assert.False(fixture.TryAcquire(token, out _)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawal.Kind); + + RuntimePlacementCancellationReceipt cancellation = fixture.Lifetime + .Physics.SetPosition.Forget(record); + Assert.True(cancellation.IsValid); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot discard)); + Assert.Equal(RuntimePlacementProjectionKind.Discard, discard.Kind); + Assert.Equal(withdrawal.Token.Sequence, discard.Token.Sequence); + Assert.True(discard.Token.Revision > withdrawal.Token.Revision); + Assert.False(fixture.TryAcquire(token, out _)); + + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(discard.Token)); + Assert.True(fixture.TryAcquire(token, out var permission)); + Assert.Single(permission.Withdrawals); + Assert.Equal(discard.Token, permission.Withdrawals[0]); + } + + [Fact] + public void AcceptedPreparationDebtKeepsParkingFailAtomicAndTokenRetryable() + { + using var fixture = new Fixture(); + RuntimeEntityRecord record = fixture.Add( + 0x7000300Au, + 1, + CellP, + new Vector3(10f, 27f, 7f)); + RuntimeEntityPlacementToken placement = fixture.Lifetime.Physics + .SetPosition.BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + Assert.False(fixture.TryAcquire(token, out _)); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(record)); + Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out _)); + Assert.True(fixture.Lifetime.Physics + .CancelCollisionPrefixQuiescence(token)); + + RuntimeSetPositionOutcome submitted = fixture.Lifetime.Physics + .SetPosition.SubmitPreparedPlacement( + placement, + Command(CellP, new Vector3(11f, 27f, 7f), CellP)); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + submitted.Status); + } + + [Fact] + public void QueriedNeighborPrefixHoldsResultWithoutRequestDependency() + { + uint start = PrefixQ | 0x0101u; + uint rejected = PrefixP | 0x0101u; + uint winner = PrefixQ | 0x0102u; + PhysicsEngine engine = FlatEngine(); + PhysicsDataCache cache = engine.DataCache!; + cache.RegisterCellStructForTest( + start, + ContainmentCell( + new Plane(new Vector3(0f, -1f, 0f), 3f), + [rejected, winner])); + cache.RegisterCellStructForTest( + rejected, + ContainmentCell( + new Plane(new Vector3(0f, 1f, 0f), -20f), + [])); + cache.RegisterCellStructForTest( + winner, + ContainmentCell( + new Plane(new Vector3(0f, 1f, 0f), -7f), + [])); + using var fixture = new Fixture(engine: engine); + RuntimeEntityRecord record = fixture.Add( + 0x7000300Bu, + 1, + start, + new Vector3(0f, 8f, 1f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + RuntimeSetPositionOutcome held = fixture.Place( + record, + start, + new Vector3(0f, 8f, 1f)); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, held.Status); + Assert.Equal(winner, held.ExactCellId); + Assert.Equal(2UL, held.Projection.CollisionGeneration); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(record)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(held.Projection)); + Assert.False(fixture.TryAcquire(token, out _)); + Assert.True(fixture.TryAcquire(token, out _)); + } + + [Fact] + public void SimultaneousPrefixesParkAndDrainIndependentlyInOneFifo() + { + using var fixture = new Fixture(); + RuntimeEntityRecord first = fixture.Add( + 0x7000300Cu, + 1, + CellP, + new Vector3(10f, 28f, 7f)); + RuntimeEntityRecord second = fixture.Add( + 0x7000300Du, + 1, + CellQ, + new Vector3(11f, 28f, 7f)); + RuntimeCollisionPrefixQuiescenceToken p = fixture.Begin( + PrefixP, + 2UL, + includeOutdoorCells: true); + RuntimeCollisionPrefixQuiescenceToken q = fixture.Begin( + PrefixQ, + 3UL, + includeOutdoorCells: true); + + Assert.False(fixture.TryAcquire(p, out _)); + Assert.False(fixture.TryAcquire(q, out _)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(first)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(second)); + + while (fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot pending)) + { + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(pending.Token)); + } + + Assert.True(fixture.TryAcquire(p, out var pPermission)); + Assert.True(fixture.TryAcquire(q, out var qPermission)); + Assert.Single(pPermission.Withdrawals); + Assert.Single(qPermission.Withdrawals); + Assert.NotEqual( + pPermission.Withdrawals[0].Sequence, + qPermission.Withdrawals[0].Sequence); + } + + [Fact] + public void PermissionIsInvalidatedByNewPlacementAfterAcquire() + { + using var fixture = new Fixture(); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + Assert.False(fixture.TryAcquire(token, out _)); + Assert.True(fixture.TryAcquire(token, out var permission)); + Assert.True(fixture.Lifetime.Physics + .IsCollisionPrefixMutationPermissionCurrent(permission)); + RuntimeEntityRecord record = fixture.Add( + 0x7000300Eu, + 1, + CellQ, + new Vector3(10f, 29f, 7f)); + + RuntimeSetPositionOutcome held = fixture.Place( + record, + CellP, + new Vector3(12f, 29f, 7f), + currentCell: CellQ); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, held.Status); + Assert.False(fixture.Lifetime.Physics + .IsCollisionPrefixMutationPermissionCurrent(permission)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(held.Projection)); + Assert.True(fixture.TryAcquire(token, out var replacement)); + Assert.NotEqual(permission.Withdrawals, replacement.Withdrawals); + } + + [Fact] + public void IndoorOnlyQuiescenceLeavesOutdoorResidentActive() + { + uint indoor = PrefixP | 0x0101u; + using var fixture = new Fixture(); + RuntimeEntityRecord outdoor = fixture.Add( + 0x7000300Fu, + 1, + CellP, + new Vector3(10f, 30f, 7f)); + RuntimeEntityRecord interior = fixture.Add( + 0x70003010u, + 1, + indoor, + new Vector3(11f, 30f, 7f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin( + PrefixP, + 2UL, + includeOutdoorCells: false); + + Assert.False(fixture.TryAcquire(token, out _)); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(outdoor)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(interior)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(withdrawal.Token)); + Assert.True(fixture.TryAcquire(token, out var permission)); + Assert.Single(permission.Withdrawals); + Assert.Equal(interior.Key, permission.Withdrawals[0].Entity); + } + + [Fact] + public void WithdrawCallbackDeleteGuidReuseAndNewPlacementCannotEscapeBarrier() + { + using var fixture = new Fixture(); + RuntimeEntityRecord first = fixture.Add( + 0x70003011u, + 1, + CellP, + new Vector3(10f, 31f, 7f)); + RuntimeEntityRecord retired = fixture.Add( + 0x70003012u, + 1, + CellP, + new Vector3(11f, 31f, 7f)); + RuntimeEntityRecord? replacement = null; + RuntimeSetPositionOutcome reentrant = default; + var observer = new PlacementObserver(delta => + { + if (replacement is not null + || delta.Placement.Kind + is not RuntimePlacementProjectionKind.Withdraw + || delta.Placement.Token.Entity != first.Key) + { + return; + } + + Assert.True(fixture.Lifetime.TryAcceptDelete( + new DeleteObject.Parsed(retired.ServerGuid, retired.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + fixture.Lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(fixture.Lifetime.RetireCanonicalOnly(retired)); + replacement = fixture.Add( + retired.ServerGuid, + 2, + CellQ, + new Vector3(12f, 31f, 7f)); + reentrant = fixture.Place( + replacement, + CellP, + new Vector3(13f, 31f, 7f), + currentCell: CellQ); + }); + using IDisposable subscription = fixture.Lifetime.Events + .SubscribePlacement(observer); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + Assert.False(fixture.TryAcquire(token, out _)); + Assert.NotNull(replacement); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, reentrant.Status); + Assert.False(fixture.Lifetime.Entities.IsCurrent(retired)); + Assert.True(fixture.Lifetime.Entities.IsCurrent(replacement)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(replacement)); + + int receipts = 0; + while (fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot pending)) + { + receipts++; + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(pending.Token)); + } + Assert.Equal(3, receipts); + Assert.True(fixture.TryAcquire(token, out var permission)); + Assert.Equal(3, permission.Withdrawals.Length); + } + + [Fact] + public void AbortReleaseKeepsNewcomerBehindBarrierUntilEveryPlaceAck() + { + using var fixture = new Fixture(); + RuntimeEntityRecord resident = fixture.Add( + 0x70003013u, + 1, + CellP, + new Vector3(10f, 32f, 7f)); + RuntimeSetPositionOutcome prepared = fixture.Place( + resident, + CellP, + new Vector3(11f, 32f, 7f), + currentCell: CellP); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(prepared.Projection)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + Assert.False(fixture.TryAcquire(token, out _)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot residentWithdrawal)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(residentWithdrawal.Token)); + Assert.True(fixture.TryAcquire(token, out _)); + + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot residentPlace)); + Assert.Equal(RuntimePlacementProjectionKind.Place, residentPlace.Kind); + + RuntimeEntityRecord newcomer = fixture.Add( + 0x70003014u, + 1, + CellQ, + new Vector3(12f, 32f, 7f)); + RuntimeSetPositionOutcome newcomerHeld = fixture.Place( + newcomer, + CellP, + new Vector3(13f, 32f, 7f), + currentCell: CellQ); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, newcomerHeld.Status); + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(residentPlace.Token)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot newcomerWithdrawal)); + Assert.Equal( + RuntimePlacementProjectionKind.Withdraw, + newcomerWithdrawal.Kind); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(newcomerWithdrawal.Token)); + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot newcomerPlace)); + Assert.Equal(RuntimePlacementProjectionKind.Place, newcomerPlace.Kind); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(newcomerPlace.Token)); + Assert.True(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(resident)); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(newcomer)); + } + + [Fact] + public void ColdAbortRestoreWaitsForExactMoverPreparation() + { + using var fixture = new Fixture(); + RuntimeEntityRecord resident = fixture.Add( + 0x70003015u, + 1, + CellP, + new Vector3(10f, 33f, 7f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + Assert.False(fixture.TryAcquire(token, out _)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(withdrawal.Token)); + Assert.True(fixture.TryAcquire(token, out _)); + + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out _)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .TryGetAwaitingPreparationToken( + resident, + out RuntimeEntityPlacementToken preparationToken)); + RuntimeSetPositionCommand command = PrepareAuthoredCommand( + fixture.Lifetime, + preparationToken); + + RuntimeSetPositionOutcome submitted = fixture.Lifetime.Physics + .SetPosition.SubmitPreparedPlacement(preparationToken, command); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, submitted.Status); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(restored.Token)); + Assert.True(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + } + + [Fact] + public void ResetConvergesPendingDiscardAndPendingRestoreReceipts() + { + using (var discardFixture = new Fixture()) + { + RuntimeEntityRecord record = discardFixture.Add( + 0x70003016u, + 1, + CellP, + new Vector3(10f, 34f, 7f)); + RuntimeCollisionPrefixQuiescenceToken token = + discardFixture.Begin(2UL); + Assert.False(discardFixture.TryAcquire(token, out _)); + _ = discardFixture.Lifetime.Physics.SetPosition.Forget(record); + Assert.Equal(1, discardFixture.Lifetime.Physics.CaptureOwnership() + .PendingCollisionPrefixProjectionCount); + + discardFixture.Lifetime.Physics.SetPosition.ResetSession(); + + RuntimePhysicsOwnershipSnapshot reset = discardFixture.Lifetime + .Physics.CaptureOwnership(); + Assert.Equal(0, reset.CollisionPrefixQuiescenceCount); + Assert.Equal(0, reset.PendingCollisionPrefixProjectionCount); + } + + using var restoreFixture = new Fixture(); + RuntimeEntityRecord resident = restoreFixture.Add( + 0x70003017u, + 1, + CellP, + new Vector3(10f, 35f, 7f)); + RuntimeSetPositionOutcome prepared = restoreFixture.Place( + resident, + CellP, + new Vector3(11f, 35f, 7f), + currentCell: CellP); + Assert.True(restoreFixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(prepared.Projection)); + RuntimeCollisionPrefixQuiescenceToken restoreToken = + restoreFixture.Begin(2UL); + Assert.False(restoreFixture.TryAcquire(restoreToken, out _)); + Assert.True(restoreFixture.Lifetime.Physics.SetPosition + .TryPeekProjection(out RuntimePlacementProjectionSnapshot withdraw)); + Assert.True(restoreFixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(withdraw.Token)); + Assert.True(restoreFixture.TryAcquire(restoreToken, out _)); + Assert.False(restoreFixture.Lifetime.Physics + .CancelCollisionPrefixQuiescence( + restoreToken, + successorGeneration: 1UL, + successorReady: true)); + Assert.Equal(1, restoreFixture.Lifetime.Physics.CaptureOwnership() + .PendingCollisionPrefixProjectionCount); + + restoreFixture.Lifetime.Physics.SetPosition.ResetSession(); + + RuntimePhysicsOwnershipSnapshot restoreReset = restoreFixture.Lifetime + .Physics.CaptureOwnership(); + Assert.Equal(0, restoreReset.CollisionPrefixQuiescenceCount); + Assert.Equal(0, restoreReset.PendingCollisionPrefixProjectionCount); + } + + [Fact] + public void CancelBeforeFirstAcquireCannotStrandNewHeldPlacement() + { + using var fixture = new Fixture(); + RuntimeEntityRecord record = fixture.Add( + 0x70003018u, + 1, + CellQ, + new Vector3(10f, 36f, 7f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + RuntimeSetPositionOutcome held = fixture.Place( + record, + CellP, + new Vector3(11f, 36f, 7f), + currentCell: CellQ); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, held.Status); + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token)); + Assert.Equal(1, fixture.Lifetime.Physics.CaptureOwnership() + .CollisionPrefixQuiescenceCount); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(held.Projection)); + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token)); + + Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(restored.Token)); + Assert.True(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)); + Assert.Equal(restored.Token.ExactCellId, record.FullCellId); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(record)); + } + + private sealed class Fixture : IDisposable + { + private readonly RuntimeGenerationToken _generation = new(7UL); + + internal Fixture( + bool bindGeneration = false, + PhysicsEngine? engine = null) + { + Lifetime = new RuntimeEntityObjectLifetime(engine ?? FlatEngine()); + if (bindGeneration) + { + Lifetime.BindEventContext( + () => _generation, + static () => 11UL); + } + } + + internal RuntimeEntityObjectLifetime Lifetime { get; } + + internal RuntimeEntityRecord Add( + uint guid, + ushort incarnation, + uint cell, + Vector3 position) + { + RuntimeEntityRecord record = Lifetime.RegisterEntity( + Spawn(guid, incarnation, cell, position)).Canonical!; + Lifetime.Entities.SetFinalPhysicsState( + record, + PhysicsStateFlags.Gravity); + Lifetime.Entities.SetFullCell( + record, + cell, + (cell & 0xFFFF0000u) | 0xFFFFu); + var body = new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cell, position, position); + Lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + Lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true); + return record; + } + + internal RuntimeSetPositionOutcome Place( + RuntimeEntityRecord record, + uint cell, + Vector3 position, + uint? currentCell = null) => Lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(cell, position, currentCell)); + + internal RuntimeCollisionPrefixQuiescenceToken Begin( + ulong generation) => Lifetime.Physics + .BeginCollisionPrefixQuiescence( + PrefixP, + generation, + includeOutdoorCells: true); + + internal RuntimeCollisionPrefixQuiescenceToken Begin( + uint prefix, + ulong generation, + bool includeOutdoorCells) => Lifetime.Physics + .BeginCollisionPrefixQuiescence( + prefix, + generation, + includeOutdoorCells); + + internal bool TryAcquire( + RuntimeCollisionPrefixQuiescenceToken token, + out RuntimeCollisionPrefixMutationPermission permission) => + Lifetime.Physics.TryAcquireCollisionPrefixMutationPermission( + token, + out permission); + + internal RuntimePlacementProjectionSubscription Subscribe( + IRuntimePlacementProjectionSink sink) => new( + Lifetime.Placements, + () => _generation, + sink, + retryPendingOnSubscribe: true); + + public void Dispose() => Lifetime.Dispose(); + } + + private sealed class RecordingSink( + Func apply) + : IRuntimePlacementProjectionSink + { + internal List Applied { get; } = []; + + public bool TryApply(in RuntimePlacementProjectionSnapshot projection) + { + Applied.Add(projection); + return apply(projection); + } + } + + private sealed class PlacementObserver( + Action? onPlacement = null) + : IRuntimePlacementObserver + { + public void OnPlacement(in RuntimePlacementDelta delta) => + onPlacement?.Invoke(delta); + } + + private static RuntimeSetPositionCommand Command( + uint cell, + Vector3 position, + uint? currentCell) => new( + new PhysicsSetPositionRequest( + position, + Quaternion.Identity, + cell, + position, + ImmutableArray.Empty, + Scale: 1f, + StepUpHeight: 0.4f, + StepDownHeight: 0.4f, + Flags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide, + CurrentCellId: currentCell), + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 10d, + ExpectedVelocityAuthorityVersion: 0UL); + + private static RuntimeSetPositionCommand PrepareAuthoredCommand( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityPlacementToken token) + { + var setup = new FlatSetupCollision( + ImmutableArray.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.4f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f); + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.Resolved(0x02000001u, setup), + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 10d, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide); + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + token, + preparation, + out RuntimeSetPositionCommand command)); + return command; + } + + private static PhysicsEngine FlatEngine() + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + AddFlatLandblock(engine, PrefixP); + AddFlatLandblock(engine, PrefixQ); + return engine; + } + + private static void AddFlatLandblock(PhysicsEngine engine, uint prefix) => + engine.AddLandblock( + prefix, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + private static CellPhysics ContainmentCell( + Plane plane, + uint[] visibleCells) => new() + { + BSP = new PhysicsBSPTree + { + Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf }, + }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree + { + Root = new CellBSPNode + { + SplittingPlane = plane, + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }, + }, + Portals = [new PortalInfo(0xFFFF, 0, 0)], + PortalPolygons = new Dictionary(), + VisibleCellIds = new HashSet(visibleCells), + }; + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort instance, + uint cell, + Vector3 position) + { + var serverPosition = new CreateObject.ServerPosition( + cell, + position.X, + position.Y, + position.Z, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Gravity, + Position: serverPosition, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + serverPosition, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "collision-prefix-quiescence-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.Gravity, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} From 9b0f59bd1b06a960e438c944ba1924be2457b234 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 17:33:34 +0200 Subject: [PATCH 39/73] feat(runtime): atomically replace collision generations --- .../Streaming/LandblockPhysicsPublisher.cs | 145 ++++- .../LandblockPresentationPipeline.cs | 64 ++- .../LandblockPresentationRetirementOwner.cs | 28 +- .../LandblockRetirementCoordinator.cs | 54 +- .../Streaming/StreamingController.cs | 16 +- src/AcDream.Core/AcDream.Core.csproj | 6 + src/AcDream.Core/Physics/PhysicsEngine.cs | 8 +- .../Hosting/HeadlessSessionWorldProjection.cs | 360 ++++++++---- .../Entities/RuntimeEntityObjectLifetime.cs | 3 +- ...ntimeLocalPlayerPhysicsPublicationState.cs | 8 + .../Physics/RuntimePhysicsState.cs | 537 ++++++++++++++---- .../Physics/RuntimeSetPositionState.cs | 210 +++++-- .../LandblockPhysicsPublisherTests.cs | 234 ++++++++ .../LandblockRetirementCoordinatorTests.cs | 100 ++++ .../HeadlessSessionHostTests.cs | 235 +++++++- ...LocalPlayerPhysicsPublicationStateTests.cs | 84 ++- ...untimeCollisionMutationTransactionTests.cs | 479 ++++++++++++++++ .../RuntimeCollisionPrefixQuiescenceTests.cs | 7 +- .../RuntimePhysicsOwnershipBoundaryTests.cs | 44 ++ .../Physics/RuntimePhysicsStateTests.cs | 159 +++++- .../Physics/RuntimeSetPositionStateTests.cs | 62 +- 21 files changed, 2435 insertions(+), 408 deletions(-) create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsOwnershipBoundaryTests.cs diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 1144cf08..87cb5628 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -70,15 +70,46 @@ public sealed class LandblockPhysicsPublication : IDisposable internal int RefloodCursor { get; set; } internal bool RefloodCommitted { get; set; } internal bool SealCommitted { get; set; } + internal bool EngineMutationCommitted { get; set; } + /// + /// The last Runtime mutation poll was nonterminal. This includes exact + /// placement acknowledgements, collision-report/shadow dispatch debt, and + /// Runtime's deliberate first quiescence poll. + /// + internal bool RuntimeMutationPending { get; set; } internal bool BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } + internal bool CancellationRequested { get; private set; } public void Dispose() { - if (!CompletionCommitted) - Physics.CancelCollisionGeneration( - CollisionAdmission, - PreparedGeneration); + if (!TryCancel()) + { + throw new InvalidOperationException( + "Collision publication cancellation is waiting for exact placement acknowledgements and must remain retained."); + } + } + + internal bool TryCancel() + { + if (CompletionCommitted) + return true; + CancellationRequested = true; + for (int poll = 0; poll < 2; poll++) + { + if (Physics.CancelCollisionGeneration( + CollisionAdmission, + PreparedGeneration)) + { + return true; + } + if (Physics.CaptureOwnership() + .PendingCollisionPrefixProjectionCount != 0) + { + return false; + } + } + return false; } public uint LandblockId => Build.Landblock.LandblockId; @@ -246,9 +277,14 @@ public sealed class LandblockPhysicsPublisher publication.SetupObjectIds); return publication; } - catch + catch (Exception publicationError) { - _physics.CancelCollisionGeneration(collisionAdmission, prepared); + if (!_physics.CancelCollisionGeneration(collisionAdmission, prepared)) + { + throw new AggregateException( + "Collision preparation failed and its pre-engine cancellation did not converge.", + publicationError); + } throw; } } @@ -416,7 +452,7 @@ public sealed class LandblockPhysicsPublisher /// presentation pipeline supplies the static-presentation owner callback /// that preserves per-entity light-before-collision order. /// - public void CompletePublication( + public bool CompletePublication( LandblockPhysicsPublication publication, Action? beforeStaticCollision = null) { @@ -426,7 +462,29 @@ public sealed class LandblockPhysicsPublisher "Physics publication cannot complete before its prefix commits."); while (!AdvanceCompleteOne(publication, beforeStaticCollision)) { + if (publication.RuntimeMutationPending) + { + if (!CanContinueMutationSynchronously()) + return false; + // The activation transaction deliberately closes its exact + // quiescence boundary on one poll and transfers the engine on + // the next. This synchronous compatibility API may consume + // that finite internal suffix; the frame-budgeted pipeline + // always yields on the first nonterminal commit below. + publication.RuntimeMutationPending = false; + } } + return true; + } + + internal bool CanContinueMutationSynchronously() + { + RuntimePhysicsOwnershipSnapshot ownership = _physics.CaptureOwnership(); + return ownership.PendingCollisionPrefixProjectionCount == 0 + && ownership.PendingCollisionReportCount == 0 + && ownership.PendingCollisionSetPositionDispatchCount == 0 + && ownership.PendingShadowSetPositionDispatchCount == 0 + && !ownership.IsCollisionReportDispatching; } /// @@ -438,11 +496,15 @@ public sealed class LandblockPhysicsPublisher Action? beforeStaticCollision = null) { ValidateReceipt(publication); + if (publication.CancellationRequested) + throw new InvalidOperationException( + "A cancelled collision publication cannot resume."); if (!publication.BeginCommitted) throw new InvalidOperationException( "Physics publication cannot complete before its prefix commits."); if (publication.CompletionCommitted) return true; + publication.RuntimeMutationPending = false; long started = Stopwatch.GetTimestamp(); LoadedLandblock landblock = publication.Build.Landblock; @@ -567,12 +629,20 @@ public sealed class LandblockPhysicsPublisher _physics.CommitCollisionGeneration( publication.CollisionAdmission, publication.PreparedGeneration); - if (!commit.Committed) + publication.EngineMutationCommitted |= commit.EngineCommitted; + if (!commit.Completed) { - // Runtime coalesces post-seal arrivals in its owner journal. - // Resume that seal tail rather than restarting the - // completed generation-wide capture/reflood pass. - publication.SealCommitted = false; + publication.RuntimeMutationPending = true; + if (!commit.EngineCommitted) + { + // Runtime coalesces post-seal arrivals in its owner + // journal. Resume that seal tail rather than restarting + // the completed generation-wide capture/reflood pass. + publication.SealCommitted = false; + } + // Once EngineCommitted is true the replacement is canonical + // and cannot be resealed or rolled back. Later frames poll + // only the exact resident-restore acknowledgement suffix. _completePublishTicks += Stopwatch.GetTimestamp() - started; return false; } @@ -587,16 +657,54 @@ public sealed class LandblockPhysicsPublisher return publication.CompletionCommitted; } - public void DemoteToTerrain(uint landblockId) + public bool DemoteToTerrain(uint landblockId) { - _physics.DemoteCollisionToTerrain(landblockId); - _demotionCount++; + for (int poll = 0; poll < 2; poll++) + { + if (AdvanceDemotion(landblockId)) + return true; + if (_physics.CaptureOwnership() + .PendingCollisionPrefixProjectionCount != 0) + { + return false; + } + } + return false; } - public void RemoveLandblock(uint landblockId) + internal bool AdvanceDemotion(uint landblockId) { - _physics.WithdrawCollision(landblockId); + RuntimeCollisionMutationResult result = + _physics.DemoteCollisionToTerrain(landblockId); + if (!result.Completed) + return false; + _demotionCount++; + return true; + } + + public bool RemoveLandblock(uint landblockId) + { + for (int poll = 0; poll < 2; poll++) + { + if (AdvanceRemoval(landblockId)) + return true; + if (_physics.CaptureOwnership() + .PendingCollisionPrefixProjectionCount != 0) + { + return false; + } + } + return false; + } + + internal bool AdvanceRemoval(uint landblockId) + { + RuntimeCollisionMutationResult result = + _physics.WithdrawCollision(landblockId); + if (!result.Completed) + return false; _fullRemovalCount++; + return true; } private void PublishCell( @@ -1066,7 +1174,8 @@ public sealed class LandblockPhysicsPublisher "The physics publication receipt belongs to another publisher.", nameof(publication)); } - if (!publication.CompletionCommitted) + if (!publication.CompletionCommitted + && !publication.EngineMutationCommitted) { ObjectDisposedException.ThrowIf( publication.PreparedGeneration.IsDisposed, diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 5f7c9fff..75802436 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -217,14 +217,34 @@ public sealed class LandblockPresentationPipeline /// /// Cancels retained publication receipts during a generation reset. A - /// collision receipt owns only its private staging world until activation, - /// so cancellation cannot withdraw or partially replace the active world. + /// pre-engine receipt restores its exact prior generation. A receipt that + /// already transferred the engine generation remains retained until its + /// canonical post-engine placement acknowledgement suffix completes; the + /// committed transfer is never rolled back. /// - internal void CancelPendingPublications() + internal bool CancelPendingPublications() { - foreach (PublicationTransaction transaction in _publications.Values) - transaction.PhysicsPublication?.Dispose(); - _publications.Clear(); + if (_publications.Count == 0) + return true; + LandblockStreamResult[] pending = [.. _publications.Keys]; + bool completed = true; + for (int index = 0; index < pending.Length; index++) + { + LandblockStreamResult result = pending[index]; + if (!_publications.TryGetValue( + result, + out PublicationTransaction? transaction)) + { + continue; + } + bool cancelled = transaction.PhysicsPublication is not { } physics + || physics.TryCancel(); + if (cancelled) + _publications.Remove(result); + else + completed = false; + } + return completed && _publications.Count == 0; } public void ResumePublication(LandblockStreamResult result) @@ -247,10 +267,15 @@ public sealed class LandblockPresentationPipeline return Advance(result, transaction, meter, ensureProgress); } - public void AdvanceRetirements() => _retirements.Advance(); + public void AdvanceRetirements() + { + _retirements.Advance(); + } - public void AdvanceRetirements(StreamingWorkMeter meter) => + public void AdvanceRetirements(StreamingWorkMeter meter) + { _retirements.Advance(meter); + } internal void AdvancePriorityRetirement( uint landblockId, @@ -261,14 +286,26 @@ public sealed class LandblockPresentationPipeline { _retirements.BeginFull(landblockId); if (_retirements.UsesBudgetedSteps) + { _retirements.Advance(); + if (_retirements.IsPending(landblockId) + && (_physicsPublisher?.CanContinueMutationSynchronously() + ?? true)) + _retirements.Advance(); + } } public void BeginNearLayerRetirement(uint landblockId) { _retirements.BeginNearLayer(landblockId); if (_retirements.UsesBudgetedSteps) + { _retirements.Advance(); + if (_retirements.IsPending(landblockId) + && (_physicsPublisher?.CanContinueMutationSynchronously() + ?? true)) + _retirements.Advance(); + } } internal void EnqueueFullRetirement(uint landblockId) => @@ -754,6 +791,17 @@ public sealed class LandblockPresentationPipeline { return new LandblockPublicationAdvance(false, progressed); } + if (transaction.PhysicsPublication.RuntimeMutationPending) + { + if (meter is not null + || !_physicsPublisher + .CanContinueMutationSynchronously()) + { + return new LandblockPublicationAdvance( + false, + progressed); + } + } } while (!transaction.StaticPublication.CompletionCommitted) { diff --git a/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs index f9e0e937..368565c7 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs @@ -73,22 +73,20 @@ public sealed class LandblockPresentationRetirementOwner static entity => entity.ServerGuid == 0, _staticPresentation.RemovePluginProjection); + if (!ticket.RunOnce( + LandblockRetirementStage.Physics, + () => ticket.Kind == LandblockRetirementKind.Full + ? _physics.AdvanceRemoval(ticket.LandblockId) + : _physics.AdvanceDemotion(ticket.LandblockId))) + { + return; + } if (ticket.Kind == LandblockRetirementKind.Full) { ticket.RunOnce( LandblockRetirementStage.Terrain, () => _render.RemoveTerrain(ticket.LandblockId)); } - - ticket.RunOnce( - LandblockRetirementStage.Physics, - () => - { - if (ticket.Kind == LandblockRetirementKind.Full) - _physics.RemoveLandblock(ticket.LandblockId); - else - _physics.DemoteToTerrain(ticket.LandblockId); - }); ticket.RunOnce( LandblockRetirementStage.CellVisibility, () => _render.RemoveCellVisibility(ticket.LandblockId)); @@ -128,13 +126,9 @@ public sealed class LandblockPresentationRetirementOwner LandblockRetirementStage.Physics => ticket.RunOnceStep( LandblockRetirementStage.Physics, - () => - { - if (ticket.Kind == LandblockRetirementKind.Full) - _physics.RemoveLandblock(ticket.LandblockId); - else - _physics.DemoteToTerrain(ticket.LandblockId); - }), + () => ticket.Kind == LandblockRetirementKind.Full + ? _physics.AdvanceRemoval(ticket.LandblockId) + : _physics.AdvanceDemotion(ticket.LandblockId)), LandblockRetirementStage.CellVisibility => ticket.RunOnceStep( LandblockRetirementStage.CellVisibility, diff --git a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs index 82046fa1..5519758d 100644 --- a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs +++ b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs @@ -12,8 +12,8 @@ public enum LandblockRetirementStage : ushort EntityLighting = 1 << 3, EntityTranslucency = 1 << 4, PluginProjection = 1 << 5, - Terrain = 1 << 6, - Physics = 1 << 7, + Physics = 1 << 6, + Terrain = 1 << 7, CellVisibility = 1 << 8, BuildingRegistry = 1 << 9, EnvironmentCells = 1 << 10, @@ -24,6 +24,7 @@ internal enum LandblockRetirementOperationResult : byte { NoWork, Progressed, + Pending, Failed, } @@ -77,6 +78,28 @@ public sealed class LandblockRetirementTicket } } + public bool RunOnce(LandblockRetirementStage stage, Func operation) + { + ValidateSingleStage(stage); + ArgumentNullException.ThrowIfNull(operation); + if ((CompletedStages & stage) != 0) + return true; + + try + { + if (!operation()) + return false; + CompletedStages |= stage; + _failures.Remove(stage); + return true; + } + catch (Exception error) + { + _failures[stage] = error; + return false; + } + } + internal LandblockRetirementOperationResult RunOnceStep( LandblockRetirementStage stage, Action operation) @@ -100,6 +123,31 @@ public sealed class LandblockRetirementTicket } } + internal LandblockRetirementOperationResult RunOnceStep( + LandblockRetirementStage stage, + Func operation) + { + ValidateSingleStage(stage); + ArgumentNullException.ThrowIfNull(operation); + if ((CompletedStages & stage) != 0) + return LandblockRetirementOperationResult.NoWork; + + try + { + if (!operation()) + return LandblockRetirementOperationResult.Pending; + + CompletedStages |= stage; + _failures.Remove(stage); + return LandblockRetirementOperationResult.Progressed; + } + catch (Exception error) + { + _failures[stage] = error; + return LandblockRetirementOperationResult.Failed; + } + } + public bool RunForEachEntity( LandblockRetirementStage stage, Func predicate, @@ -815,6 +863,8 @@ public sealed class LandblockRetirementCoordinator } meter.Complete(); + if (result == LandblockRetirementOperationResult.Pending) + return BudgetedAdvanceResult.Yielded; return BudgetedAdvanceResult.Progressed; } diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 6f4c7912..b10e88e5 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -1362,19 +1362,23 @@ public sealed class StreamingController { if (!transaction.PendingPublicationsCleared) { + bool cancelled = false; if (!TryRunStreamingWork( meter, new StreamingWorkCost(EntityOperations: 1), "recenter-cancel-publications", () => { - _presentation.CancelPendingPublications(); - transaction.PendingPublicationsCleared = true; + cancelled = + _presentation.CancelPendingPublications(); return true; })) { return false; } + transaction.PendingPublicationsCleared = cancelled; + if (!cancelled) + return false; } if (!TryRunStreamingWork( meter, @@ -1519,19 +1523,23 @@ public sealed class StreamingController { if (!transaction.PendingPublicationsCleared) { + bool cancelled = false; if (!TryRunStreamingWork( meter, new StreamingWorkCost(EntityOperations: 1), "reload-cancel-publications", () => { - _presentation.CancelPendingPublications(); - transaction.PendingPublicationsCleared = true; + cancelled = + _presentation.CancelPendingPublications(); return true; })) { return false; } + transaction.PendingPublicationsCleared = cancelled; + if (!cancelled) + return false; } if (!TryRunStreamingWork( meter, diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 8300307e..966b25e1 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -26,6 +26,12 @@ <_Parameter1>AcDream.Runtime.Tests + + <_Parameter1>AcDream.App.Tests + + + <_Parameter1>AcDream.Headless.Tests + diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 7af81615..69194998 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1106,7 +1106,7 @@ public sealed class PhysicsEngine /// Register a landblock with its terrain surface, indoor cells, portal /// planes, and world-space origin offset. /// - public void AddLandblock(uint landblockId, TerrainSurface terrain, + internal void AddLandblock(uint landblockId, TerrainSurface terrain, IReadOnlyList cells, IReadOnlyList portals, float worldOffsetX, float worldOffsetY) { @@ -1120,7 +1120,7 @@ public sealed class PhysicsEngine /// /// Remove a previously registered landblock, including its shadow objects. /// - public void RemoveLandblock(uint landblockId) + internal void RemoveLandblock(uint landblockId) { _landblocks.Remove(landblockId); RemoveLandblockSlot(landblockId); @@ -1152,7 +1152,7 @@ public sealed class PhysicsEngine /// owned by this engine. Runtime calls this only at terminal disposal; /// ordinary streaming still uses the typed per-landblock retirement path. /// - public void Clear() + internal void Clear() { if (_landblocks.Count != 0) { @@ -1173,7 +1173,7 @@ public sealed class PhysicsEngine /// while preserving its terrain surface and world offset for Far-tier use. /// The corresponding render-side demotion preserves the terrain slot too. /// - public void DemoteLandblockToTerrain(uint landblockId) + internal void DemoteLandblockToTerrain(uint landblockId) { uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (_landblocks.TryGetValue(canonical, out var landblock)) diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index c903e04a..407d7a62 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -18,9 +18,37 @@ internal interface IHeadlessCollisionNeighborhood bool IsReady(uint fullCellId); } -internal static class HeadlessCollisionGenerationTransaction +internal readonly record struct HeadlessCollisionGenerationAdvance( + bool Completed, + bool Progressed, + bool WaitingForProjectionAcknowledgement, + bool YieldToCaller); + +internal sealed class HeadlessCollisionGenerationTransaction { - internal static RuntimeCollisionGenerationCommit Execute( + private readonly RuntimePhysicsState _physics; + private readonly RuntimeCollisionAdmission _admission; + private readonly PreparedLandblockCollisionGeneration _prepared; + private bool _ownerCaptureCommitted; + private int _refreshCursor; + private bool _sealCommitted; + + private HeadlessCollisionGenerationTransaction( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + _physics = physics; + _admission = admission; + _prepared = prepared; + } + + internal uint LandblockId => _admission.LandblockId; + internal bool EngineMutationCommitted { get; private set; } + internal bool CompletionCommitted { get; private set; } + internal bool CancellationRequested { get; private set; } + + internal static HeadlessCollisionGenerationTransaction Begin( RuntimePhysicsState physics, uint landblockId, Action? afterAdmission, @@ -33,60 +61,103 @@ internal static class HeadlessCollisionGenerationTransaction RuntimeCollisionAdmission admission = physics.BeginCollisionAdmission(landblockId); PreparedLandblockCollisionGeneration? prepared = null; - bool committed = false; try { - // This hook exists so the exact post-admission/pre-prepare failure - // boundary remains covered. Production does not install one. afterAdmission?.Invoke(admission); prepared = physics.PrepareCollisionGeneration(admission); stage(admission, prepared); - - RuntimeCollisionOwnerCaptureStep ownerCapture; - do - { - ownerCapture = physics.AdvanceCollisionRetainedOwnerCapture( - admission, - prepared); - } - while (!ownerCapture.Completed); - foreach (uint ownerId in prepared.RetainedOwnerIds) - { - physics.RefreshCollisionRetainedOwner( - admission, - prepared, - ownerId); - } - RuntimeCollisionSealStep seal; - do - { - seal = physics.AdvanceCollisionGenerationSeal( - admission, - prepared); - } - while (!seal.Completed && !seal.Restarted); - if (!seal.Completed) - { - throw new InvalidOperationException( - "Headless collision owner set changed during synchronous sealing."); - } - - RuntimeCollisionGenerationCommit result = - physics.CommitCollisionGeneration(admission, prepared); - if (!result.Committed) - { - throw new InvalidOperationException( - "Headless collision generation changed during synchronous publication."); - } - committed = true; - return result; + return new HeadlessCollisionGenerationTransaction( + physics, + admission, + prepared); } - finally + catch (Exception publicationError) { - if (!committed) - physics.CancelCollisionGeneration(admission, prepared); + if (!physics.CancelCollisionGeneration(admission, prepared)) + { + throw new AggregateException( + "Headless collision preparation failed and its pre-engine cancellation did not converge.", + publicationError); + } + throw; } } + + internal HeadlessCollisionGenerationAdvance Advance() + { + if (CancellationRequested) + throw new InvalidOperationException( + "A cancelled headless collision generation cannot resume."); + if (CompletionCommitted) + return new(true, false, false, false); + + if (!EngineMutationCommitted) + { + if (!_ownerCaptureCommitted) + { + RuntimeCollisionOwnerCaptureStep capture = + _physics.AdvanceCollisionRetainedOwnerCapture( + _admission, + _prepared); + _ownerCaptureCommitted = capture.Completed; + return new(false, true, false, false); + } + + if (_refreshCursor < _prepared.RetainedOwnerIds.Count) + { + _physics.RefreshCollisionRetainedOwner( + _admission, + _prepared, + _prepared.RetainedOwnerIds[_refreshCursor]); + _refreshCursor++; + return new(false, true, false, false); + } + + if (!_sealCommitted) + { + RuntimeCollisionSealStep seal = + _physics.AdvanceCollisionGenerationSeal( + _admission, + _prepared); + _sealCommitted = seal.Completed; + if (seal.Restarted) + { + _ownerCaptureCommitted = false; + _refreshCursor = 0; + } + return new(false, true, false, false); + } + } + + RuntimeCollisionGenerationCommit commit = + _physics.CommitCollisionGeneration(_admission, _prepared); + EngineMutationCommitted |= commit.EngineCommitted; + CompletionCommitted = commit.Completed; + bool waiting = !commit.Completed + && _physics.CaptureOwnership() + .PendingCollisionPrefixProjectionCount != 0; + if (!commit.Completed && !commit.EngineCommitted) + _sealCommitted = false; + return new( + commit.Completed, + Progressed: true, + WaitingForProjectionAcknowledgement: waiting, + YieldToCaller: !commit.Completed); + } + + internal bool TryCancel() + { + if (CompletionCommitted) + return true; + CancellationRequested = true; + bool completed = _physics.CancelCollisionGeneration( + _admission, + _prepared); + if (completed && EngineMutationCommitted) + CompletionCommitted = true; + return completed; + } + } /// @@ -97,10 +168,23 @@ internal static class HeadlessCollisionGenerationTransaction internal sealed class HeadlessCollisionNeighborhood : IHeadlessCollisionNeighborhood { + private readonly record struct PublicationSpec( + uint LandblockId, + Vector3 Origin, + bool Required); + private readonly GameRuntime _runtime; private readonly HeadlessProcessContentOwner .HeadlessProcessContentLease _content; private readonly HashSet _resident = []; + private readonly Queue _retirementQueue = []; + private readonly Queue _publicationQueue = []; + private HeadlessCollisionGenerationTransaction? _pendingPublication; + private bool _pendingPublicationCancellation; + private bool _resetRequired; + private bool _publicationPlanBuilt; + private uint _requestedCenterLandblock; + private uint _requestedFullCell; private uint _centerLandblock; internal HeadlessCollisionNeighborhood( @@ -122,6 +206,21 @@ internal sealed class HeadlessCollisionNeighborhood nameof(fullCellId), "A collision neighborhood requires a real destination cell."); } + if (_requestedCenterLandblock != center) + { + _requestedCenterLandblock = center; + _requestedFullCell = fullCellId; + _resetRequired = true; + _publicationPlanBuilt = false; + _publicationQueue.Clear(); + _pendingPublicationCancellation = + _pendingPublication is not null; + } + else + { + _requestedFullCell = fullCellId; + } + if (_centerLandblock == center && _resident.Contains(center) && _runtime.EntityObjects.Physics.Engine @@ -131,55 +230,17 @@ internal sealed class HeadlessCollisionNeighborhood .UpdatePlayerCurrCell(fullCellId); return; } - - RetireAll(); - int centerX = (int)((center >> 24) & 0xFFu); - int centerY = (int)((center >> 16) & 0xFFu); - try - { - PublishOne( - center, - Vector3.Zero, - fullCellId, - required: true); - for (int dx = -1; dx <= 1; dx++) - { - for (int dy = -1; dy <= 1; dy++) - { - if (dx == 0 && dy == 0) - continue; - int landblockX = centerX + dx; - int landblockY = centerY + dy; - if ((uint)landblockX > byte.MaxValue - || (uint)landblockY > byte.MaxValue) - { - continue; - } - uint landblockId = - ((uint)landblockX << 24) - | ((uint)landblockY << 16) - | 0xFFFFu; - PublishOne( - landblockId, - new Vector3(dx * 192f, dy * 192f, 0f), - fullCellId, - required: false); - } - } - _centerLandblock = center; - _runtime.EntityObjects.Physics.Engine - .UpdatePlayerCurrCell(fullCellId); - } - catch - { - RetireAll(); - throw; - } + AdvanceWork(); } public bool IsReady(uint fullCellId) { uint center = CanonicalLandblock(fullCellId); + if (_requestedCenterLandblock == center) + { + _requestedFullCell = fullCellId; + AdvanceWork(); + } if (_centerLandblock != center || !_resident.Contains(center) || !_runtime.EntityObjects.Physics.Engine @@ -192,7 +253,7 @@ internal sealed class HeadlessCollisionNeighborhood .GetCellStruct(fullCellId) is not null; } - private void PublishOne( + private HeadlessCollisionGenerationTransaction? CreatePublication( uint landblockId, Vector3 origin, uint currentCellId, @@ -207,7 +268,7 @@ internal sealed class HeadlessCollisionNeighborhood throw new InvalidDataException( $"Required headless landblock 0x{landblockId:X8} is missing."); } - return; + return null; } IReadOnlyList staticEntities = @@ -244,7 +305,7 @@ internal sealed class HeadlessCollisionNeighborhood landblock); RuntimePhysicsState physics = _runtime.EntityObjects.Physics; - _ = HeadlessCollisionGenerationTransaction.Execute( + return HeadlessCollisionGenerationTransaction.Begin( physics, landblockId, afterAdmission: null, @@ -294,22 +355,113 @@ internal sealed class HeadlessCollisionNeighborhood collisions, origin); }); - _resident.Add(CanonicalLandblock(landblockId)); } - private void RetireAll() + private void AdvanceWork() { - if (_resident.Count == 0) + if (_pendingPublicationCancellation + && _pendingPublication is { } cancelling) { - _centerLandblock = 0u; - return; + bool wasCommitted = cancelling.EngineMutationCommitted; + if (!cancelling.TryCancel()) + return; + if (wasCommitted || cancelling.EngineMutationCommitted) + _resident.Add(cancelling.LandblockId); + _pendingPublication = null; + _pendingPublicationCancellation = false; } - uint[] retiring = [.. _resident]; - _resident.Clear(); - _centerLandblock = 0u; - foreach (uint landblock in retiring) - _ = _runtime.EntityObjects.Physics.WithdrawCollision(landblock); + if (_resetRequired) + { + _retirementQueue.Clear(); + foreach (uint landblock in _resident) + _retirementQueue.Enqueue(landblock); + _centerLandblock = 0u; + _resetRequired = false; + } + + while (_retirementQueue.TryPeek(out uint retiring)) + { + RuntimeCollisionMutationResult result = _runtime.EntityObjects + .Physics.WithdrawCollision(retiring); + if (!result.Completed) + return; + _retirementQueue.Dequeue(); + _resident.Remove(retiring); + } + + if (!_publicationPlanBuilt) + { + BuildPublicationPlan( + _requestedCenterLandblock, + _publicationQueue); + _publicationPlanBuilt = true; + } + + while (true) + { + if (_pendingPublication is null) + { + if (!_publicationQueue.TryDequeue(out PublicationSpec spec)) + { + _centerLandblock = _requestedCenterLandblock; + _runtime.EntityObjects.Physics.Engine + .UpdatePlayerCurrCell(_requestedFullCell); + return; + } + _pendingPublication = CreatePublication( + spec.LandblockId, + spec.Origin, + _requestedFullCell, + spec.Required); + if (_pendingPublication is null) + continue; + } + + HeadlessCollisionGenerationAdvance advance = + _pendingPublication.Advance(); + if (advance.Completed) + { + _resident.Add(_pendingPublication.LandblockId); + _pendingPublication = null; + continue; + } + if (advance.WaitingForProjectionAcknowledgement) + return; + if (advance.YieldToCaller) + return; + if (!advance.Progressed) + throw new InvalidOperationException( + "Headless collision publication made no progress."); + } + } + + private static void BuildPublicationPlan( + uint center, + Queue destination) + { + destination.Enqueue(new PublicationSpec( + center, + Vector3.Zero, + Required: true)); + int centerX = (int)((center >> 24) & 0xFFu); + int centerY = (int)((center >> 16) & 0xFFu); + for (int dx = -1; dx <= 1; dx++) + { + for (int dy = -1; dy <= 1; dy++) + { + if (dx == 0 && dy == 0) + continue; + int x = centerX + dx; + int y = centerY + dy; + if ((uint)x > byte.MaxValue || (uint)y > byte.MaxValue) + continue; + destination.Enqueue(new PublicationSpec( + ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu, + new Vector3(dx * 192f, dy * 192f, 0f), + Required: false)); + } + } } private static uint CanonicalLandblock(uint fullCellId) => diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 951c189f..3814f099 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -1053,8 +1053,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable _sessionClearInProgress = true; RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); Physics.CollisionReports.LeaveWorldBatch(active); - Physics.SetPosition.ResetSession(); - Physics.CollisionReports.ResetSession(); + Physics.ResetSessionPhysics(); Entities.BeginSessionClear(); foreach (RuntimeEntityRecord canonical in active) { diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs index b57ee1dd..8a631030 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -388,6 +388,14 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority; } + // Seal the exact SetPosition owner before the irreversible no-fail + // suffix. Any internal invariant failure therefore leaves every + // canonical body/controller owner untouched. + _physics.SetPosition.PrepareDormantLocalActivationOwnership( + candidate.Record, + candidate.Body, + candidate.PreparedActivation.Token.Placement); + // All validation is complete. The remaining stores are callback-free, // non-allocating, and cannot fail on this single Runtime update thread. // The controller remains RuntimeOwnedDormant; the subsequent world diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 7b3c2e06..86ab4deb 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -37,6 +37,8 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( bool IsCollisionReportDispatching, int CollisionPrefixQuiescenceCount, int PendingCollisionPrefixProjectionCount, + int CollisionPrefixMutationCount, + int CommittedCollisionPrefixMutationCount, int CollisionAdmissionCount, int CollisionGenerationCount, bool OwnsProductionDataCache, @@ -75,6 +77,8 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( && !IsCollisionReportDispatching && CollisionPrefixQuiescenceCount == 0 && PendingCollisionPrefixProjectionCount == 0 + && CollisionPrefixMutationCount == 0 + && CommittedCollisionPrefixMutationCount == 0 && CollisionAdmissionCount == 0 && CollisionGenerationCount == 0 && OwnsProductionDataCache; @@ -100,11 +104,13 @@ public sealed class RuntimeCollisionAdmission internal RuntimeCollisionAdmission( RuntimePhysicsState owner, uint landblockId, - ulong generation) + ulong generation, + ulong previousGeneration) { Owner = owner; LandblockId = landblockId; Generation = generation; + PreviousGeneration = previousGeneration; } internal RuntimePhysicsState Owner { get; } @@ -112,6 +118,32 @@ public sealed class RuntimeCollisionAdmission internal bool Completed { get; set; } public uint LandblockId { get; } public ulong Generation { get; } + internal ulong PreviousGeneration { get; } +} + +internal enum RuntimeCollisionPrefixMutationKind : byte +{ + Activation, + Demotion, + Withdrawal, +} + +internal sealed class RuntimeCollisionPrefixMutation +{ + internal required RuntimeCollisionPrefixMutationKind Kind { get; init; } + internal required uint LandblockId { get; init; } + internal required ulong PreviousGeneration { get; init; } + internal required ulong TargetGeneration { get; init; } + internal ulong InvalidatedGeneration { get; init; } + internal required RuntimeCollisionPrefixQuiescenceToken Quiescence + { get; init; } + internal RuntimeCollisionAdmission? Admission { get; init; } + internal PreparedLandblockCollisionGeneration? Prepared { get; init; } + internal RuntimeCollisionPrefixMutationPermission Permission { get; set; } + internal bool EngineMutationCommitted { get; set; } + internal bool CancellationRequested { get; set; } + internal bool WasResident { get; set; } + internal bool Ready { get; set; } } public readonly record struct RuntimeCollisionAcknowledgement( @@ -120,11 +152,23 @@ public readonly record struct RuntimeCollisionAcknowledgement( bool WasResident, bool Ready); +public readonly record struct RuntimeCollisionMutationResult( + RuntimeCollisionAcknowledgement Acknowledgement, + bool Completed) +{ + public uint LandblockId => Acknowledgement.LandblockId; + public ulong Generation => Acknowledgement.Generation; + public bool WasResident => Acknowledgement.WasResident; + public bool Ready => Acknowledgement.Ready; +} + public readonly record struct RuntimeCollisionGenerationCommit( RuntimeCollisionAcknowledgement Acknowledgement, - uint[] DirtyRetainedOwnerIds) + uint[] DirtyRetainedOwnerIds, + bool EngineCommitted, + bool Completed) { - public bool Committed => Acknowledgement.Ready; + public bool Committed => Completed; } public readonly record struct RuntimeCollisionGenerationCommitted( @@ -1045,6 +1089,8 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions = new(); private readonly Dictionary _preparedCollisionGenerations = new(); + private readonly Dictionary + _collisionPrefixMutations = new(); private readonly CollisionOwnerMutationJournal _collisionOwnerJournal = new(); private readonly Dictionary> _collisionOwnerSubscribers = new(); @@ -1175,6 +1221,9 @@ public sealed class RuntimePhysicsState : IDisposable collisionReports.IsDispatching, setPosition.CollisionPrefixQuiescenceCount, setPosition.PendingQuiescenceProjectionCount, + _collisionPrefixMutations.Count, + _collisionPrefixMutations.Values.Count( + mutation => mutation.EngineMutationCommitted), _collisionAdmissions.Count, _collisionGenerations.Count, ReferenceEquals(Engine.DataCache, DataCache), @@ -1907,6 +1956,29 @@ public sealed class RuntimePhysicsState : IDisposable _spatialRoots.Clear(); } + internal void ResetSessionPhysics() + { + EnsureNotDisposed(); + // GameRuntime serializes reset after the host update loop has stopped. + // Reset may therefore run on the lifecycle/disposal thread rather than + // the retired generation's update thread. It clears every mutation + // owner before releasing affinity so the next generation can bind its + // own update thread without admitting concurrent mutation. + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + prepared.Dispose(); + } + _preparedCollisionGenerations.Clear(); + _collisionPrefixMutations.Clear(); + _collisionAdmissions.Clear(); + SetPosition.ResetSession(); + CollisionReports.ResetSession(); + TrimCollisionOwnerJournal(); + AdvanceCollisionWorldAuthority(); + Volatile.Write(ref _collisionMutationThreadId, 0); + } + internal RuntimeCollisionPrefixQuiescenceToken BeginCollisionPrefixQuiescence( uint landblockId, @@ -1957,25 +2029,29 @@ public sealed class RuntimePhysicsState : IDisposable successorReady); } - internal bool CompleteCollisionPrefixQuiescence( - in RuntimeCollisionPrefixMutationPermission permission) - { - EnsureNotDisposed(); - EnsureCollisionMutationThread(); - return SetPosition.CompleteCollisionPrefixQuiescence(permission); - } - public RuntimeCollisionAdmission BeginCollisionAdmission( uint landblockId) { EnsureNotDisposed(); EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); + if (_collisionPrefixMutations.ContainsKey(canonical)) + { + throw new InvalidOperationException( + $"Collision prefix 0x{canonical:X8} is still completing its previous mutation transaction."); + } + ulong currentGeneration = _collisionGenerations.TryGetValue( + canonical, + out ulong current) + ? current + : 0UL; + ulong previousGeneration = currentGeneration; AdvanceCollisionWorldAuthority(); if (_collisionAdmissions.Remove( canonical, out RuntimeCollisionAdmission? superseded)) { + previousGeneration = superseded.PreviousGeneration; SetPosition.CancelCollisionGeneration( canonical, superseded.Generation); @@ -1986,16 +2062,13 @@ public sealed class RuntimePhysicsState : IDisposable prepared.Dispose(); } } - ulong generation = _collisionGenerations.TryGetValue( - canonical, - out ulong current) - ? checked(current + 1UL) - : 1UL; + ulong generation = checked(currentGeneration + 1UL); _collisionGenerations[canonical] = generation; var admission = new RuntimeCollisionAdmission( this, canonical, - generation); + generation, + previousGeneration); _collisionAdmissions[canonical] = admission; SetPosition.BeginCollisionGeneration(canonical, generation); return admission; @@ -2047,7 +2120,7 @@ public sealed class RuntimePhysicsState : IDisposable /// collision world is never withdrawn. A stale receipt may dispose its /// own staging storage but cannot invalidate a newer admission. /// - internal void CancelCollisionGeneration( + internal bool CancelCollisionGeneration( RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration? prepared = null) { @@ -2067,6 +2140,38 @@ public sealed class RuntimePhysicsState : IDisposable nameof(prepared)); } + if (_collisionPrefixMutations.TryGetValue( + admission.LandblockId, + out RuntimeCollisionPrefixMutation? mutation)) + { + if (mutation.Kind is not RuntimeCollisionPrefixMutationKind.Activation + || !ReferenceEquals(mutation.Admission, admission) + || (prepared is not null + && !ReferenceEquals(mutation.Prepared, prepared))) + { + return false; + } + if (mutation.EngineMutationCommitted) + return AdvanceCommittedActivation(mutation).Committed; + + mutation.CancellationRequested = true; + bool previousReady = mutation.PreviousGeneration != 0UL + && Engine.IsLandblockTerrainResident( + mutation.LandblockId); + bool released = mutation.PreviousGeneration == 0UL + ? SetPosition.CancelCollisionPrefixQuiescenceToUnavailable( + mutation.Quiescence) + : CancelCollisionPrefixQuiescence( + mutation.Quiescence, + mutation.PreviousGeneration, + previousReady); + if (!released) + { + return false; + } + _collisionPrefixMutations.Remove(mutation.LandblockId); + } + prepared?.Dispose(); if (prepared is not null && _preparedCollisionGenerations.TryGetValue( @@ -2090,6 +2195,7 @@ public sealed class RuntimePhysicsState : IDisposable AdvanceCollisionWorldAuthority(); } TrimCollisionOwnerJournal(); + return true; } internal void StageCollisionAssets( @@ -2261,8 +2367,29 @@ public sealed class RuntimePhysicsState : IDisposable RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared) { - ValidateAdmission(admission); + EnsureNotDisposed(); EnsureCollisionMutationThread(); + ArgumentNullException.ThrowIfNull(admission); + ArgumentNullException.ThrowIfNull(prepared); + + if (_collisionPrefixMutations.TryGetValue( + admission.LandblockId, + out RuntimeCollisionPrefixMutation? pending)) + { + if (pending.Kind is not RuntimeCollisionPrefixMutationKind.Activation + || !ReferenceEquals(pending.Admission, admission) + || !ReferenceEquals(pending.Prepared, prepared)) + { + throw new InvalidOperationException( + "A different collision-prefix mutation owns this landblock."); + } + if (pending.EngineMutationCommitted) + return AdvanceCommittedActivation(pending); + if (pending.CancellationRequested) + return PendingActivation(pending); + } + + ValidateAdmission(admission); ValidatePreparedGeneration(admission, prepared); if (!admission.AssetsPrepared) { @@ -2292,8 +2419,62 @@ public sealed class RuntimePhysicsState : IDisposable admission.Generation, Engine.IsLandblockTerrainResident(admission.LandblockId), Ready: false), - Array.Empty()); + Array.Empty(), + EngineCommitted: false, + Completed: false); } + + RuntimeCollisionPrefixMutation mutation; + if (!_collisionPrefixMutations.TryGetValue( + admission.LandblockId, + out mutation!)) + { + RuntimeCollisionPrefixQuiescenceToken token = + BeginCollisionPrefixQuiescence( + admission.LandblockId, + admission.Generation, + includeOutdoorCells: true); + mutation = new RuntimeCollisionPrefixMutation + { + Kind = RuntimeCollisionPrefixMutationKind.Activation, + LandblockId = admission.LandblockId, + PreviousGeneration = admission.PreviousGeneration, + TargetGeneration = admission.Generation, + Quiescence = token, + Admission = admission, + Prepared = prepared, + WasResident = Engine.IsLandblockTerrainResident( + admission.LandblockId), + }; + _collisionPrefixMutations.Add(admission.LandblockId, mutation); + + // The first poll deliberately closes the prefix and marks its + // resident-parking pass complete, even when the prefix is empty. + // A later poll alone may consume mutation permission; affected + // residents also require their exact Withdraw acknowledgements. + } + + if (!TryAcquireCollisionPrefixMutationPermission( + mutation.Quiescence, + out RuntimeCollisionPrefixMutationPermission permission)) + { + return PendingActivation(mutation); + } + mutation.Permission = permission; + + // Parking a live owner writes to the canonical shadow journal. The + // prepared replacement must be resealed against that exact journal + // tail before permission can be consumed. + if (!IsCollisionPrefixMutationPermissionCurrent(permission) + || !prepared.IsOwnerMutationReconciliationCurrent + || !prepared.IsReadyForActivation + || HasOlderPreparedGeneration(prepared) + || prepared.HasPendingCommittedRebase + || prepared.HasPendingRetirement) + { + return PendingActivation(mutation); + } + PhysicsEngine.PreparedPhysicsEngineLandblock replacement = prepared.TakeSealedReplacement(); bool suppressOwnerJournal = _suppressCollisionOwnerJournal; @@ -2313,20 +2494,70 @@ public sealed class RuntimePhysicsState : IDisposable if (later.Sequence > prepared.Sequence) later.EnqueueCommittedRebase(replacement); } - admission.Completed = true; - _collisionAdmissions.Remove(admission.LandblockId); _preparedCollisionGenerations.Remove(admission.LandblockId); prepared.MarkCommitted(); - TrimCollisionOwnerJournal(); - var acknowledgement = new RuntimeCollisionAcknowledgement( - admission.LandblockId, - admission.Generation, - Engine.IsLandblockTerrainResident(admission.LandblockId), - Ready: Engine.IsLandblockTerrainResident(admission.LandblockId)); + mutation.EngineMutationCommitted = true; + mutation.Ready = Engine.IsLandblockTerrainResident( + admission.LandblockId); + mutation.WasResident = mutation.Ready; SetPosition.CommitCollisionGeneration( - acknowledgement.LandblockId, - acknowledgement.Generation, - acknowledgement.Ready); + mutation.LandblockId, + mutation.TargetGeneration, + mutation.Ready); + RuntimeCollisionGenerationCommit completed = + AdvanceCommittedActivation(mutation); + return completed; + } + + private RuntimeCollisionGenerationCommit PendingActivation( + RuntimeCollisionPrefixMutation mutation) => new( + new RuntimeCollisionAcknowledgement( + mutation.LandblockId, + mutation.TargetGeneration, + mutation.WasResident, + Ready: mutation.EngineMutationCommitted && mutation.Ready), + Array.Empty(), + EngineCommitted: mutation.EngineMutationCommitted, + Completed: false); + + private RuntimeCollisionGenerationCommit AdvanceCommittedActivation( + RuntimeCollisionPrefixMutation mutation) + { + if (!mutation.EngineMutationCommitted) + throw new InvalidOperationException( + "Collision activation cannot release before its engine transaction commits."); + + bool completed = SetPosition.ReleaseCollisionPrefixAfterMutation( + mutation.Quiescence, + mutation.TargetGeneration, + mutation.Ready); + var acknowledgement = new RuntimeCollisionAcknowledgement( + mutation.LandblockId, + mutation.TargetGeneration, + mutation.WasResident, + mutation.Ready); + if (!completed) + { + return new RuntimeCollisionGenerationCommit( + acknowledgement, + Array.Empty(), + EngineCommitted: true, + Completed: false); + } + + RuntimeCollisionAdmission admission = mutation.Admission + ?? throw new InvalidOperationException( + "Collision activation lost its admission owner."); + admission.Completed = true; + if (_collisionAdmissions.TryGetValue( + mutation.LandblockId, + out RuntimeCollisionAdmission? current) + && ReferenceEquals(current, admission)) + { + _collisionAdmissions.Remove(mutation.LandblockId); + } + _collisionPrefixMutations.Remove(mutation.LandblockId); + TrimCollisionOwnerJournal(); PublishCollisionGenerationCommitted( new RuntimeCollisionGenerationCommitted( acknowledgement.LandblockId, @@ -2334,67 +2565,179 @@ public sealed class RuntimePhysicsState : IDisposable acknowledgement.Ready)); return new RuntimeCollisionGenerationCommit( acknowledgement, - Array.Empty()); + Array.Empty(), + EngineCommitted: true, + Completed: true); } - public RuntimeCollisionAcknowledgement DemoteCollisionToTerrain( + private static RuntimeCollisionMutationResult PendingRetirement( + RuntimeCollisionPrefixMutation mutation) => new( + new RuntimeCollisionAcknowledgement( + mutation.LandblockId, + mutation.TargetGeneration, + mutation.WasResident, + Ready: mutation.EngineMutationCommitted && mutation.Ready), + Completed: false); + + private void CommitCollisionInvalidation( + RuntimeCollisionPrefixMutation mutation) + { + _collisionGenerations[mutation.LandblockId] = + mutation.TargetGeneration; + SetPosition.CancelCollisionGeneration( + mutation.LandblockId, + mutation.InvalidatedGeneration); + if (_collisionAdmissions.TryGetValue( + mutation.LandblockId, + out RuntimeCollisionAdmission? currentAdmission) + && ReferenceEquals(currentAdmission, mutation.Admission)) + { + _collisionAdmissions.Remove(mutation.LandblockId); + } + if (_preparedCollisionGenerations.TryGetValue( + mutation.LandblockId, + out PreparedLandblockCollisionGeneration? currentPrepared) + && ReferenceEquals(currentPrepared, mutation.Prepared)) + { + _preparedCollisionGenerations.Remove(mutation.LandblockId); + currentPrepared.Dispose(); + } + } + + public RuntimeCollisionMutationResult DemoteCollisionToTerrain( uint landblockId) + => AdvanceCollisionRetirementMutation( + landblockId, + RuntimeCollisionPrefixMutationKind.Demotion); + + public RuntimeCollisionMutationResult WithdrawCollision( + uint landblockId) + => AdvanceCollisionRetirementMutation( + landblockId, + RuntimeCollisionPrefixMutationKind.Withdrawal); + + private RuntimeCollisionMutationResult AdvanceCollisionRetirementMutation( + uint landblockId, + RuntimeCollisionPrefixMutationKind kind) { EnsureNotDisposed(); EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); - bool resident = Engine.IsLandblockTerrainResident(canonical); - InvalidateCollisionAdmission(canonical); - bool suppressOwnerJournal = _suppressCollisionOwnerJournal; - _suppressCollisionOwnerJournal = true; - try - { - Engine.DemoteLandblockToTerrain(canonical); - } - finally - { - _suppressCollisionOwnerJournal = suppressOwnerJournal; - } - foreach ((_, PreparedLandblockCollisionGeneration prepared) in - _preparedCollisionGenerations) - { - prepared.RecordDemotion(canonical); - } - return new RuntimeCollisionAcknowledgement( - canonical, - _collisionGenerations[canonical], - resident, - Ready: Engine.IsLandblockTerrainResident(canonical)); - } + if (canonical == 0u) + throw new ArgumentOutOfRangeException(nameof(landblockId)); + if (kind is RuntimeCollisionPrefixMutationKind.Activation) + throw new ArgumentOutOfRangeException(nameof(kind)); - public RuntimeCollisionAcknowledgement WithdrawCollision( - uint landblockId) - { - EnsureNotDisposed(); - EnsureCollisionMutationThread(); - uint canonical = CanonicalLandblock(landblockId); - bool resident = Engine.IsLandblockTerrainResident(canonical); - InvalidateCollisionAdmission(canonical); - bool suppressOwnerJournal = _suppressCollisionOwnerJournal; - _suppressCollisionOwnerJournal = true; - try + if (!_collisionPrefixMutations.TryGetValue( + canonical, + out RuntimeCollisionPrefixMutation? mutation)) { - Engine.RemoveLandblock(canonical); + ulong currentGeneration = _collisionGenerations.TryGetValue( + canonical, + out ulong current) + ? current + : 0UL; + RuntimeCollisionAdmission? admission = + _collisionAdmissions.GetValueOrDefault(canonical); + ulong previousGeneration = admission?.PreviousGeneration + ?? currentGeneration; + ulong invalidatedGeneration = admission is not null + ? admission.Generation + : checked(currentGeneration + 1UL); + ulong targetGeneration = checked( + Math.Max(currentGeneration, invalidatedGeneration) + 1UL); + SetPosition.BeginCollisionGeneration( + canonical, + targetGeneration); + RuntimeCollisionPrefixQuiescenceToken token = + BeginCollisionPrefixQuiescence( + canonical, + targetGeneration, + includeOutdoorCells: + kind is RuntimeCollisionPrefixMutationKind.Withdrawal); + mutation = new RuntimeCollisionPrefixMutation + { + Kind = kind, + LandblockId = canonical, + PreviousGeneration = previousGeneration, + InvalidatedGeneration = invalidatedGeneration, + TargetGeneration = targetGeneration, + Quiescence = token, + Admission = admission, + Prepared = _preparedCollisionGenerations.GetValueOrDefault( + canonical), + WasResident = Engine.IsLandblockTerrainResident(canonical), + }; + _collisionPrefixMutations.Add(canonical, mutation); } - finally + if (mutation.Kind != kind) { - _suppressCollisionOwnerJournal = suppressOwnerJournal; + throw new InvalidOperationException( + "A different collision-prefix mutation owns this landblock."); } - foreach ((_, PreparedLandblockCollisionGeneration prepared) in - _preparedCollisionGenerations) + + if (!mutation.EngineMutationCommitted) { - prepared.RecordWithdrawal(canonical); + if (!TryAcquireCollisionPrefixMutationPermission( + mutation.Quiescence, + out RuntimeCollisionPrefixMutationPermission permission) + || !IsCollisionPrefixMutationPermissionCurrent(permission)) + { + return PendingRetirement(mutation); + } + mutation.Permission = permission; + CommitCollisionInvalidation(mutation); + + bool suppressOwnerJournal = _suppressCollisionOwnerJournal; + _suppressCollisionOwnerJournal = true; + try + { + if (kind is RuntimeCollisionPrefixMutationKind.Demotion) + Engine.DemoteLandblockToTerrain(canonical); + else + Engine.RemoveLandblock(canonical); + AdvanceCollisionWorldAuthority(); + } + finally + { + _suppressCollisionOwnerJournal = suppressOwnerJournal; + } + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + if (kind is RuntimeCollisionPrefixMutationKind.Demotion) + prepared.RecordDemotion(canonical); + else + prepared.RecordWithdrawal(canonical); + } + mutation.EngineMutationCommitted = true; + mutation.Ready = kind is RuntimeCollisionPrefixMutationKind.Demotion + && Engine.IsLandblockTerrainResident(canonical); + if (mutation.Ready) + { + SetPosition.CommitCollisionGeneration( + canonical, + mutation.TargetGeneration, + ready: true); + } } - return new RuntimeCollisionAcknowledgement( - canonical, - _collisionGenerations[canonical], - resident, - Ready: false); + + bool completed = SetPosition.ReleaseCollisionPrefixAfterMutation( + mutation.Quiescence, + mutation.TargetGeneration, + mutation.Ready); + if (completed) + { + _collisionPrefixMutations.Remove(canonical); + TrimCollisionOwnerJournal(); + } + return new RuntimeCollisionMutationResult( + new RuntimeCollisionAcknowledgement( + canonical, + mutation.TargetGeneration, + mutation.WasResident, + mutation.Ready), + completed); } public void Dispose() @@ -2419,6 +2762,7 @@ public sealed class RuntimePhysicsState : IDisposable _spatialRemotes.Clear(); _spatialProjectiles.Clear(); _spatialRoots.Clear(); + _collisionPrefixMutations.Clear(); _collisionAdmissions.Clear(); _collisionGenerations.Clear(); CellCommitted = null; @@ -2570,35 +2914,6 @@ public sealed class RuntimePhysicsState : IDisposable } } - private void InvalidateCollisionAdmission(uint landblockId) - { - AdvanceCollisionWorldAuthority(); - ulong currentGeneration = _collisionGenerations.TryGetValue( - landblockId, - out ulong current) - ? current - : 0UL; - ulong invalidatedGeneration = _collisionAdmissions.TryGetValue( - landblockId, - out RuntimeCollisionAdmission? admission) - ? admission.Generation - : checked(currentGeneration + 1UL); - ulong generation = checked( - Math.Max(currentGeneration, invalidatedGeneration) + 1UL); - _collisionGenerations[landblockId] = generation; - SetPosition.CancelCollisionGeneration( - landblockId, - invalidatedGeneration); - _collisionAdmissions.Remove(landblockId); - if (_preparedCollisionGenerations.Remove( - landblockId, - out PreparedLandblockCollisionGeneration? prepared)) - { - prepared.Dispose(); - } - TrimCollisionOwnerJournal(); - } - internal bool TryPrepareSpatialRootAdmission(RuntimeEntityRecord record) { EnsureNotDisposed(); @@ -2960,7 +3275,13 @@ public sealed class RuntimePhysicsState : IDisposable { return; } - subscribers.Remove(prepared); + for (int index = 0; index < subscribers.Count; index++) + { + if (!ReferenceEquals(subscribers[index], prepared)) + continue; + subscribers.RemoveAt(index); + break; + } if (subscribers.Count == 0) _collisionOwnerSubscribers.Remove(ownerId); } diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index f651eb92..b817d45d 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -106,8 +106,7 @@ internal readonly record struct RuntimeCollisionPrefixMutationPermission( RuntimeCollisionPrefixQuiescenceToken Quiescence, ImmutableArray Withdrawals) { - internal bool IsValid => Quiescence.IsValid - && !Withdrawals.IsDefault; + internal bool IsValid => Quiescence.IsValid; } internal readonly record struct RuntimeCollisionEvaluationAuthority( @@ -373,8 +372,9 @@ internal sealed class RuntimeSetPositionState : IDisposable { get; } = []; internal bool ResidentsParked { get; set; } internal bool PermissionIssued { get; set; } - internal bool AbortReleaseInProgress { get; set; } - internal ulong AbortRestoreGeneration { get; set; } + internal bool ReleaseInProgress { get; set; } + internal ulong ReleaseGeneration { get; set; } + internal bool ReleaseGenerationReady { get; set; } } private sealed class ContactCommitGuard( @@ -504,10 +504,10 @@ internal sealed class RuntimeSetPositionState : IDisposable if (_collisionPrefixQuiescence.TryGetValue( prefix, out CollisionPrefixQuiescence? active) - && active.AbortReleaseInProgress) + && active.ReleaseInProgress) { throw new InvalidOperationException( - $"Collision quiescence 0x{prefix:X8}/{active.Token.OperationId} is restoring its retained generation."); + $"Collision quiescence 0x{prefix:X8}/{active.Token.OperationId} is releasing its retained residents."); } _collisionPrefixQuiescence.Remove( prefix, @@ -592,7 +592,9 @@ internal sealed class RuntimeSetPositionState : IDisposable current.PermissionIssued = true; permission = new RuntimeCollisionPrefixMutationPermission( current.Token, - current.RetainedWithdrawals.ToImmutableArray()); + current.RetainedWithdrawals.Count == 0 + ? default + : current.RetainedWithdrawals.ToImmutableArray()); return true; } @@ -635,39 +637,93 @@ internal sealed class RuntimeSetPositionState : IDisposable return removedBeforePark; } - if (successorGeneration == 0UL - || !successorReady - || current.PendingWithdrawals.Count != 0) + if (successorGeneration == 0UL) + return false; + + return AdvanceCollisionPrefixRelease( + token, + successorGeneration, + successorReady, + requireMutationPermission: false); + } + + internal bool CancelCollisionPrefixQuiescenceToUnavailable( + in RuntimeCollisionPrefixQuiescenceToken token) + { + EnsureNotDisposed(); + return AdvanceCollisionPrefixRelease( + token, + generation: 0UL, + ready: false, + requireMutationPermission: false); + } + + internal bool ReleaseCollisionPrefixAfterMutation( + in RuntimeCollisionPrefixQuiescenceToken token, + ulong activeGeneration, + bool ready) + { + EnsureNotDisposed(); + return AdvanceCollisionPrefixRelease( + token, + activeGeneration, + ready, + requireMutationPermission: true); + } + + private bool AdvanceCollisionPrefixRelease( + in RuntimeCollisionPrefixQuiescenceToken token, + ulong generation, + bool ready, + bool requireMutationPermission) + { + if ((generation == 0UL && ready) + || !TryGetCurrentQuiescence( + token, + out CollisionPrefixQuiescence? state)) { return false; } - if (!current.AbortReleaseInProgress) + CollisionPrefixQuiescence current = state!; + if (current.PendingWithdrawals.Count != 0) + return false; + if (requireMutationPermission + && !current.PermissionIssued + && !current.ReleaseInProgress) { - current.AbortReleaseInProgress = true; - current.AbortRestoreGeneration = successorGeneration; - current.PermissionIssued = false; - RebindQuiescedDeferredOperations( - token, - successorGeneration, - ready: true); + return false; } - else if (current.AbortRestoreGeneration != successorGeneration) + if (!current.ReleaseInProgress) + { + current.ReleaseInProgress = true; + current.ReleaseGeneration = generation; + current.ReleaseGenerationReady = ready; + current.PermissionIssued = false; + } + else if (current.ReleaseGeneration != generation + || current.ReleaseGenerationReady != ready) { return false; } // A re-entrant/network placement may have joined the still-closed - // prefix after abort release started. Transfer every exact newcomer - // on each poll before deciding the barrier can open. - RebindQuiescedDeferredOperations( - token, - successorGeneration, - ready: true); + // prefix after release started. Transfer every exact newcomer on each + // poll before deciding the barrier can open. + if (_operations.Count != 0) + { + RebindQuiescedDeferredOperations( + token, + ready ? generation : 0UL, + ready, + releaseUnavailable: !ready); + } if (current.PendingRestorePlacements.Count != 0 || HasQuiescedDeferredOperations(token.LandblockPrefix)) + { return false; + } bool removed = _collisionPrefixQuiescence.Remove( token.LandblockPrefix); if (removed) @@ -675,19 +731,6 @@ internal sealed class RuntimeSetPositionState : IDisposable return removed; } - internal bool CompleteCollisionPrefixQuiescence( - in RuntimeCollisionPrefixMutationPermission permission) - { - EnsureNotDisposed(); - if (!IsCollisionPrefixMutationPermissionCurrent(permission)) - return false; - bool removed = _collisionPrefixQuiescence.Remove( - permission.Quiescence.LandblockPrefix); - if (removed) - _physics.AdvanceCollisionQuiescenceAuthority(); - return removed; - } - internal void BindEventStream(RuntimeEntityObjectEventStream events) { EnsureNotDisposed(); @@ -770,6 +813,34 @@ internal sealed class RuntimeSetPositionState : IDisposable portal, captureMoverPreparationAuthority: true); + internal void PrepareDormantLocalActivationOwnership( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeEntityPlacementToken token) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(body); + if (!token.IsValid + || record.Key != token.Entity + || !_operations.TryGetValue(token.Entity, out Operation? operation) + || operation.Token != token + || operation.Stage is not RuntimeEntityPlacementStage + .AwaitingPreparation + || !ReferenceEquals(operation.Record, record) + || record.PhysicsBody is not null + || !IsCurrent(operation) + || body.InWorld + || (body.TransientState & TransientStateFlags.Active) != 0) + { + throw new InvalidOperationException( + "Dormant local activation must bind to the exact current placement owner."); + } + + operation.Body = body; + operation.DormantLocalActivation = true; + } + private RuntimeEntityPlacementToken BeginAcceptedPlacementCore( RuntimeEntityRecord record, ulong expectedPositionAuthorityVersion, @@ -2531,6 +2602,8 @@ internal sealed class RuntimeSetPositionState : IDisposable uint prefix, bool includeOutdoorCells) { + if (_physics.SpatialRootCount == 0) + return false; var roots = new List(); _physics.CopySpatialRootsTo(roots); for (int index = 0; index < roots.Count; index++) @@ -2586,7 +2659,7 @@ internal sealed class RuntimeSetPositionState : IDisposable uint prefix = state.Token.LandblockPrefix; foreach (Operation operation in _operations.Values) { - if (operation.WakeableLostCell) + if (operation.WakeableLostCell || operation.DormantLocalActivation) continue; if (PlacementTouchesPrefix(operation.Command.Physics, prefix) || ResultTouchesPrefix(operation.Result, prefix) @@ -2703,7 +2776,8 @@ internal sealed class RuntimeSetPositionState : IDisposable || !_collisionPrefixQuiescence.TryGetValue( operation.CollisionPrefix, out CollisionPrefixQuiescence? state) - || !state.AbortReleaseInProgress) + || !state.ReleaseInProgress + || !state.ReleaseGenerationReady) { return; } @@ -2745,21 +2819,40 @@ internal sealed class RuntimeSetPositionState : IDisposable private void RebindQuiescedDeferredOperations( in RuntimeCollisionPrefixQuiescenceToken token, ulong successorGeneration, - bool ready) + bool ready, + bool releaseUnavailable = false) { foreach (Operation operation in _operations.Values.ToArray()) { + bool unavailableAfterReadyCommit = ready + && operation.CollisionQuiescenceHeld + && operation.CollisionGeneration == 0UL + && operation.CollisionPrefix == token.LandblockPrefix; if (!operation.WakeableLostCell - || operation.CollisionGeneration != token.CollisionGeneration - || operation.CollisionPrefix != token.LandblockPrefix) + || !operation.CollisionQuiescenceHeld + || operation.CollisionPrefix != token.LandblockPrefix + || (operation.CollisionGeneration + != token.CollisionGeneration + && !unavailableAfterReadyCommit)) { continue; } UnindexDeferred(operation); - operation.CollisionGeneration = successorGeneration; + ulong reboundGeneration = releaseUnavailable + || unavailableAfterReadyCommit + ? 0UL + : successorGeneration; + operation.CollisionGeneration = reboundGeneration; operation.CollisionGenerationReady = ready - && successorGeneration != 0UL; - if (successorGeneration != 0UL) + && reboundGeneration != 0UL; + if (releaseUnavailable || unavailableAfterReadyCommit) + { + operation.CollisionQuiescenceHeld = false; + operation.Stage = operation.RequiresPreparation + ? RuntimeEntityPlacementStage.AwaitingPreparation + : RuntimeEntityPlacementStage.AwaitingCell; + } + if (reboundGeneration != 0UL) IndexDeferred(operation); else IndexUnboundDeferred(operation); @@ -3073,26 +3166,27 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.Command.Physics, out CollisionPrefixQuiescence? blocking)) { - if (blocking!.AbortReleaseInProgress + if (blocking!.ReleaseInProgress + && blocking.ReleaseGenerationReady && operation.CollisionQuiescenceHeld && operation.CollisionPrefix == blocking.Token.LandblockPrefix) { - // The old collision generation remains active. Keep the - // admission barrier closed to new commands while this exact - // parked operation restores and its Place receipt drains. + // The selected collision generation is active. Keep the + // admission barrier closed while this exact parked operation + // restores and its Place receipt drains. restoringQuiescence = blocking.Token; } else { - UnindexDeferred(operation); - operation.CollisionPrefix = blocking.Token.LandblockPrefix; - operation.CollisionGeneration = blocking.Token.CollisionGeneration; - operation.CollisionGenerationReady = false; - operation.CollisionQuiescenceHeld = true; - operation.Stage = RuntimeEntityPlacementStage.QuiescenceHeld; - IndexDeferred(operation); - return; + UnindexDeferred(operation); + operation.CollisionPrefix = blocking.Token.LandblockPrefix; + operation.CollisionGeneration = blocking.Token.CollisionGeneration; + operation.CollisionGenerationReady = false; + operation.CollisionQuiescenceHeld = true; + operation.Stage = RuntimeEntityPlacementStage.QuiescenceHeld; + IndexDeferred(operation); + return; } } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index a5f9106d..a3287ea4 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -2,9 +2,13 @@ using System.Collections.Immutable; using System.Numerics; using System.Reflection; using AcDream.App.Streaming; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -19,6 +23,91 @@ public sealed class LandblockPhysicsPublisherTests private static readonly float[] HeightTable = Enumerable.Range(0, 256).Select(index => (float)index).ToArray(); + [Fact] + public void ReplacementYieldsAtHeldWithdrawAndPlaceWithoutPostEngineReseal() + { + using var lifetime = new RuntimeEntityObjectLifetime( + new PhysicsDataCache()); + RuntimePhysicsState physics = lifetime.Physics; + var publisher = new LandblockPhysicsPublisher(physics, HeightTable); + Publish(publisher, Build(FirstLandblock)); + + const uint guid = 0x70004101u; + const uint cell = 0xA9B40001u; + Vector3 position = new(10f, 10f, 0f); + RuntimeEntityRecord record = lifetime.RegisterEntity( + RuntimeSpawn(guid, cell, position)).Canonical!; + lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity); + lifetime.Entities.SetFullCell(record, cell, FirstLandblock); + var body = new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cell, position, position); + lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + physics.AcknowledgeSpatialProjection(record, spatial: true); + RuntimePlacementProjectionToken seeded = SeedRuntimePlacement( + physics, + record, + cell, + position); + Assert.True(physics.SetPosition.AcknowledgeProjection(seeded)); + + LandblockPhysicsPublication receipt = Begin( + publisher, + Build(FirstLandblock)); + Assert.False(publisher.CompletePublication(receipt)); + Assert.False(receipt.EngineMutationCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawal.Kind); + + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + Assert.False(publisher.CompletePublication(receipt)); + Assert.True(receipt.EngineMutationCommitted); + Assert.True(receipt.SealCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot placement)); + Assert.Equal(RuntimePlacementProjectionKind.Place, placement.Kind); + + Assert.False(publisher.CompletePublication(receipt)); + Assert.True(receipt.SealCommitted); + Assert.True(receipt.EngineMutationCommitted); + + Assert.True(physics.SetPosition.AcknowledgeProjection(placement.Token)); + Assert.True(publisher.CompletePublication(receipt)); + Assert.True(receipt.CompletionCommitted); + + LandblockPhysicsPublication cancelled = Begin( + publisher, + Build(FirstLandblock)); + Assert.False(publisher.CompletePublication(cancelled)); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot cancelWithdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + cancelWithdrawal.Token)); + Assert.False(publisher.CompletePublication(cancelled)); + Assert.True(cancelled.EngineMutationCommitted); + Assert.True(cancelled.SealCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot cancelPlacement)); + + Assert.False(cancelled.TryCancel()); + Assert.True(cancelled.CancellationRequested); + Assert.True(cancelled.SealCommitted); + Assert.True(physics.SetPosition.AcknowledgeProjection( + cancelPlacement.Token)); + Assert.True(cancelled.TryCancel()); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + [Fact] public void Constructor_ClonesHeightTableAndRejectsIncompleteInput() { @@ -821,6 +910,151 @@ public sealed class LandblockPhysicsPublisherTests LandblockBuild build) => publisher.BeginPublication(RenderReceipt(build)); + private static RuntimePlacementProjectionToken SeedRuntimePlacement( + RuntimePhysicsState physics, + RuntimeEntityRecord record, + uint cell, + Vector3 position) + { + Type coreAssemblyMarker = typeof(PhysicsEngine); + Type requestType = coreAssemblyMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsSetPositionRequest", + throwOnError: true)!; + Type flagsType = coreAssemblyMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsSetPositionFlags", + throwOnError: true)!; + Type placementClassType = coreAssemblyMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsPlacementClass", + throwOnError: true)!; + object request = Activator.CreateInstance( + requestType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + position, + Quaternion.Identity, + cell, + position, + ImmutableArray.Empty, + 1f, + 0.4f, + 0.4f, + PhysicsStateFlags.None, + ObjectInfoState.None, + 0u, + Enum.ToObject(placementClassType, 0), + Enum.ToObject(flagsType, 0x011u), + Vector3.Zero, + 0f, + 0f, + 0u, + cell, + ], + culture: null)!; + + Type runtimeAssemblyMarker = typeof(RuntimePhysicsState); + Type commandType = runtimeAssemblyMarker.Assembly.GetType( + "AcDream.Runtime.Physics.RuntimeSetPositionCommand", + throwOnError: true)!; + Type kindType = runtimeAssemblyMarker.Assembly.GetType( + "AcDream.Runtime.Physics.RuntimeSetPositionOperationKind", + throwOnError: true)!; + object command = Activator.CreateInstance( + commandType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + request, + Enum.ToObject(kindType, 2), + 10d, + 0UL, + 0f, + 0f, + default(RuntimePortalPlacementAuthority), + ], + culture: null)!; + MethodInfo apply = physics.SetPosition.GetType().GetMethod( + "Apply", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException("Runtime SetPosition.Apply"); + object outcome = apply.Invoke( + physics.SetPosition, + [record, record.PositionAuthorityVersion, command])!; + return (RuntimePlacementProjectionToken)(outcome.GetType().GetProperty( + "Projection", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?.GetValue(outcome) + ?? throw new MissingMemberException("Runtime placement projection")); + } + + private static WorldSession.EntitySpawn RuntimeSpawn( + uint guid, + uint cell, + Vector3 position) + { + var serverPosition = new CreateObject.ServerPosition( + cell, + position.X, + position.Y, + position.Z, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var spawnPhysics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Gravity, + Position: serverPosition, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + serverPosition, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "app-collision-publication-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.Gravity, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: spawnPhysics); + } + private static LandblockRenderPublication RenderReceipt(LandblockBuild build) { var publisher = new LandblockRenderPublisher( diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs index 5904dddb..3c8c84fe 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs @@ -9,6 +9,106 @@ namespace AcDream.App.Tests.Streaming; public sealed class LandblockRetirementCoordinatorTests { + [Fact] + public void PendingPhysicsStageYieldsBeforeTerrainAndRemainsRetryable() + { + const uint landblockId = 0x2020FFFFu; + var ticket = new LandblockRetirementTicket( + new GpuLandblockRetirement( + landblockId, + LandblockRetirementKind.Full, + Array.Empty()), + LandblockRetirementStage.Physics + | LandblockRetirementStage.Terrain); + int physicsPolls = 0; + int terrainPolls = 0; + + Assert.Equal( + LandblockRetirementOperationResult.Pending, + ticket.RunOnceStep( + LandblockRetirementStage.Physics, + () => + { + physicsPolls++; + return false; + })); + Assert.Equal(LandblockRetirementStage.Physics, ticket.NextIncompleteStage); + Assert.Equal(1, physicsPolls); + Assert.Equal(0, terrainPolls); + + Assert.Equal( + LandblockRetirementOperationResult.Progressed, + ticket.RunOnceStep( + LandblockRetirementStage.Physics, + () => + { + physicsPolls++; + return true; + })); + Assert.Equal(LandblockRetirementStage.Terrain, ticket.NextIncompleteStage); + Assert.True(ticket.RunOnce( + LandblockRetirementStage.Terrain, + () => terrainPolls++)); + Assert.True(ticket.IsComplete); + Assert.Equal(2, physicsPolls); + Assert.Equal(1, terrainPolls); + } + + [Fact] + public void BudgetedPendingPhysicsPollsExactlyOncePerAdvance() + { + const uint landblockId = 0x2023FFFFu; + var state = StateWith(landblockId); + int physicsPolls = 0; + int terrainPolls = 0; + bool acknowledgePhysics = false; + LandblockRetirementOperationResult AdvancePresentation( + LandblockRetirementTicket ticket) => + ticket.NextIncompleteStage switch + { + LandblockRetirementStage.Physics => ticket.RunOnceStep( + LandblockRetirementStage.Physics, + () => + { + physicsPolls++; + return acknowledgePhysics; + }), + LandblockRetirementStage.Terrain => ticket.RunOnceStep( + LandblockRetirementStage.Terrain, + () => terrainPolls++), + { } stage => ticket.RunOnceStep(stage, () => { }), + }; + LandblockRetirementCoordinator coordinator = + LandblockRetirementCoordinator.CreateBudgeted( + state, + AdvancePresentation, + ticket => + { + while (!ticket.IsComplete) + _ = AdvancePresentation(ticket); + }); + coordinator.BeginFull(landblockId); + + var first = new StreamingWorkMeter(Budget(maxEntityOperations: 64)); + coordinator.Advance(first); + first.FinishFrame(); + + Assert.Equal(1, physicsPolls); + Assert.Equal(0, terrainPolls); + Assert.Equal(0, first.Snapshot.FailureCount); + Assert.Equal(1, coordinator.PendingCount); + + acknowledgePhysics = true; + var second = new StreamingWorkMeter(Budget(maxEntityOperations: 64)); + coordinator.Advance(second); + second.FinishFrame(); + + Assert.Equal(2, physicsPolls); + Assert.Equal(1, terrainPolls); + Assert.Equal(0, second.Snapshot.FailureCount); + Assert.Equal(0, coordinator.PendingCount); + } + [Fact] public void EntityStageFailure_DetachesImmediately_AndResumesAtFailedEntity() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 7bcb7114..131b4b6e 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -1,6 +1,8 @@ using System.Buffers.Binary; +using System.Collections.Immutable; using System.Net; using System.Numerics; +using System.Reflection; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -605,7 +607,7 @@ public sealed class HeadlessSessionHostTests using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; const uint landblockId = 0xA9B4FFFFu; - _ = HeadlessCollisionGenerationTransaction.Execute( + CompleteCollisionGeneration( physics, landblockId, afterAdmission: null, @@ -616,7 +618,7 @@ public sealed class HeadlessSessionHostTests CollisionAssets(landblockId, 10f))); Assert.Throws(() => - HeadlessCollisionGenerationTransaction.Execute( + CompleteCollisionGeneration( physics, landblockId, _ => throw new FixtureCollisionPublicationException(), @@ -629,13 +631,137 @@ public sealed class HeadlessSessionHostTests Assert.Equal(0, ownership.CollisionAdmissionCount); } + [Fact] + public void CollisionTransactionYieldsTheFirstNonterminalRuntimePoll() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint landblockId = 0xA9B4FFFFu; + HeadlessCollisionGenerationTransaction transaction = + HeadlessCollisionGenerationTransaction.Begin( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 10f))); + + HeadlessCollisionGenerationAdvance advance; + do + { + advance = transaction.Advance(); + Assert.True(advance.Progressed); + } + while (!advance.YieldToCaller); + + Assert.False(advance.Completed); + Assert.False(advance.WaitingForProjectionAcknowledgement); + Assert.False(transaction.CompletionCommitted); + + do + { + advance = transaction.Advance(); + Assert.True(advance.Progressed); + } + while (!advance.Completed && !advance.YieldToCaller); + if (!advance.Completed) + advance = transaction.Advance(); + + Assert.True(advance.Completed); + Assert.True(transaction.EngineMutationCommitted); + Assert.True(transaction.CompletionCommitted); + Assert.Equal(0, physics.CaptureOwnership().CollisionAdmissionCount); + } + + [Fact] + public void CollisionTransactionRetainsPostEngineCancellationUntilPlaceAck() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint landblockId = 0xA9B4FFFFu; + CompleteCollisionGeneration( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 10f))); + + const uint guid = 0x70004201u; + const uint cell = 0xA9B40001u; + Vector3 position = new(10f, 10f, 0f); + RuntimeEntityRecord record = lifetime.RegisterEntity( + Spawn(guid)).Canonical!; + lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity); + lifetime.Entities.SetFullCell(record, cell, landblockId); + var body = new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cell, position, position); + lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + physics.AcknowledgeSpatialProjection(record, spatial: true); + RuntimePlacementProjectionToken seeded = SeedRuntimePlacement( + physics, + record, + cell, + position); + Assert.True(physics.SetPosition.AcknowledgeProjection(seeded)); + + HeadlessCollisionGenerationTransaction transaction = + HeadlessCollisionGenerationTransaction.Begin( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 20f))); + HeadlessCollisionGenerationAdvance advance; + do + { + advance = transaction.Advance(); + } + while (!advance.WaitingForProjectionAcknowledgement); + Assert.False(transaction.EngineMutationCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + + do + { + advance = transaction.Advance(); + } + while (!advance.WaitingForProjectionAcknowledgement); + Assert.True(transaction.EngineMutationCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot placement)); + + Assert.False(transaction.TryCancel()); + Assert.True(physics.SetPosition.AcknowledgeProjection(placement.Token)); + Assert.True(transaction.TryCancel()); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + [Fact] public void CollisionTransactionCancelsStagingFaultWithoutWithdrawingActiveWorld() { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; const uint landblockId = 0xA9B4FFFFu; - _ = HeadlessCollisionGenerationTransaction.Execute( + CompleteCollisionGeneration( physics, landblockId, afterAdmission: null, @@ -646,7 +772,7 @@ public sealed class HeadlessSessionHostTests CollisionAssets(landblockId, 10f))); Assert.Throws(() => - HeadlessCollisionGenerationTransaction.Execute( + CompleteCollisionGeneration( physics, landblockId, afterAdmission: null, @@ -714,6 +840,107 @@ public sealed class HeadlessSessionHostTests runtime.MovementOwner.Controller = controller; } + private static void CompleteCollisionGeneration( + RuntimePhysicsState physics, + uint landblockId, + Action? afterAdmission, + Action stage) + { + HeadlessCollisionGenerationTransaction transaction = + HeadlessCollisionGenerationTransaction.Begin( + physics, + landblockId, + afterAdmission, + stage); + while (true) + { + HeadlessCollisionGenerationAdvance advance = transaction.Advance(); + if (advance.Completed) + return; + Assert.True(advance.Progressed); + Assert.False(advance.WaitingForProjectionAcknowledgement); + } + } + + private static RuntimePlacementProjectionToken SeedRuntimePlacement( + RuntimePhysicsState physics, + RuntimeEntityRecord record, + uint cell, + Vector3 position) + { + Type coreMarker = typeof(PhysicsEngine); + Type requestType = coreMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsSetPositionRequest", + throwOnError: true)!; + Type flagsType = coreMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsSetPositionFlags", + throwOnError: true)!; + Type placementClassType = coreMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsPlacementClass", + throwOnError: true)!; + object request = Activator.CreateInstance( + requestType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + position, + Quaternion.Identity, + cell, + position, + ImmutableArray.Empty, + 1f, + 0.4f, + 0.4f, + PhysicsStateFlags.None, + ObjectInfoState.None, + 0u, + Enum.ToObject(placementClassType, 0), + Enum.ToObject(flagsType, 0x011u), + Vector3.Zero, + 0f, + 0f, + 0u, + cell, + ], + culture: null)!; + Type runtimeMarker = typeof(RuntimePhysicsState); + Type commandType = runtimeMarker.Assembly.GetType( + "AcDream.Runtime.Physics.RuntimeSetPositionCommand", + throwOnError: true)!; + Type kindType = runtimeMarker.Assembly.GetType( + "AcDream.Runtime.Physics.RuntimeSetPositionOperationKind", + throwOnError: true)!; + object command = Activator.CreateInstance( + commandType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + request, + Enum.ToObject(kindType, 2), + 10d, + 0UL, + 0f, + 0f, + default(RuntimePortalPlacementAuthority), + ], + culture: null)!; + MethodInfo apply = physics.SetPosition.GetType().GetMethod( + "Apply", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException("Runtime SetPosition.Apply"); + object outcome = apply.Invoke( + physics.SetPosition, + [record, record.PositionAuthorityVersion, command])!; + return (RuntimePlacementProjectionToken)(outcome.GetType().GetProperty( + "Projection", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?.GetValue(outcome) + ?? throw new MissingMemberException("Runtime placement projection")); + } + private static RuntimeLandblockCollisionAssets CollisionAssets( uint landblockId, float terrainHeight) diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs index 1768124f..0c432e83 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -66,7 +66,33 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests .IsExactPreparedPlacementCurrent( fixture.Record, fixture.Placement, - fixture.Command)); + fixture.Command)); + } + + [Fact] + public void DormantOwnershipPreflightFailurePublishesNoCanonicalOwner() + { + using var fixture = new Fixture(); + RuntimeLocalPlayerPhysicsPublicationToken publication = + fixture.Prepare(); + Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( + fixture.Record, + publishWithdrawal: false)); + var unownedBody = new PhysicsBody(); + + Assert.Throws(() => fixture.Lifetime.Physics + .SetPosition.PrepareDormantLocalActivationOwnership( + fixture.Record, + unownedBody, + publication.Placement)); + + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + Assert.False(unownedBody.InWorld); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(1, fixture.Owner.CaptureOwnership().CandidateCount); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); } [Fact] @@ -2467,32 +2493,48 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared) { - while (true) + bool engineCommitted = false; + for (int poll = 0; poll < 10_000; poll++) { - while (!physics.AdvanceCollisionRetainedOwnerCapture( - admission, - prepared).Completed) + if (!engineCommitted) { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (!seal.Completed) + continue; } - foreach (uint ownerId in prepared.RetainedOwnerIds) + RuntimeCollisionGenerationCommit result = + physics.CommitCollisionGeneration(admission, prepared); + if (result.Completed) + return result; + while (physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection)) { - physics.RefreshCollisionRetainedOwner( - admission, - prepared, - ownerId); + Assert.True(physics.SetPosition.AcknowledgeProjection( + projection.Token)); } - RuntimeCollisionSealStep seal; - do - { - seal = physics.AdvanceCollisionGenerationSeal( - admission, - prepared); - } - while (!seal.Completed && !seal.Restarted); - if (seal.Completed) - break; + engineCommitted = result.EngineCommitted; } - return physics.CommitCollisionGeneration(admission, prepared); + throw new InvalidOperationException( + "Collision generation did not complete its Runtime mutation transaction."); } private static RuntimeLandblockCollisionAssets CollisionAssets( diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs new file mode 100644 index 00000000..090a5f29 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs @@ -0,0 +1,479 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed partial class RuntimeCollisionPrefixQuiescenceTests +{ + [Fact] + public void ActivationWaitsForExactWithdrawAndPlaceReceipts() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimeEntityRecord record = fixture.Add( + 0x70003101u, + 1, + CellP, + new Vector3(11f, 40f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(11.5f, 40f, 0f)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded.Projection)); + + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + int committedNotifications = 0; + physics.CollisionGenerationCommitted += _ => committedNotifications++; + + RuntimeCollisionGenerationCommit first = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(first.EngineCommitted); + Assert.False(first.Completed); + Assert.False(physics.IsSpatialRoot(record)); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawn)); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawn.Kind); + + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawn.Token)); + _ = SealMutation(physics, admission, prepared); + RuntimeCollisionGenerationCommit transferred = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(transferred.EngineCommitted); + Assert.False(transferred.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + RuntimeCollisionGenerationCommit completed = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(completed.EngineCommitted); + Assert.True(completed.Completed); + Assert.True(physics.IsSpatialRoot(record)); + Assert.Equal(1, committedNotifications); + Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixMutationCount); + Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixQuiescenceCount); + } + + [Fact] + public void DemotionParksOnlyIndoorResidentsAndLeavesOutdoorPresentationLive() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimeEntityRecord outdoor = fixture.Add( + 0x70003102u, + 1, + CellP, + new Vector3(12f, 41f, 0f)); + RuntimeEntityRecord indoor = fixture.Add( + 0x70003103u, + 1, + PrefixP | 0x0100u, + new Vector3(13f, 41f, 0f)); + + RuntimeCollisionMutationResult first = + fixture.Lifetime.Physics.DemoteCollisionToTerrain(PrefixP); + Assert.False(first.Completed); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(outdoor)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(indoor)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.Equal(indoor.Key, withdrawal.Token.Entity); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(withdrawal.Token)); + + RuntimeCollisionMutationResult completed = + fixture.Lifetime.Physics.DemoteCollisionToTerrain(PrefixP); + Assert.True(completed.Completed); + Assert.True(completed.Ready); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(outdoor)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(indoor)); + Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection(out _)); + } + + [Fact] + public void WithdrawalLeavesResidentUnboundUntilLaterGenerationWakesIt() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimeEntityRecord record = fixture.Add( + 0x70003104u, + 1, + CellP, + new Vector3(14f, 42f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(14.5f, 42f, 0f)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded.Projection)); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + + RuntimeCollisionMutationResult withdrawal = + physics.WithdrawCollision(PrefixP); + Assert.False(withdrawal.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot removed)); + Assert.True(physics.SetPosition.AcknowledgeProjection(removed.Token)); + withdrawal = physics.WithdrawCollision(PrefixP); + Assert.True(withdrawal.Completed); + Assert.False(withdrawal.Ready); + Assert.False(physics.IsSpatialRoot(record)); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + RuntimeCollisionGenerationCommit pending = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(pending.Completed); + _ = SealMutation(physics, admission, prepared); + RuntimeCollisionGenerationCommit completed = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(completed.EngineCommitted); + Assert.False(completed.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + completed = physics.CommitCollisionGeneration(admission, prepared); + Assert.True(completed.Completed); + Assert.True(physics.IsSpatialRoot(record)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SessionResetClearsPreAndPostEngineMutationDebt(bool postEngine) + { + using var fixture = new Fixture(bindGeneration: true); + RuntimeEntityRecord record = fixture.Add( + 0x70003105u, + 1, + CellP, + new Vector3(15f, 43f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(15.5f, 43f, 0f)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded.Projection)); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Completed); + if (postEngine) + { + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + withdrawal.Token)); + _ = SealMutation(physics, admission, prepared); + RuntimeCollisionGenerationCommit transferred = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(transferred.EngineCommitted); + Assert.False(transferred.Completed); + } + + _ = fixture.Lifetime.BeginSessionClear(); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CommittedCollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.PendingCollisionPrefixProjectionCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void CancelledActivationCannotCommitWhileRestoreAckIsPending() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission baselineAdmission = + physics.BeginCollisionAdmission(PrefixP); + using (PreparedLandblockCollisionGeneration baseline = + PrepareSealedMutation(physics, baselineAdmission, PrefixP)) + { + Assert.False(physics.CommitCollisionGeneration( + baselineAdmission, + baseline).Completed); + _ = SealMutation(physics, baselineAdmission, baseline); + Assert.True(physics.CommitCollisionGeneration( + baselineAdmission, + baseline).Completed); + } + + RuntimeEntityRecord record = fixture.Add( + 0x70003106u, + 1, + CellP, + new Vector3(16f, 44f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(16.5f, 44f, 0f)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + seeded.Projection)); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + + RuntimeCollisionGenerationCommit parked = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(parked.EngineCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + + Assert.False(physics.CancelCollisionGeneration(admission, prepared)); + RuntimeCollisionGenerationCommit forbidden = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(forbidden.EngineCommitted); + Assert.False(forbidden.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + Assert.True(physics.CancelCollisionGeneration(admission, prepared)); + + Assert.True(physics.IsSpatialRoot(record)); + Assert.True(physics.Engine.IsLandblockTerrainResident(PrefixP)); + Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixMutationCount); + Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixQuiescenceCount); + Assert.Equal(0, physics.CaptureOwnership().CollisionAdmissionCount); + } + + [Fact] + public void SupersededAdmissionCancellationRestoresExactBaselineGeneration() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission baseline = + physics.BeginCollisionAdmission(PrefixP); + using (PreparedLandblockCollisionGeneration preparedBaseline = + PrepareSealedMutation(physics, baseline, PrefixP)) + { + Assert.False(physics.CommitCollisionGeneration( + baseline, + preparedBaseline).Completed); + Assert.True(physics.CommitCollisionGeneration( + baseline, + preparedBaseline).Completed); + } + + RuntimeEntityRecord record = fixture.Add( + 0x70003107u, + 1, + CellP, + new Vector3(17f, 45f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(17.5f, 45f, 0f)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + seeded.Projection)); + + RuntimeCollisionAdmission admissionA = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration preparedA = + PrepareSealedMutation(physics, admissionA, PrefixP); + RuntimeCollisionAdmission admissionB = + physics.BeginCollisionAdmission(PrefixP); + Assert.Equal(baseline.Generation, admissionB.PreviousGeneration); + using PreparedLandblockCollisionGeneration preparedB = + PrepareSealedMutation(physics, admissionB, PrefixP); + + RuntimeCollisionGenerationCommit parked = + physics.CommitCollisionGeneration(admissionB, preparedB); + Assert.False(parked.EngineCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + + Assert.False(physics.CancelCollisionGeneration(admissionB, preparedB)); + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + Assert.False(physics.CancelCollisionGeneration(admissionB, preparedB)); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + Assert.True(physics.CancelCollisionGeneration(admissionB, preparedB)); + + Assert.True(physics.IsSpatialRoot(record)); + Assert.Equal(restored.Token.ExactCellId, record.FullCellId); + Assert.True(physics.Engine.IsLandblockTerrainResident(PrefixP)); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void CancellationAfterEngineCommitFinishesExactPlaceAckWithoutRollback() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission baseline = + physics.BeginCollisionAdmission(PrefixP); + using (PreparedLandblockCollisionGeneration preparedBaseline = + PrepareSealedMutation(physics, baseline, PrefixP)) + { + Assert.False(physics.CommitCollisionGeneration( + baseline, + preparedBaseline).Completed); + Assert.True(physics.CommitCollisionGeneration( + baseline, + preparedBaseline).Completed); + } + + RuntimeEntityRecord record = fixture.Add( + 0x70003108u, + 1, + CellP, + new Vector3(18f, 46f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(18.5f, 46f, 0f)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + seeded.Projection)); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + int committedNotifications = 0; + physics.CollisionGenerationCommitted += _ => committedNotifications++; + + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + _ = SealMutation(physics, admission, prepared); + RuntimeCollisionGenerationCommit transferred = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(transferred.EngineCommitted); + Assert.False(transferred.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + + Assert.False(physics.CancelCollisionGeneration(admission, prepared)); + Assert.True(physics.Engine.IsLandblockTerrainResident(PrefixP)); + Assert.Equal(0, committedNotifications); + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + Assert.True(physics.CancelCollisionGeneration(admission, prepared)); + + Assert.True(physics.IsSpatialRoot(record)); + Assert.True(physics.Engine.IsLandblockTerrainResident(PrefixP)); + Assert.Equal(1, committedNotifications); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void ColdFirstGenerationCancellationReleasesToUnavailableWithoutDebt() + { + // The injected-engine constructor is an internal fixture seam. It can + // begin with collision resident before Runtime has assigned generation + // one; production Runtime constructs an empty PhysicsEngine. + using var fixture = new Fixture(bindGeneration: true); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeEntityRecord record = fixture.Add( + 0x70003109u, + 1, + CellP, + new Vector3(19f, 47f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(19.5f, 47f, 0f)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + seeded.Projection)); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + Assert.Equal(0UL, admission.PreviousGeneration); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + + RuntimeCollisionGenerationCommit parked = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(parked.EngineCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + Assert.True(physics.CancelCollisionGeneration(admission, prepared)); + + Assert.True(physics.Engine.IsLandblockTerrainResident(PrefixP)); + Assert.False(physics.IsSpatialRoot(record)); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.PendingCollisionPrefixProjectionCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + RuntimeSetPositionOwnershipSnapshot placement = + physics.SetPosition.CaptureOwnership(); + Assert.Equal(1, placement.UnboundDeferredCellCount); + Assert.Equal(0, placement.DeferredBucketCount); + } + + private static PreparedLandblockCollisionGeneration PrepareSealedMutation( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + uint landblock) + { + PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + new RuntimeLandblockCollisionAssets( + landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + 0f, + 0f, + 0u)); + _ = SealMutation(physics, admission, prepared); + return prepared; + } + + private static uint[] SealMutation( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + while (true) + { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (seal.Completed) + return [.. prepared.RetainedOwnerIds]; + } + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs index b49bc996..a244db9c 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs @@ -10,7 +10,7 @@ using DatReaderWriter.Types; namespace AcDream.Runtime.Tests.Physics; -public sealed class RuntimeCollisionPrefixQuiescenceTests +public sealed partial class RuntimeCollisionPrefixQuiescenceTests { private const uint PrefixP = 0xA9B40000u; private const uint CellP = PrefixP | 0x0001u; @@ -68,7 +68,10 @@ public sealed class RuntimeCollisionPrefixQuiescenceTests Assert.True(fixture.Lifetime.Physics .IsCollisionPrefixMutationPermissionCurrent(permission)); Assert.True(fixture.Lifetime.Physics - .CompleteCollisionPrefixQuiescence(permission)); + .CancelCollisionPrefixQuiescence( + token, + successorGeneration: 2UL, + successorReady: false)); Assert.False(fixture.Lifetime.Physics .IsCollisionPrefixMutationPermissionCurrent(permission)); } diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsOwnershipBoundaryTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsOwnershipBoundaryTests.cs new file mode 100644 index 00000000..3795a3ba --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsOwnershipBoundaryTests.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using AcDream.Core.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimePhysicsOwnershipBoundaryTests +{ + [Fact] + public void PhysicsWorldRootMutatorsAreRuntimeOnlyProductionInternals() + { + string[] rootMutators = + [ + nameof(PhysicsEngine.AddLandblock), + nameof(PhysicsEngine.RemoveLandblock), + nameof(PhysicsEngine.DemoteLandblockToTerrain), + nameof(PhysicsEngine.Clear), + ]; + Type engine = typeof(PhysicsEngine); + MethodInfo[] publicMethods = engine.GetMethods( + BindingFlags.Instance | BindingFlags.Public); + MethodInfo[] internalMethods = engine.GetMethods( + BindingFlags.Instance | BindingFlags.NonPublic); + foreach (string name in rootMutators) + { + Assert.DoesNotContain( + publicMethods, + method => method.Name == name); + Assert.Contains( + internalMethods, + method => method.Name == name); + } + + string[] friends = engine.Assembly + .GetCustomAttributes() + .Select(attribute => attribute.AssemblyName.Split(',')[0]) + .ToArray(); + Assert.Contains("AcDream.Runtime", friends); + Assert.DoesNotContain("AcDream.App", friends); + Assert.DoesNotContain("acdream-headless", friends); + Assert.Contains("AcDream.App.Tests", friends); + Assert.Contains("AcDream.Headless.Tests", friends); + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index 0972e99a..78349ead 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -336,7 +336,8 @@ public sealed class RuntimePhysicsStateTests CommitPrepared(first.Physics, newer, prepared)); RuntimeCollisionAcknowledgement withdrawn = - first.Physics.WithdrawCollision(0xA9B4FFFFu); + CompleteWithdrawal(first.Physics, 0xA9B4FFFFu) + .Acknowledgement; Assert.True(withdrawn.WasResident); Assert.True(withdrawn.Generation > completed.Generation); Assert.Equal(0, first.Physics.Engine.LandblockCount); @@ -465,7 +466,8 @@ public sealed class RuntimePhysicsStateTests uint owner = Assert.Single(SealPrepared(physics, admission, prepared)); physics.Engine.ShadowObjects.UpdatePhysicsState(owner, 0x14u); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); @@ -561,6 +563,11 @@ public sealed class RuntimePhysicsStateTests Assert.True(sealSteps > ownerCount); Assert.True(workUnits > ownerCount); + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + _ = SealPrepared(physics, admission, prepared); + _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -662,7 +669,8 @@ public sealed class RuntimePhysicsStateTests } while (!seal.Completed); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); @@ -742,7 +750,8 @@ public sealed class RuntimePhysicsStateTests break; } - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); ShadowEntry[] entries = physics.Engine.ShadowObjects @@ -841,7 +850,8 @@ public sealed class RuntimePhysicsStateTests } while (!seal.Completed); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); ShadowEntry[] entries = physics.Engine.ShadowObjects @@ -885,6 +895,10 @@ public sealed class RuntimePhysicsStateTests ShadowObjectRegistry shadowFacade = physics.Engine.ShadowObjects; int notifications = 0; physics.CollisionGenerationCommitted += _ => notifications++; + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + _ = SealPrepared(physics, admission, prepared); _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -1049,6 +1063,10 @@ public sealed class RuntimePhysicsStateTests PhysicsDataCache cacheFacade = physics.DataCache; CellGraph graphFacade = physics.DataCache.CellGraph; ShadowObjectRegistry shadowFacade = physics.Engine.ShadowObjects; + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + _ = SealPrepared(physics, admission, prepared); _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -1106,9 +1124,15 @@ public sealed class RuntimePhysicsStateTests _ = SealPrepared(physics, firstAdmission, first); _ = SealPrepared(physics, secondAdmission, second); + Assert.False(physics.CommitCollisionGeneration( + firstAdmission, + first).Committed); + _ = SealPrepared(physics, firstAdmission, first); + _ = GC.GetAllocatedBytesForCurrentThread(); long firstBefore = GC.GetAllocatedBytesForCurrentThread(); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, firstAdmission, first).Committed); long firstAllocated = @@ -1118,10 +1142,15 @@ public sealed class RuntimePhysicsStateTests Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); Assert.False(physics.Engine.IsLandblockTerrainResident(secondLandblock)); + _ = SealPrepared(physics, secondAdmission, second); + Assert.False(physics.CommitCollisionGeneration( + secondAdmission, + second).Committed); _ = SealPrepared(physics, secondAdmission, second); _ = GC.GetAllocatedBytesForCurrentThread(); long secondBefore = GC.GetAllocatedBytesForCurrentThread(); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, secondAdmission, second).Committed); long secondAllocated = @@ -1172,7 +1201,8 @@ public sealed class RuntimePhysicsStateTests _ = SealPrepared(physics, firstAdmission, first); _ = SealPrepared(physics, secondAdmission, second); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, firstAdmission, first).Committed); physics.Engine.ShadowObjects.UpdatePosition( @@ -1185,7 +1215,8 @@ public sealed class RuntimePhysicsStateTests seedCellId: 0x01010001u); _ = SealPrepared(physics, secondAdmission, second); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, secondAdmission, second).Committed); Assert.Equal(12f, Assert.Single( @@ -1256,11 +1287,13 @@ public sealed class RuntimePhysicsStateTests _ = SealPrepared(physics, northAdmission, northPrepared); _ = SealPrepared(physics, southAdmission, southPrepared); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, northAdmission, northPrepared).Committed); _ = SealPrepared(physics, southAdmission, southPrepared); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, southAdmission, southPrepared).Committed); Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( @@ -1394,7 +1427,8 @@ public sealed class RuntimePhysicsStateTests physics.Engine.UpdatePlayerCurrCell(secondCell); ObjCell oldRootCell = physics.DataCache.CellGraph.CurrCell!; - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); @@ -1454,7 +1488,8 @@ public sealed class RuntimePhysicsStateTests destination, seedCellId: 0x02020001u); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.False(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( @@ -1521,7 +1556,8 @@ public sealed class RuntimePhysicsStateTests seedCellId: 0x02020001u, isStatic: false); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); ShadowEntry entry = Assert.Single( @@ -1692,8 +1728,8 @@ public sealed class RuntimePhysicsStateTests preparedThird, CollisionAssets(replacement)); - Assert.True(physics.DemoteCollisionToTerrain(demoted).WasResident); - Assert.True(physics.WithdrawCollision(withdrawn).WasResident); + Assert.True(CompleteDemotion(physics, demoted).WasResident); + Assert.True(CompleteWithdrawal(physics, withdrawn).WasResident); Assert.True(CommitPrepared( physics, third, @@ -1745,7 +1781,8 @@ public sealed class RuntimePhysicsStateTests } _ = SealPrepared(physics, retiredAdmission, retiredPrepared); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, retiredAdmission, retiredPrepared).Committed); if (beginRebase) @@ -1762,9 +1799,9 @@ public sealed class RuntimePhysicsStateTests } if (withdraw) - Assert.True(physics.WithdrawCollision(retired).WasResident); + Assert.True(CompleteWithdrawal(physics, retired).WasResident); else - Assert.True(physics.DemoteCollisionToTerrain(retired).WasResident); + Assert.True(CompleteDemotion(physics, retired).WasResident); Assert.True(CommitPrepared( physics, @@ -1986,6 +2023,11 @@ public sealed class RuntimePhysicsStateTests while (!seal.Completed); Assert.Equal(ownerCount, worked); + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + _ = SealPrepared(physics, admission, prepared); + _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -2050,7 +2092,8 @@ public sealed class RuntimePhysicsStateTests survivingTarget, seedCellId: 0x02020001u, isStatic: false); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, survivingAdmission, surviving).Committed); Assert.Contains( @@ -2097,7 +2140,8 @@ public sealed class RuntimePhysicsStateTests 0f, target, seedCellId: 0x01010001u); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( @@ -2146,7 +2190,8 @@ public sealed class RuntimePhysicsStateTests outside, seedCellId: 0x02020001u); physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 0x55u); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); ShadowEntry entry = Assert.Single( @@ -2217,7 +2262,7 @@ public sealed class RuntimePhysicsStateTests replacementAdmission, replacementPrepared); - Assert.True(physics.WithdrawCollision(retired).WasResident); + Assert.True(CompleteWithdrawal(physics, retired).WasResident); Assert.False(physics.CommitCollisionGeneration( replacementAdmission, replacementPrepared).Committed); @@ -2226,7 +2271,8 @@ public sealed class RuntimePhysicsStateTests physics, replacementAdmission, replacementPrepared); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, replacementAdmission, replacementPrepared).Committed); Assert.False(physics.Engine.IsLandblockTerrainResident(retired)); @@ -2254,7 +2300,8 @@ public sealed class RuntimePhysicsStateTests uint ordinal = index + 0x1000u; uint x = ordinal & 0xFFu; uint y = (ordinal >> 8) & 0xFFu; - _ = physics.WithdrawCollision( + _ = CompleteWithdrawal( + physics, (x << 24) | (y << 16) | 0xFFFFu); } Assert.False(physics.CommitCollisionGeneration( @@ -2272,7 +2319,8 @@ public sealed class RuntimePhysicsStateTests Assert.True(++steps < 100_000); } while (!seal.Completed); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); } @@ -2380,7 +2428,8 @@ public sealed class RuntimePhysicsStateTests Assert.InRange(seal.WorkUnits, 0, 1); } while (!seal.Completed); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); @@ -2448,7 +2497,8 @@ public sealed class RuntimePhysicsStateTests seedCellId: 0x01010001u, isStatic: false); physics.Engine.ShadowObjects.Deregister(77u); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); @@ -2484,7 +2534,8 @@ public sealed class RuntimePhysicsStateTests seedCellId: 0x01010001u, isStatic: false); } - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.Equal( @@ -2903,7 +2954,55 @@ public sealed class RuntimePhysicsStateTests PreparedLandblockCollisionGeneration prepared) { _ = SealPrepared(physics, admission, prepared); - return physics.CommitCollisionGeneration(admission, prepared); + return CompleteSealedCommit(physics, admission, prepared); + } + + private static RuntimeCollisionGenerationCommit CompleteSealedCommit( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + for (int poll = 0; poll < 10_000; poll++) + { + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + if (commit.Committed) + return commit; + if (!commit.EngineCommitted) + _ = SealPrepared(physics, admission, prepared); + } + throw new InvalidOperationException( + "Collision generation did not complete its Runtime mutation transaction."); + } + + private static RuntimeCollisionMutationResult CompleteDemotion( + RuntimePhysicsState physics, + uint landblockId) + { + for (int poll = 0; poll < 10_000; poll++) + { + RuntimeCollisionMutationResult result = + physics.DemoteCollisionToTerrain(landblockId); + if (result.Completed) + return result; + } + throw new InvalidOperationException( + "Collision demotion did not complete its Runtime mutation transaction."); + } + + private static RuntimeCollisionMutationResult CompleteWithdrawal( + RuntimePhysicsState physics, + uint landblockId) + { + for (int poll = 0; poll < 10_000; poll++) + { + RuntimeCollisionMutationResult result = + physics.WithdrawCollision(landblockId); + if (result.Completed) + return result; + } + throw new InvalidOperationException( + "Collision withdrawal did not complete its Runtime mutation transaction."); } private static uint[] SealPrepared( diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index 92368bdd..aa7896e1 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -1750,8 +1750,6 @@ public sealed class RuntimeSetPositionStateTests RuntimePlacementProjectionSnapshot placed = observer.Deltas[^1].Placement; Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); Assert.Equal(DestinationIndoorCell, placed.Token.ExactCellId); - Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( - placed.Token)); RuntimePhysicsOwnershipSnapshot final = lifetime.Physics.CaptureOwnership(); Assert.Equal(0, final.DeferredSetPositionBucketCount); Assert.Equal(0, final.UnboundDeferredSetPositionCellCount); @@ -1872,10 +1870,6 @@ public sealed class RuntimeSetPositionStateTests Assert.All(observer.Deltas, delta => Assert.Equal( RuntimePlacementProjectionKind.Place, delta.Placement.Kind)); - Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( - observer.Deltas[0].Placement.Token)); - Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( - observer.Deltas[1].Placement.Token)); } [Theory] @@ -2425,32 +2419,48 @@ public sealed class RuntimeSetPositionStateTests RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared) { - while (true) + bool engineCommitted = false; + for (int poll = 0; poll < 10_000; poll++) { - while (!physics.AdvanceCollisionRetainedOwnerCapture( - admission, - prepared).Completed) + if (!engineCommitted) { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (!seal.Completed) + continue; } - foreach (uint ownerId in prepared.RetainedOwnerIds) + RuntimeCollisionGenerationCommit result = + physics.CommitCollisionGeneration(admission, prepared); + if (result.Completed) + return result; + while (physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection)) { - physics.RefreshCollisionRetainedOwner( - admission, - prepared, - ownerId); + Assert.True(physics.SetPosition.AcknowledgeProjection( + projection.Token)); } - RuntimeCollisionSealStep seal; - do - { - seal = physics.AdvanceCollisionGenerationSeal( - admission, - prepared); - } - while (!seal.Completed && !seal.Restarted); - if (seal.Completed) - break; + engineCommitted = result.EngineCommitted; } - return physics.CommitCollisionGeneration(admission, prepared); + throw new InvalidOperationException( + "Collision generation did not complete its Runtime mutation transaction."); } private static void AddSyntheticCell( From 0fbc7a1fb74036cb45d02cd42575844f5d47fb5e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 18:22:45 +0200 Subject: [PATCH 40/73] fix(runtime): preserve hidden setposition collision ownership --- .../Physics/ShadowObjectRegistry.cs | 28 +-- .../Entities/RuntimeEntityObjectLifetime.cs | 6 + .../Physics/RuntimeCollisionReportingState.cs | 20 ++ .../Physics/RuntimePhysicsState.cs | 3 +- .../Physics/RuntimeSetPositionState.cs | 30 ++- .../Physics/ShadowSetPositionCommitTests.cs | 27 +++ .../RuntimeCollisionReportingStateTests.cs | 227 +++++++++++++++++- .../Physics/RuntimeSetPositionStateTests.cs | 45 ++++ 8 files changed, 343 insertions(+), 43 deletions(-) diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 083011ed..df86dbd4 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -883,23 +883,17 @@ public sealed class ShadowObjectRegistry DataCache = DataCache, }; staging.InstallOwnerState(source); - if (suspendOwner) - { - if (!staging.Suspend(entityId)) - return false; - } - else - { - staging.CommitSetPosition( - entityId, - worldPosition, - worldRotation, - seedCellId, - worldOffsetX, - worldOffsetY, - action, - crossCellIds); - } + staging.CommitSetPosition( + entityId, + worldPosition, + worldRotation, + seedCellId, + worldOffsetX, + worldOffsetY, + action, + crossCellIds); + if (suspendOwner && !staging.Suspend(entityId)) + return false; if (!staging.TryCaptureOwnerState( entityId, out PreparedShadowOwnerState? replacement) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 3814f099..03dd3cc1 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -752,6 +752,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable transition = Entities.ApplyRawPhysicsState( canonical, update.PhysicsState); + if (canonical.Key is { } key) + { + Physics.Engine.ShadowObjects.UpdatePhysicsState( + key.LocalEntityId, + (uint)canonical.FinalPhysicsState); + } ulong stateVersion = canonical.StateAuthorityVersion; ulong physicsMutationVersion = canonical.PhysicsStateMutationVersion; diff --git a/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs b/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs index b3dbc360..bfa13331 100644 --- a/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs @@ -438,6 +438,12 @@ internal sealed class RuntimeCollisionReportingState : IDisposable bool reported = false; for (int index = 0; index < receipt.Actions.Length; index++) { + if (IsExactSetPositionBatchOwnerHidden(receipt)) + { + return new( + SetPositionCollisionBatchDispatchStatus.Completed, + reported); + } if (receipt.Owner.PositionAuthorityVersion != receipt.OwnerPositionAuthorityVersion || _owners.TryGetValue( @@ -463,6 +469,12 @@ internal sealed class RuntimeCollisionReportingState : IDisposable action, receipt.PhysicsTime, receipt.BatchId); } + if (IsExactSetPositionBatchOwnerHidden(receipt)) + { + return new( + SetPositionCollisionBatchDispatchStatus.Completed, + reported); + } if (receipt.Owner.PositionAuthorityVersion != receipt.OwnerPositionAuthorityVersion || _owners.TryGetValue( @@ -498,6 +510,14 @@ internal sealed class RuntimeCollisionReportingState : IDisposable reported); } + private bool IsExactSetPositionBatchOwnerHidden( + in SetPositionCollisionBatchReceipt receipt) => + _entities.IsCurrent(receipt.Owner) + && receipt.Owner.PositionAuthorityVersion + == receipt.OwnerPositionAuthorityVersion + && ReferenceEquals(receipt.Owner.PhysicsBody, receipt.OwnerBody) + && (receipt.OwnerBody.State & PhysicsStateFlags.Hidden) != 0; + internal bool DiscardSetPositionBatch( in SetPositionCollisionBatchReceipt receipt) => receipt.IsValid diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 86ab4deb..52f12009 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -3199,8 +3199,7 @@ public sealed class RuntimePhysicsState : IDisposable && record.PositionAuthorityVersion == positionAuthorityVersion && record.SpatialAuthorityVersion == spatialAuthorityVersion && ReferenceEquals(record.PhysicsBody, body) - && body.InWorld - && (body.State & PhysicsStateFlags.Hidden) == 0; + && body.InWorld; } private void OnCollisionOwnerMutated(uint ownerId, ulong version) diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index b817d45d..86aae1d4 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -392,8 +392,7 @@ internal sealed class RuntimeSetPositionState : IDisposable body, placementCommitVersion, fullCellId, - requireSpatialRoot: false) - && owner.IsCollisionReportingEligible(record, body); + requireSpatialRoot: false); } private readonly RuntimePhysicsState _physics; @@ -3389,21 +3388,21 @@ internal sealed class RuntimeSetPositionState : IDisposable body, canonicalCommitVersion, committedCellId, - requireSpatialRoot: false) - || !IsCollisionReportingEligible(record, body)) + requireSpatialRoot: false)) { return false; } - bool reportingCurrent = _physics.HandleSetPositionCollisionReports( - record, - operation.PositionAuthorityVersion, - operation.SpatialAuthorityVersion, - operation.Command.GameTime, - operation.PreviousContact, - operation.PreviousOnWalkable, - collidedWithEnvironment, - collidedObjectIds, - out _); + bool reportingCurrent = !IsCollisionReportingEligible(record, body) + || _physics.HandleSetPositionCollisionReports( + record, + operation.PositionAuthorityVersion, + operation.SpatialAuthorityVersion, + operation.Command.GameTime, + operation.PreviousContact, + operation.PreviousOnWalkable, + collidedWithEnvironment, + collidedObjectIds, + out _); if (!reportingCurrent || !IsCanonicalPlacementCommitCurrent( operation, @@ -3411,8 +3410,7 @@ internal sealed class RuntimeSetPositionState : IDisposable body, canonicalCommitVersion, committedCellId, - requireSpatialRoot: false) - || !IsCollisionReportingEligible(record, body)) + requireSpatialRoot: false)) return false; body.FramesStationaryFall = result.FramesStationaryFall; if (IsVelocityCurrent(operation)) diff --git a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs index 2b3d8a51..9c8136ba 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs @@ -38,6 +38,33 @@ public sealed class ShadowSetPositionCommitTests Assert.Single(registry.GetObjectsInCell(Cell9)).Position); } + [Fact] + public void PreparedSuspendedMoveRetainsNewCanonicalPosition() + { + var registry = RegisteredSingle(); + var moved = new Vector3(36f, 12f, 50f); + Assert.True(registry.TryPrepareSetPosition( + 1u, + moved, + Quaternion.Identity, + Cell9, + 0f, + 0f, + PhysicsShadowCommitAction.Replace, + [Cell9], + provenShapeless: false, + suspendOwner: true, + out var prepared)); + + Assert.Single(registry.GetObjectsInCell(Cell1)); + Assert.True(registry.TryApplySetPosition(prepared!, out _)); + Assert.Empty(registry.GetObjectsInCell(Cell1)); + Assert.Empty(registry.GetObjectsInCell(Cell9)); + Assert.Equal(1, registry.SuspendedRegistrationCount); + Assert.Equal(moved, prepared!.OwnerState!.Registration.EntityWorldPos); + Assert.Equal(Cell9, prepared.OwnerState.Registration.SeedCellId); + } + [Fact] public void PreparedCrossPrefixDispatchesMembershipThenMutationOnce() { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs index efabd2e4..f23ea6f7 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs @@ -1883,7 +1883,7 @@ public sealed class RuntimeCollisionReportingStateTests } [Fact] - public void HitGroundHiddenTransitionCannotResumeCollisionTracking() + public void HitGroundHiddenTransitionCommitsPlacementWithoutResumingCollisionTracking() { using var lifetime = Lifetime(); RuntimeEntityRecord owner = Entity( @@ -1937,11 +1937,20 @@ public sealed class RuntimeCollisionReportingStateTests PlacementCommand(owner, new Vector3(16f, 12f, 7f))); Assert.True(hidden); - Assert.Equal(RuntimeSetPositionStatus.Cancelled, outcome.Status); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection)); Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)); + Assert.Equal(Cell, owner.FullCellId); + Assert.Equal(projection.WorldPosition, owner.PhysicsBody!.Position); + Assert.True(owner.PhysicsBody.InWorld); Assert.Empty(observer.Reports); Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() .TrackedObjectCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); } [Fact] @@ -2010,7 +2019,7 @@ public sealed class RuntimeCollisionReportingStateTests } [Fact] - public void CollisionReportHiddenTransitionStopsResponseAndShadowReflood() + public void CollisionReportHiddenTransitionPreservesCommittedPlacement() { using var lifetime = Lifetime(); RuntimeEntityRecord owner = Entity( @@ -2023,7 +2032,6 @@ public sealed class RuntimeCollisionReportingStateTests RegisterDynamicShadow(lifetime, owner); RegisterDynamicShadow(lifetime, target); uint targetId = target.Key!.Value.LocalEntityId; - Vector3 initial = owner.PhysicsBody!.Position; lifetime.Physics.Engine.TransitionCellCollisionTestHook = (transition, phase, _, _) => { @@ -2062,14 +2070,217 @@ public sealed class RuntimeCollisionReportingStateTests PlacementCommand(owner, new Vector3(18f, 12f, 7f))); Assert.True(hidden); - Assert.Equal(RuntimeSetPositionStatus.Cancelled, outcome.Status); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)); Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() .TrackedObjectCount); - ShadowEntry shadow = Assert.Single( - lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + Assert.Equal(Cell, owner.FullCellId); + Assert.Equal(new Vector3(18f, 12f, 7f), owner.PhysicsBody!.Position); + ShadowObjectRegistry shadows = lifetime.Physics.Engine.ShadowObjects; + uint ownerId = owner.Key!.Value.LocalEntityId; + ShadowEntry hiddenShadow = Assert.Single( + shadows.AllEntriesForDebug(), + entry => entry.EntityId == ownerId); + Assert.Equal(owner.PhysicsBody.Position, hiddenShadow.Position); + Assert.Equal(0, shadows.SuspendedRegistrationCount); + Assert.True(shadows.TryGetCollisionOwner( + ownerId, + out uint hiddenShadowState, + out _)); + Assert.Equal((uint)owner.FinalPhysicsState, hiddenShadowState); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + + Assert.True(lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)PhysicsStateFlags.ReportCollisions, + owner.Incarnation, + StateSequence: 3), + acknowledgeProjection: null, + out _, + out _)); + Assert.Equal(0, shadows.SuspendedRegistrationCount); + ShadowEntry restored = Assert.Single( + shadows.AllEntriesForDebug(), + entry => entry.EntityId == ownerId); + Assert.Equal(owner.PhysicsBody.Position, restored.Position); + Assert.Equal((uint)owner.FinalPhysicsState, restored.State); + } + + [Fact] + public void PreparedCollisionBatchStopsAfterCallbackHidesOwner() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x7000207Eu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord first = Entity( + lifetime, 0x7000207Fu, 1, PhysicsStateFlags.None); + RuntimeEntityRecord second = Entity( + lifetime, 0x70002080u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, first); + RegisterDynamicShadow(lifetime, second); + bool hidden = false; + var observer = new CollisionObserver(report => + { + if (hidden + || report.Kind is not RuntimeCollisionReportKind.ObjectCollision + || report.Recipient != owner.Key) + { + return; + } + hidden = lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Hidden), + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch( + owner, + owner.PhysicsBody!, + physicsTime: 12d, + previousContact: false, + previousOnWalkable: false, + finalOnWalkable: false, + collidedWithEnvironment: false, + [ + first.Key!.Value.LocalEntityId, + second.Key!.Value.LocalEntityId, + ], + out var prepared)); + Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch( + prepared!, out var receipt)); + + SetPositionCollisionBatchDispatchResult result = lifetime.Physics + .CollisionReports.DispatchSetPositionBatchResult(receipt); + + Assert.True(hidden); + Assert.Equal(SetPositionCollisionBatchDispatchStatus.Completed, + result.Status); + RuntimeCollisionReport report = Assert.Single( + observer.Reports, + candidate => candidate.Kind + is RuntimeCollisionReportKind.ObjectCollision); + Assert.Equal(first.Key, report.Other); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.TrackedObjectCount); + Assert.Equal(0, ownership.ReversePeerCount); + Assert.Equal(0, ownership.PendingSetPositionDispatchCount); + ShadowObjectRegistry shadows = lifetime.Physics.Engine.ShadowObjects; + Assert.Equal(0, shadows.SuspendedRegistrationCount); + Assert.Contains( + shadows.AllEntriesForDebug(), entry => entry.EntityId == owner.Key!.Value.LocalEntityId); - Assert.Equal(initial, shadow.Position); + Assert.True(shadows.TryGetCollisionOwner( + owner.Key!.Value.LocalEntityId, + out uint shadowState, + out _)); + Assert.Equal((uint)owner.FinalPhysicsState, shadowState); + } + + [Fact] + public void NoDrawOwnerStillPublishesItsCollisionReport() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x7000207Au, + 1, + PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.NoDraw); + RuntimeEntityRecord target = Entity( + lifetime, 0x7000207Bu, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX); + } + return TransitionState.OK; + }; + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + PlacementCommand(owner, new Vector3(19f, 12f, 7f))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + RuntimeCollisionReport report = Assert.Single( + observer.Reports, + report => report.Kind is RuntimeCollisionReportKind.ObjectCollision); + Assert.Equal(owner.Key, report.Recipient); + Assert.Equal(target.Key, report.Other); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + } + + [Fact] + public void NoDrawOwnerStillAllowsReciprocalPeerCollisionReport() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, 0x7000207Cu, 1, PhysicsStateFlags.NoDraw); + RuntimeEntityRecord target = Entity( + lifetime, + 0x7000207Du, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX); + } + return TransitionState.OK; + }; + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + PlacementCommand(owner, new Vector3(20f, 12f, 7f))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + RuntimeCollisionReport report = Assert.Single( + observer.Reports, + report => report.Kind is RuntimeCollisionReportKind.ObjectCollision); + Assert.Equal(target.Key, report.Recipient); + Assert.Equal(owner.Key, report.Other); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); } [Fact] diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index aa7896e1..c3c34ab2 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -113,6 +113,42 @@ public sealed class RuntimeSetPositionStateTests Assert.Equal(1, ownership.PreparedMoverCount); } + [Theory] + [InlineData(PhysicsStateFlags.Hidden)] + [InlineData(PhysicsStateFlags.Hidden | PhysicsStateFlags.NoDraw)] + public void HiddenObjectCommitsResidenceWhileSuppressingCollisionReports( + PhysicsStateFlags suppressedState) + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001022u, 1); + PhysicsStateFlags state = PhysicsStateFlags.Gravity | suppressedState; + PhysicsBody body = AttachBody(lifetime, record, SourceCell, state); + var collisionObserver = new CollisionReportObserver(); + using IDisposable collisionSubscription = lifetime.Physics + .CollisionReports.Subscribe(collisionObserver); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(12f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(body.InWorld); + Assert.Equal(SourceCell, record.FullCellId); + Assert.Equal(new Vector3(12f, 18f, 7f), body.Position); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + Assert.Empty(collisionObserver.Reports); + RuntimeCollisionReportingOwnershipSnapshot reporting = lifetime + .Physics.CollisionReports.CaptureOwnership(); + Assert.Equal(0, reporting.OwnerCount); + Assert.Equal(0, reporting.PendingSetPositionDispatchCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + } + [Fact] public void PublicPlacementChannelObservesRetriesAndAcknowledgesExactToken() { @@ -2582,6 +2618,15 @@ public sealed class RuntimeSetPositionStateTests } } + private sealed class CollisionReportObserver + : IRuntimeCollisionReportObserver + { + internal List Reports { get; } = []; + + public void OnCollisionReport(in RuntimeCollisionReport report) => + Reports.Add(report); + } + private sealed class EntityObserver : IRuntimeEntityObjectObserver { internal List Deltas { get; } = []; From 3f800a4aeca2b7a7580849fe04917a89014771ee Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 18:23:07 +0200 Subject: [PATCH 41/73] feat(runtime): classify retail authoritative position routes --- ...imeAuthoritativePositionRouteClassifier.cs | 554 ++++++++++++++++++ ...thoritativePositionRouteClassifierTests.cs | 553 +++++++++++++++++ 2 files changed, 1107 insertions(+) create mode 100644 src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs diff --git a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs new file mode 100644 index 00000000..ab849c48 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs @@ -0,0 +1,554 @@ +using System.Numerics; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Physics; + +internal enum RuntimePositionEntityKind : byte +{ + Unknown, + LocalPlayer, + Remote, + Projectile, +} + +internal enum RuntimeCreateResidenceKind : byte +{ + Unknown, + TopLevel, + Parented, + PickedUp, +} + +internal enum RuntimeAcceptedPositionSource : byte +{ + Unknown, + PositionEvent, + SameIncarnationCreate, +} + +internal enum RuntimeLeaveWorldCause : byte +{ + Unknown, + Pickup, + Parent, +} + +internal enum RuntimeAuthoritativePositionDisposition : byte +{ + RejectedAuthority, + RejectedData, + AwaitFreshPosition, + NoPositionOperation, + Interpolate, + SetPosition, + SetPositionSimple, +} + +internal enum RuntimeTeleportHookPhase : byte +{ + None, + BeforePositionOperation, + AfterPositionOperation, + AfterEnterWorld, +} + +/// +/// Exact accepted-wire authority for one presentation-independent position +/// route. identifies the incarnation while +/// rejects a superseded accepted pose. +/// The previous and accepted TELEPORT_TS values remain explicit so the pure +/// classifier can apply retail's wrap-safe freshness rule without consulting +/// App state. +/// +internal readonly record struct RuntimeAuthoritativePositionAuthority( + RuntimeGenerationToken Generation, + RuntimeEntityKey Entity, + ulong PositionAuthorityVersion, + ushort AcceptedPositionSequence, + ushort PreviousTeleportSequence, + ushort AcceptedTeleportSequence, + PositionTimestampDisposition TimestampDisposition) +{ + internal bool IsStructurallyValid => Generation.Value != 0UL + && Entity.LocalEntityId != 0u + && PositionAuthorityVersion != 0UL + && TimestampDisposition is PositionTimestampDisposition.Apply + or PositionTimestampDisposition.ForcePosition; + + internal bool TeleportAdvanced => + TimestampDisposition is PositionTimestampDisposition.Apply + && PhysicsTimestampGate.IsNewer( + PreviousTeleportSequence, + AcceptedTeleportSequence); + + internal bool TeleportRegressed => + PhysicsTimestampGate.IsNewer( + AcceptedTeleportSequence, + PreviousTeleportSequence); +} + +/// +/// Placement facts which must not be collapsed into presentation visibility. +/// Hidden and NoDraw objects still run retail SetPosition. Hidden suppresses +/// the complete collision batch after its retail state rewrite; NoDraw is +/// render-only. Source/peer ReportCollisions and IgnoreCollisions decisions +/// remain downstream in RuntimeCollisionReportingState. +/// +internal readonly record struct RuntimePositionPlacementFacts( + PhysicsStateFlags PhysicsState, + bool HasAuthoredMoverShape) +{ + internal bool CollisionBatchEligible => + (PhysicsState & PhysicsStateFlags.Hidden) == 0; +} + +internal readonly record struct RuntimeCreatePositionRouteRequest( + RuntimeAuthoritativePositionAuthority Authority, + RuntimePositionEntityKind EntityKind, + RuntimeCreateResidenceKind Residence, + CreateObject.ServerPosition? AcceptedWirePosition, + RuntimePositionPlacementFacts PlacementFacts); + +internal readonly record struct RuntimeAcceptedPositionRouteRequest( + RuntimeAuthoritativePositionAuthority Authority, + RuntimePositionEntityKind EntityKind, + RuntimeAcceptedPositionSource Source, + CreateObject.ServerPosition AcceptedWirePosition, + uint? PlacementFrame, + Vector3? PositionPackVelocity, + uint? CommittedCellId, + bool HasContact, + float PlayerDistance, + bool UsePositionFromServer, + bool HasAnimations, + RuntimePositionPlacementFacts PlacementFacts); + +internal readonly record struct RuntimeLeaveWorldRouteRequest( + RuntimeAuthoritativePositionAuthority Authority, + RuntimePositionEntityKind EntityKind, + RuntimeLeaveWorldCause Cause, + RuntimePositionPlacementFacts PlacementFacts); + +/// +/// Immutable action plan for retail HandleReceivedPosition/MoveOrTeleport. +/// It deliberately contains no renderer, world entity, UI, or host callback. +/// Graphical and headless hosts will consume the same plan during Group 2. +/// +internal readonly record struct RuntimeAuthoritativePositionRoute( + RuntimeAuthoritativePositionAuthority Authority, + RuntimeAuthoritativePositionDisposition Disposition, + RuntimeSetPositionOperationKind OperationKind, + PhysicsSetPositionFlags SetPositionFlags, + uint PlacementFrame, + bool UnparentBeforeRouting, + bool ApplyPlacementFrameBeforeRouting, + bool LeaveWorld, + RuntimeTeleportHookPhase TeleportHookPhase, + bool StopInterpolating, + bool ConstrainAfterRouting, + bool PreserveHeading, + bool ZeroVelocity, + bool SendPositionImmediately, + bool CollisionBatchEligible) +{ + internal bool Accepted => Disposition is not + RuntimeAuthoritativePositionDisposition.RejectedAuthority + and not RuntimeAuthoritativePositionDisposition.RejectedData; + + internal bool PerformsSetPosition => Disposition is + RuntimeAuthoritativePositionDisposition.SetPosition + or RuntimeAuthoritativePositionDisposition.SetPositionSimple; + + internal bool RunsTeleportHook => + TeleportHookPhase is not RuntimeTeleportHookPhase.None; +} + +/// +/// Pure retail route classifier for new CreateObject residence, +/// SmartBox::HandleReceivedPosition (0x00453FD0), and +/// CPhysicsObj::MoveOrTeleport (0x00516330). +/// +internal static class RuntimeAuthoritativePositionRouteClassifier +{ + private const float MaxPhysicsDistance = 96f; + private const PhysicsSetPositionFlags InitialCreateFlags = + PhysicsSetPositionFlags.Placement | PhysicsSetPositionFlags.Slide; + private const PhysicsSetPositionFlags AuthoritativeTeleportFlags = + PhysicsSetPositionFlags.Teleport + | PhysicsSetPositionFlags.Slide + | PhysicsSetPositionFlags.SendPositionEvent; + + internal static RuntimeAuthoritativePositionRoute ClassifyCreate( + in RuntimeCreatePositionRouteRequest request) + { + if (!ValidCreateAuthority(request.Authority) + || !ValidEntityKind(request.EntityKind) + || request.Residence is RuntimeCreateResidenceKind.Unknown) + { + return RejectedAuthority(request.Authority); + } + + RuntimeSetPositionOperationKind operation = OperationKind( + request.EntityKind, + initialCreate: true); + bool reporting = request.PlacementFacts.CollisionBatchEligible; + if (request.Residence is RuntimeCreateResidenceKind.Parented + or RuntimeCreateResidenceKind.PickedUp) + { + return new RuntimeAuthoritativePositionRoute( + request.Authority, + RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, + operation, + PhysicsSetPositionFlags.None, + 0u, + UnparentBeforeRouting: false, + ApplyPlacementFrameBeforeRouting: false, + LeaveWorld: true, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainAfterRouting: false, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + reporting); + } + + if (request.AcceptedWirePosition is not { } position + || !ValidPosition(position)) + { + return RejectedData(request.Authority, operation, reporting); + } + + // New CreateObject enters through CPhysicsObj::enter_world(Position*) + // -> enter_world(1), which uses 0x11 for every top-level object. + // Static state and an authored-shapeless Setup do not suppress the + // SetPosition transaction; Core supplies retail's placement dummy + // sphere when required. + return new RuntimeAuthoritativePositionRoute( + request.Authority, + RuntimeAuthoritativePositionDisposition.SetPosition, + operation, + InitialCreateFlags, + 0u, + UnparentBeforeRouting: false, + ApplyPlacementFrameBeforeRouting: false, + LeaveWorld: false, + TeleportHookPhase: request.EntityKind + is RuntimePositionEntityKind.LocalPlayer + ? RuntimeTeleportHookPhase.AfterEnterWorld + : RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainAfterRouting: false, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + reporting); + } + + internal static RuntimeAuthoritativePositionRoute ClassifyAcceptedPosition( + in RuntimeAcceptedPositionRouteRequest request) + { + RuntimeSetPositionOperationKind operation = OperationKind( + request.EntityKind, + initialCreate: false); + bool reporting = request.PlacementFacts.CollisionBatchEligible; + if (!ValidAcceptedAuthority(request.Authority, request.EntityKind) + || request.Source is RuntimeAcceptedPositionSource.Unknown + || !ValidEntityKind(request.EntityKind)) + { + return RejectedAuthority(request.Authority, operation, reporting); + } + if (!ValidPosition(request.AcceptedWirePosition)) + return RejectedData(request.Authority, operation, reporting); + + bool force = request.Authority.TimestampDisposition + is PositionTimestampDisposition.ForcePosition; + if (force) + { + // The FORCE_POSITION branch precedes unset_parent and + // SetPlacementFrame in HandleReceivedPosition. + return new RuntimeAuthoritativePositionRoute( + request.Authority, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + operation, + AuthoritativeTeleportFlags, + request.PlacementFrame ?? 0u, + UnparentBeforeRouting: false, + ApplyPlacementFrameBeforeRouting: false, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainAfterRouting: false, + PreserveHeading: true, + ZeroVelocity: false, + SendPositionImmediately: true, + reporting); + } + + uint placement = request.PlacementFrame ?? 0u; + if (request.EntityKind is RuntimePositionEntityKind.LocalPlayer) + { + if (request.Authority.TeleportAdvanced) + { + return new RuntimeAuthoritativePositionRoute( + request.Authority, + RuntimeAuthoritativePositionDisposition.SetPosition, + operation, + AuthoritativeTeleportFlags, + placement, + UnparentBeforeRouting: true, + ApplyPlacementFrameBeforeRouting: !request.HasAnimations, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.AfterPositionOperation, + StopInterpolating: false, + ConstrainAfterRouting: true, + PreserveHeading: false, + ZeroVelocity: true, + SendPositionImmediately: false, + reporting); + } + + bool interpolate = request.UsePositionFromServer + && request.HasContact; + return new RuntimeAuthoritativePositionRoute( + request.Authority, + interpolate + ? RuntimeAuthoritativePositionDisposition.Interpolate + : RuntimeAuthoritativePositionDisposition.NoPositionOperation, + operation, + PhysicsSetPositionFlags.None, + placement, + UnparentBeforeRouting: true, + ApplyPlacementFrameBeforeRouting: !request.HasAnimations, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainAfterRouting: true, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + reporting); + } + + // Accepted wire position is intentionally distinct from committed + // residence. A target frame with a nonzero cell does not make a + // cellless canonical body resident; only a later Runtime SetPosition + // or simulation commit may change FullCellId. + bool cellless = !request.CommittedCellId.HasValue + || request.CommittedCellId.Value == 0u; + if (request.Authority.TeleportAdvanced || cellless) + { + return new RuntimeAuthoritativePositionRoute( + request.Authority, + RuntimeAuthoritativePositionDisposition.SetPosition, + operation, + AuthoritativeTeleportFlags, + placement, + UnparentBeforeRouting: true, + ApplyPlacementFrameBeforeRouting: !request.HasAnimations, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.BeforePositionOperation, + StopInterpolating: false, + ConstrainAfterRouting: true, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + reporting); + } + + // Same-incarnation CreateObject passes arg5=true directly to + // HandleReceivedPosition; it never consults the body's current + // contact bit for MoveOrTeleport classification. + bool effectiveContact = request.Source + is RuntimeAcceptedPositionSource.SameIncarnationCreate + || request.HasContact; + if (!effectiveContact) + { + return new RuntimeAuthoritativePositionRoute( + request.Authority, + RuntimeAuthoritativePositionDisposition.NoPositionOperation, + operation, + PhysicsSetPositionFlags.None, + placement, + UnparentBeforeRouting: true, + ApplyPlacementFrameBeforeRouting: !request.HasAnimations, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainAfterRouting: false, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + reporting); + } + + // Retail has no explicit NaN policy here. Reject a nonfinite derived + // distance at the modern authority boundary rather than recreating an + // x87 unordered-compare accident as gameplay behavior. + if (!float.IsFinite(request.PlayerDistance) + || request.PlayerDistance < 0f) + { + return RejectedData(request.Authority, operation, reporting); + } + + bool nearby = request.PlayerDistance < MaxPhysicsDistance; + return new RuntimeAuthoritativePositionRoute( + request.Authority, + nearby + ? RuntimeAuthoritativePositionDisposition.Interpolate + : RuntimeAuthoritativePositionDisposition.SetPositionSimple, + operation, + nearby ? PhysicsSetPositionFlags.None : AuthoritativeTeleportFlags, + placement, + UnparentBeforeRouting: true, + ApplyPlacementFrameBeforeRouting: !request.HasAnimations, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: !nearby, + ConstrainAfterRouting: true, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + reporting); + } + + internal static RuntimeAuthoritativePositionRoute ClassifyLeaveWorld( + in RuntimeLeaveWorldRouteRequest request) + { + RuntimeSetPositionOperationKind operation = OperationKind( + request.EntityKind, + initialCreate: false); + bool reporting = request.PlacementFacts.CollisionBatchEligible; + if (!ValidCreateAuthority(request.Authority) + || !ValidEntityKind(request.EntityKind) + || request.Cause is RuntimeLeaveWorldCause.Unknown) + { + return RejectedAuthority(request.Authority, operation, reporting); + } + + return new RuntimeAuthoritativePositionRoute( + request.Authority, + RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, + operation, + PhysicsSetPositionFlags.None, + 0u, + UnparentBeforeRouting: false, + ApplyPlacementFrameBeforeRouting: false, + LeaveWorld: true, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainAfterRouting: false, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + reporting); + } + + private static bool ValidCreateAuthority( + in RuntimeAuthoritativePositionAuthority authority) => + authority.IsStructurallyValid + && authority.TimestampDisposition is PositionTimestampDisposition.Apply + && authority.PreviousTeleportSequence == authority.AcceptedTeleportSequence; + + private static bool ValidAcceptedAuthority( + in RuntimeAuthoritativePositionAuthority authority, + RuntimePositionEntityKind kind) + { + if (!authority.IsStructurallyValid || authority.TeleportRegressed) + return false; + return authority.TimestampDisposition switch + { + PositionTimestampDisposition.Apply => true, + PositionTimestampDisposition.ForcePosition => + kind is RuntimePositionEntityKind.LocalPlayer + && authority.PreviousTeleportSequence + == authority.AcceptedTeleportSequence, + _ => false, + }; + } + + private static bool ValidEntityKind(RuntimePositionEntityKind kind) => + kind is RuntimePositionEntityKind.LocalPlayer + or RuntimePositionEntityKind.Remote + or RuntimePositionEntityKind.Projectile; + + private static bool ValidPosition(in CreateObject.ServerPosition position) + { + var origin = new Vector3( + position.PositionX, + position.PositionY, + position.PositionZ); + var orientation = new Quaternion( + position.RotationX, + position.RotationY, + position.RotationZ, + position.RotationW); + return float.IsFinite(origin.X) + && float.IsFinite(origin.Y) + && float.IsFinite(origin.Z) + && float.IsFinite(orientation.X) + && float.IsFinite(orientation.Y) + && float.IsFinite(orientation.Z) + && float.IsFinite(orientation.W) + && PositionFrameValidation.IsValid( + position.LandblockId, + origin, + orientation); + } + + private static RuntimeSetPositionOperationKind OperationKind( + RuntimePositionEntityKind kind, + bool initialCreate) => kind switch + { + RuntimePositionEntityKind.LocalPlayer when initialCreate => + RuntimeSetPositionOperationKind.InitialLogin, + RuntimePositionEntityKind.LocalPlayer => + RuntimeSetPositionOperationKind.LocalAuthoritative, + RuntimePositionEntityKind.Projectile => + RuntimeSetPositionOperationKind.ProjectileAuthoritative, + _ => RuntimeSetPositionOperationKind.RemoteAuthoritative, + }; + + private static RuntimeAuthoritativePositionRoute RejectedAuthority( + in RuntimeAuthoritativePositionAuthority authority, + RuntimeSetPositionOperationKind operation = + RuntimeSetPositionOperationKind.RemoteAuthoritative, + bool reporting = false) => new( + authority, + RuntimeAuthoritativePositionDisposition.RejectedAuthority, + operation, + PhysicsSetPositionFlags.None, + 0u, + UnparentBeforeRouting: false, + ApplyPlacementFrameBeforeRouting: false, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainAfterRouting: false, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + reporting); + + private static RuntimeAuthoritativePositionRoute RejectedData( + in RuntimeAuthoritativePositionAuthority authority, + RuntimeSetPositionOperationKind operation, + bool reporting) => new( + authority, + RuntimeAuthoritativePositionDisposition.RejectedData, + operation, + PhysicsSetPositionFlags.None, + 0u, + UnparentBeforeRouting: false, + ApplyPlacementFrameBeforeRouting: false, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainAfterRouting: false, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + reporting); +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs new file mode 100644 index 00000000..d895998e --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs @@ -0,0 +1,553 @@ +using System.Numerics; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimeAuthoritativePositionRouteClassifierTests +{ + [Theory] + [InlineData(RuntimePositionEntityKind.LocalPlayer, + RuntimeSetPositionOperationKind.InitialLogin, + RuntimeTeleportHookPhase.AfterEnterWorld)] + [InlineData(RuntimePositionEntityKind.Remote, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + RuntimeTeleportHookPhase.None)] + [InlineData(RuntimePositionEntityKind.Projectile, + RuntimeSetPositionOperationKind.ProjectileAuthoritative, + RuntimeTeleportHookPhase.None)] + internal void TopLevelCreate_AlwaysUsesRetailEnterWorldFlags( + RuntimePositionEntityKind kind, + RuntimeSetPositionOperationKind expectedKind, + RuntimeTeleportHookPhase expectedHookPhase) + { + RuntimeAuthoritativePositionRoute route = + RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate( + new RuntimeCreatePositionRouteRequest( + Authority(), + kind, + RuntimeCreateResidenceKind.TopLevel, + Position(), + new RuntimePositionPlacementFacts( + PhysicsStateFlags.Static + | PhysicsStateFlags.Hidden + | PhysicsStateFlags.NoDraw, + HasAuthoredMoverShape: false))); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPosition, + route.Disposition); + Assert.Equal(expectedKind, route.OperationKind); + Assert.Equal(0x11u, (uint)route.SetPositionFlags); + Assert.Equal(expectedHookPhase, route.TeleportHookPhase); + Assert.True(route.PerformsSetPosition); + Assert.False(route.CollisionBatchEligible); + } + + [Theory] + [InlineData(RuntimeCreateResidenceKind.Parented)] + [InlineData(RuntimeCreateResidenceKind.PickedUp)] + internal void NewParentedOrPickedUpCreate_LeavesWorldAndAwaitsPosition( + RuntimeCreateResidenceKind residence) + { + RuntimeAuthoritativePositionRoute route = + RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate( + new RuntimeCreatePositionRouteRequest( + Authority(), + RuntimePositionEntityKind.Remote, + residence, + AcceptedWirePosition: null, + default)); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, + route.Disposition); + Assert.True(route.LeaveWorld); + Assert.False(route.PerformsSetPosition); + } + + [Theory] + [MemberData(nameof(InvalidPositions))] + internal void CreateAndPositionRejectMalformedAuthoritativeFrames( + CreateObject.ServerPosition invalid) + { + RuntimeAuthoritativePositionRoute create = + RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate( + new RuntimeCreatePositionRouteRequest( + Authority(), + RuntimePositionEntityKind.Remote, + RuntimeCreateResidenceKind.TopLevel, + invalid, + default)); + RuntimeAuthoritativePositionRoute update = ClassifyRemote( + position: invalid, + committedCellId: Cell, + hasContact: true, + playerDistance: 1f); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.RejectedData, + create.Disposition); + Assert.Equal(RuntimeAuthoritativePositionDisposition.RejectedData, + update.Disposition); + } + + [Theory] + [InlineData(RuntimeAcceptedPositionSource.PositionEvent)] + [InlineData(RuntimeAcceptedPositionSource.SameIncarnationCreate)] + internal void AcceptedPosition_UnparentsAndAppliesDefaultZeroPlacementBeforeRoute( + RuntimeAcceptedPositionSource source) + { + RuntimeAuthoritativePositionRoute route = ClassifyRemote( + source: source, + placementFrame: null, + committedCellId: Cell, + hasContact: true, + playerDistance: 10f); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.Interpolate, + route.Disposition); + Assert.True(route.UnparentBeforeRouting); + Assert.True(route.ApplyPlacementFrameBeforeRouting); + Assert.Equal(0u, route.PlacementFrame); + } + + [Fact] + public void AnimatedAcceptedPosition_UnparentsButSkipsPlacementFrameReset() + { + RuntimeAuthoritativePositionRoute route = ClassifyRemote( + committedCellId: Cell, + hasContact: true, + playerDistance: 10f, + hasAnimations: true); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.Interpolate, + route.Disposition); + Assert.True(route.UnparentBeforeRouting); + Assert.False(route.ApplyPlacementFrameBeforeRouting); + } + + [Theory] + [InlineData(95.999f, RuntimeAuthoritativePositionDisposition.Interpolate)] + [InlineData(96f, RuntimeAuthoritativePositionDisposition.SetPositionSimple)] + internal void SameIncarnationCreate_ForcesContactRoute( + float distance, + RuntimeAuthoritativePositionDisposition expected) + { + RuntimeAuthoritativePositionRoute route = ClassifyRemote( + source: RuntimeAcceptedPositionSource.SameIncarnationCreate, + committedCellId: Cell, + hasContact: false, + playerDistance: distance); + + Assert.Equal(expected, route.Disposition); + Assert.True(route.ConstrainAfterRouting); + } + + [Theory] + [InlineData(ushort.MaxValue, 0)] + [InlineData(0x8000, 0)] + internal void RemoteFreshTeleport_UsesWrapSafeTimestampAndRetailHook( + ushort previousTeleport, + ushort acceptedTeleport) + { + RuntimeAuthoritativePositionRoute route = ClassifyRemote( + authority: Authority(previousTeleport, acceptedTeleport), + committedCellId: Cell, + hasContact: false, + playerDistance: float.NaN); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPosition, + route.Disposition); + Assert.Equal(0x1012u, (uint)route.SetPositionFlags); + Assert.Equal(RuntimeTeleportHookPhase.BeforePositionOperation, + route.TeleportHookPhase); + Assert.True(route.ConstrainAfterRouting); + } + + [Fact] + public void RemoteCellless_UsesCommittedResidenceNotAcceptedWireCell() + { + CreateObject.ServerPosition accepted = Position(Cell, 200f); + + RuntimeAuthoritativePositionRoute route = ClassifyRemote( + position: accepted, + committedCellId: null, + hasContact: true, + playerDistance: 1f); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPosition, + route.Disposition); + Assert.Equal(RuntimeTeleportHookPhase.BeforePositionOperation, + route.TeleportHookPhase); + Assert.Equal(Cell, accepted.LandblockId); + } + + [Fact] + public void RemoteNoContact_PerformsNoPositionOperationOrConstraint() + { + RuntimeAuthoritativePositionRoute route = ClassifyRemote( + committedCellId: Cell, + hasContact: false, + playerDistance: float.NaN); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.NoPositionOperation, + route.Disposition); + Assert.False(route.ConstrainAfterRouting); + Assert.False(route.PerformsSetPosition); + } + + [Theory] + [InlineData(0f, RuntimeAuthoritativePositionDisposition.Interpolate, false)] + [InlineData(95.999f, RuntimeAuthoritativePositionDisposition.Interpolate, false)] + [InlineData(96f, RuntimeAuthoritativePositionDisposition.SetPositionSimple, true)] + [InlineData(500f, RuntimeAuthoritativePositionDisposition.SetPositionSimple, true)] + internal void RemoteContact_UsesExactNinetySixBoundary( + float distance, + RuntimeAuthoritativePositionDisposition expected, + bool stopInterpolating) + { + RuntimeAuthoritativePositionRoute route = ClassifyRemote( + committedCellId: Cell, + hasContact: true, + playerDistance: distance); + + Assert.Equal(expected, route.Disposition); + Assert.Equal(stopInterpolating, route.StopInterpolating); + Assert.True(route.ConstrainAfterRouting); + Assert.Equal(stopInterpolating ? 0x1012u : 0u, + (uint)route.SetPositionFlags); + } + + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + [InlineData(-0.001f)] + internal void RemoteContact_RejectsMalformedDerivedDistance(float distance) + { + RuntimeAuthoritativePositionRoute route = ClassifyRemote( + committedCellId: Cell, + hasContact: true, + playerDistance: distance); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.RejectedData, + route.Disposition); + } + + [Fact] + public void RemotePositionPackVelocity_IsDeadRoutingInput() + { + RuntimeAcceptedPositionRouteRequest request = RemoteRequest( + committedCellId: Cell, + hasContact: true, + playerDistance: 10f) with + { + PositionPackVelocity = new Vector3(float.NaN, float.PositiveInfinity, 7f), + }; + + RuntimeAuthoritativePositionRoute route = + RuntimeAuthoritativePositionRouteClassifier + .ClassifyAcceptedPosition(request); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.Interpolate, + route.Disposition); + Assert.True(route.Accepted); + } + + [Fact] + public void LocalFreshForce_PreservesHeadingBlipsAndAcknowledgesImmediately() + { + RuntimeAuthoritativePositionRoute route = ClassifyLocal( + Authority( + previousTeleport: 10, + acceptedTeleport: 10, + disposition: PositionTimestampDisposition.ForcePosition)); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, + route.Disposition); + Assert.Equal(0x1012u, (uint)route.SetPositionFlags); + Assert.True(route.PreserveHeading); + Assert.True(route.SendPositionImmediately); + Assert.False(route.UnparentBeforeRouting); + Assert.False(route.ApplyPlacementFrameBeforeRouting); + Assert.False(route.ConstrainAfterRouting); + } + + [Fact] + public void ForcePosition_IsRejectedForRemoteOrUnequalTeleport() + { + RuntimeAuthoritativePositionAuthority force = Authority( + 10, 10, PositionTimestampDisposition.ForcePosition); + RuntimeAuthoritativePositionRoute remote = ClassifyRemote(authority: force); + RuntimeAuthoritativePositionRoute unequal = ClassifyLocal( + Authority(10, 11, PositionTimestampDisposition.ForcePosition)); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.RejectedAuthority, + remote.Disposition); + Assert.Equal(RuntimeAuthoritativePositionDisposition.RejectedAuthority, + unequal.Disposition); + } + + [Fact] + public void LocalFreshTeleport_SetsPositionConstrainsAndZerosVelocity() + { + RuntimeAuthoritativePositionRoute route = ClassifyLocal( + Authority(ushort.MaxValue, 0)); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPosition, + route.Disposition); + Assert.Equal(0x1012u, (uint)route.SetPositionFlags); + Assert.True(route.UnparentBeforeRouting); + Assert.True(route.ApplyPlacementFrameBeforeRouting); + Assert.True(route.ConstrainAfterRouting); + Assert.True(route.ZeroVelocity); + Assert.Equal(RuntimeTeleportHookPhase.AfterPositionOperation, + route.TeleportHookPhase); + } + + [Theory] + [InlineData(false, false, false, RuntimeAuthoritativePositionDisposition.NoPositionOperation)] + [InlineData(false, true, true, RuntimeAuthoritativePositionDisposition.NoPositionOperation)] + [InlineData(true, false, true, RuntimeAuthoritativePositionDisposition.NoPositionOperation)] + [InlineData(true, true, false, RuntimeAuthoritativePositionDisposition.Interpolate)] + [InlineData(true, true, true, RuntimeAuthoritativePositionDisposition.Interpolate)] + internal void LocalOrdinary_AlwaysConstrainsAndInterpolatesOnlyByOptionAndContact( + bool usePositionFromServer, + bool hasContact, + bool hasVelocity, + RuntimeAuthoritativePositionDisposition expected) + { + RuntimeAuthoritativePositionRoute route = ClassifyLocal( + Authority(), + usePositionFromServer, + hasContact, + hasVelocity + ? new Vector3(float.NaN, float.PositiveInfinity, 1f) + : null); + + Assert.Equal(expected, route.Disposition); + Assert.True(route.ConstrainAfterRouting); + } + + [Theory] + [InlineData(RuntimeLeaveWorldCause.Pickup)] + [InlineData(RuntimeLeaveWorldCause.Parent)] + internal void PickupAndParent_LeaveWorldAndAwaitLaterFreshPosition( + RuntimeLeaveWorldCause cause) + { + RuntimeAuthoritativePositionRoute route = + RuntimeAuthoritativePositionRouteClassifier.ClassifyLeaveWorld( + new RuntimeLeaveWorldRouteRequest( + Authority(), + RuntimePositionEntityKind.Projectile, + cause, + default)); + + Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, + route.Disposition); + Assert.Equal(RuntimeSetPositionOperationKind.ProjectileAuthoritative, + route.OperationKind); + Assert.True(route.LeaveWorld); + Assert.False(route.PerformsSetPosition); + } + + [Fact] + public void HiddenAndNoDrawDoNotGatePlacement_ButReportingRemainsSeparate() + { + RuntimePositionPlacementFacts visible = new( + PhysicsStateFlags.None, + HasAuthoredMoverShape: true); + RuntimePositionPlacementFacts noDraw = visible with + { + PhysicsState = PhysicsStateFlags.NoDraw, + }; + RuntimePositionPlacementFacts hidden = visible with + { + PhysicsState = PhysicsStateFlags.Hidden, + }; + RuntimePositionPlacementFacts ignored = visible with + { + PhysicsState = PhysicsStateFlags.IgnoreCollisions, + }; + + RuntimeAuthoritativePositionRoute visibleRoute = + RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate( + new RuntimeCreatePositionRouteRequest( + Authority(), + RuntimePositionEntityKind.Remote, + RuntimeCreateResidenceKind.TopLevel, + Position(), + visible)); + RuntimeAuthoritativePositionRoute noDrawRoute = + RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate( + new RuntimeCreatePositionRouteRequest( + Authority(), + RuntimePositionEntityKind.Remote, + RuntimeCreateResidenceKind.TopLevel, + Position(), + noDraw)); + RuntimeAuthoritativePositionRoute hiddenRoute = + RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate( + new RuntimeCreatePositionRouteRequest( + Authority(), + RuntimePositionEntityKind.Remote, + RuntimeCreateResidenceKind.TopLevel, + Position(), + hidden)); + RuntimeAuthoritativePositionRoute ignoredRoute = + RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate( + new RuntimeCreatePositionRouteRequest( + Authority(), + RuntimePositionEntityKind.Remote, + RuntimeCreateResidenceKind.TopLevel, + Position(), + ignored)); + + Assert.All([visibleRoute, noDrawRoute, hiddenRoute, ignoredRoute], route => + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPosition, + route.Disposition)); + Assert.True(visibleRoute.CollisionBatchEligible); + Assert.True(noDrawRoute.CollisionBatchEligible); + Assert.False(hiddenRoute.CollisionBatchEligible); + Assert.True(ignoredRoute.CollisionBatchEligible); + } + + [Fact] + public void ProjectilePosition_UsesRemoteMoveOrTeleportClassification() + { + RuntimeAcceptedPositionRouteRequest request = RemoteRequest( + committedCellId: Cell, + hasContact: true, + playerDistance: 96f) with + { + EntityKind = RuntimePositionEntityKind.Projectile, + }; + + RuntimeAuthoritativePositionRoute route = + RuntimeAuthoritativePositionRouteClassifier + .ClassifyAcceptedPosition(request); + + Assert.Equal(RuntimeSetPositionOperationKind.ProjectileAuthoritative, + route.OperationKind); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, + route.Disposition); + } + + [Fact] + public void StaleOrMalformedAuthorityIsRejectedWithoutRouting() + { + RuntimeAuthoritativePositionRoute stale = ClassifyRemote( + authority: Authority(previousTeleport: 0, acceptedTeleport: ushort.MaxValue)); + RuntimeAuthoritativePositionRoute wrongGeneration = ClassifyRemote( + authority: Authority() with { Generation = default }); + RuntimeAuthoritativePositionRoute wrongEntity = ClassifyRemote( + authority: Authority() with { Entity = default }); + RuntimeAuthoritativePositionRoute wrongVersion = ClassifyRemote( + authority: Authority() with { PositionAuthorityVersion = 0UL }); + + Assert.All([stale, wrongGeneration, wrongEntity, wrongVersion], route => + Assert.Equal(RuntimeAuthoritativePositionDisposition.RejectedAuthority, + route.Disposition)); + } + + public static TheoryData InvalidPositions => new() + { + Position(cell: 0u), + Position(x: float.NaN), + Position(x: float.PositiveInfinity), + Position(rotationW: 0f), + Position(rotationW: float.NaN), + }; + + private const uint Cell = 0x0101FFFFu; + + private static RuntimeAuthoritativePositionAuthority Authority( + ushort previousTeleport = 10, + ushort acceptedTeleport = 10, + PositionTimestampDisposition disposition = PositionTimestampDisposition.Apply) => + new( + new RuntimeGenerationToken(7), + new RuntimeEntityKey(0x70000001u, 3), + PositionAuthorityVersion: 11UL, + AcceptedPositionSequence: 20, + previousTeleport, + acceptedTeleport, + disposition); + + private static RuntimeAuthoritativePositionRoute ClassifyRemote( + RuntimeAuthoritativePositionAuthority? authority = null, + RuntimeAcceptedPositionSource source = RuntimeAcceptedPositionSource.PositionEvent, + CreateObject.ServerPosition? position = null, + uint? placementFrame = 0u, + uint? committedCellId = Cell, + bool hasContact = true, + float playerDistance = 10f, + bool hasAnimations = false) => + RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition( + RemoteRequest( + authority, + source, + position, + placementFrame, + committedCellId, + hasContact, + playerDistance, + hasAnimations)); + + private static RuntimeAcceptedPositionRouteRequest RemoteRequest( + RuntimeAuthoritativePositionAuthority? authority = null, + RuntimeAcceptedPositionSource source = RuntimeAcceptedPositionSource.PositionEvent, + CreateObject.ServerPosition? position = null, + uint? placementFrame = 0u, + uint? committedCellId = Cell, + bool hasContact = true, + float playerDistance = 10f, + bool hasAnimations = false) => + new( + authority ?? Authority(), + RuntimePositionEntityKind.Remote, + source, + position ?? Position(), + placementFrame, + PositionPackVelocity: Vector3.One, + committedCellId, + hasContact, + playerDistance, + UsePositionFromServer: false, + hasAnimations, + default); + + private static RuntimeAuthoritativePositionRoute ClassifyLocal( + RuntimeAuthoritativePositionAuthority authority, + bool usePositionFromServer = false, + bool hasContact = true, + Vector3? velocity = null) => + RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition( + new RuntimeAcceptedPositionRouteRequest( + authority, + RuntimePositionEntityKind.LocalPlayer, + RuntimeAcceptedPositionSource.PositionEvent, + Position(), + PlacementFrame: null, + PositionPackVelocity: velocity, + CommittedCellId: Cell, + hasContact, + PlayerDistance: 0f, + usePositionFromServer, + HasAnimations: false, + default)); + + private static CreateObject.ServerPosition Position( + uint cell = Cell, + float x = 10f, + float rotationW = 1f) => + new( + cell, + x, + 20f, + 30f, + rotationW, + 0f, + 0f, + 0f); +} From 74103f75b50e23e203900786e3e47c379a60a5ee Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 18:43:23 +0200 Subject: [PATCH 42/73] feat(app): stage live entities before runtime placement --- src/AcDream.App/World/LiveEntityRuntime.cs | 65 ++++++++- .../World/LiveEntityRuntimeTests.cs | 125 ++++++++++++++++++ 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 36650ef7..e4080dee 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -30,6 +30,18 @@ public enum LiveEntityProjectionKind Attached, } +/// +/// Selects whether App may publish a newly constructed sidecar immediately or +/// must keep it non-spatial until the canonical Runtime SetPosition receipt is +/// projected. The latter is the production CreateObject path; the former is a +/// temporary compatibility seam for callers not yet cut over in Slice 4B2. +/// +internal enum LiveEntityMaterializationResidence +{ + LegacyImmediate, + AwaitRuntimePlacement, +} + /// /// Logical-resource seam coordinated by . /// Spatial bucketing is deliberately absent: registering or removing meshes, @@ -249,6 +261,8 @@ public sealed class LiveEntityRecord public IRuntimeProjectile? ProjectileRuntime => Canonical.Projectile; public ILiveEntityEffectProfile? EffectProfile { get; internal set; } public bool ResourcesRegistered { get; internal set; } + internal LiveEntityMaterializationResidence? MaterializationResidence + { get; set; } public bool IsSpatiallyProjected { get; internal set; } public bool IsSpatiallyVisible { get; internal set; } internal ulong ProjectionMutationVersion { get; set; } @@ -578,10 +592,20 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource Func factory, LiveEntityProjectionKind projectionKind, Action? initializeProjection, - out LiveEntityRecord? record) + out LiveEntityRecord? record, + LiveEntityMaterializationResidence residence = + LiveEntityMaterializationResidence.LegacyImmediate) { ArgumentNullException.ThrowIfNull(expectedCanonical); ArgumentNullException.ThrowIfNull(factory); + if (residence is LiveEntityMaterializationResidence + .AwaitRuntimePlacement + && projectionKind is not LiveEntityProjectionKind.World) + { + throw new ArgumentException( + "Only a top-level world projection can await canonical Runtime placement.", + nameof(projectionKind)); + } record = null; if (_isClearing || _sessionClearPendingFinalization @@ -605,6 +629,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource { record = _projections.AddMaterializing(expectedCanonical); createdSidecar = true; + record.MaterializationResidence = residence; initializeProjection?.Invoke(record); } catch @@ -618,6 +643,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource throw; } } + else if (record.MaterializationResidence != residence) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8}/{expectedCanonical.Incarnation} " + + $"cannot change its materialization residence from " + + $"{record.MaterializationResidence} to {residence}."); + } if (record.WorldEntity is null) { @@ -653,6 +685,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource } record.WorldEntity = entity; + // Runtime-placement residence is sticky for this incarnation and + // must suppress draw before arbitrary resource callbacks observe + // or re-enter the sidecar. + RefreshPresentation(record); _isRegisteringResources = true; try { @@ -726,6 +762,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource } RefreshPresentation(record); WorldEntity materialized = record.WorldEntity!; + if (residence is LiveEntityMaterializationResidence + .AwaitRuntimePlacement) + { + // Resource construction is a logical App-side ownership edge, not + // world residence. Runtime's immutable Place receipt is the only + // path which may install the accepted frame and spatial bucket. + record.IsSpatiallyProjected = false; + record.IsSpatiallyVisible = false; + RefreshSpatialPresentationIndexes(record); + RefreshPresentation(record); + return materialized; + } if (!RebucketLiveEntity(serverGuid, fullCellId) || !_projections.TryGet(expectedCanonical, out LiveEntityRecord? current) || !ReferenceEquals(current, record) @@ -746,6 +794,14 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.WorldEntity is not { } entity) return false; + if (record.MaterializationResidence is + LiveEntityMaterializationResidence.AwaitRuntimePlacement) + { + // The private Runtime Place path below performs a presentation- + // only bucket update. This legacy API also commits canonical + // Runtime residence and cannot touch a cut-over incarnation. + return false; + } RuntimeEntityKey key = RequireProjectionKey(record); bool wasProjected = record.IsSpatiallyProjected; @@ -3031,8 +3087,11 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return; PhysicsStateFlags state = record.FinalPhysicsState; - entity.IsDrawVisible = - (state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0; + bool residenceVisible = record.MaterializationResidence is not + LiveEntityMaterializationResidence.AwaitRuntimePlacement + || record.IsSpatiallyProjected; + entity.IsDrawVisible = residenceVisible + && (state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0; bool interactionVisible = record.IsSpatiallyVisible && record.ProjectionKind is LiveEntityProjectionKind.World diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index e462ad0b..d4704ef4 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -202,6 +202,131 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(new[] { false, true }, visibilityEdges); } + [Fact] + public void RuntimePlacementPendingMaterialization_OwnsResourcesWithoutPublishingResidence() + { + const uint guid = 0x70000081u; + const uint cell = 0x01010001u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + LiveEntityRuntime runtime = LiveEntityRuntimeFixture.Create( + spatial, + resources); + LiveEntityRegistrationResult registration = runtime.RegisterLiveEntity( + Spawn(guid, instance: 1, positionSequence: 1, cell)); + RuntimeEntityRecord canonical = Assert.IsType( + registration.Canonical); + uint acceptedCell = canonical.FullCellId; + ulong spatialAuthority = canonical.SpatialAuthorityVersion; + ulong placementCommit = canonical.PlacementCommitVersion; + ulong clockEpoch = canonical.ObjectClockEpoch; + bool clockActive = canonical.ObjectClock.IsActive; + RuntimePhysicsOwnershipSnapshot physicsOwnership = + runtime.Physics.CaptureOwnership(); + var visibilityEdges = new List(); + runtime.ProjectionVisibilityChanged += (_, visible) => + visibilityEdges.Add(visible); + resources.OnRegister = entity => + { + Assert.False(entity.IsDrawVisible); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + Assert.False(current.IsSpatiallyProjected); + Assert.False(current.IsSpatiallyVisible); + Assert.False(runtime.RebucketLiveEntity(guid, cell)); + }; + + WorldEntity entity = Assert.IsType( + runtime.MaterializeLiveEntity( + canonical, + cell, + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out LiveEntityRecord? record, + LiveEntityMaterializationResidence.AwaitRuntimePlacement)); + + Assert.NotNull(record); + Assert.Same(entity, record!.WorldEntity); + Assert.True(record.ResourcesRegistered); + Assert.False(record.IsSpatiallyProjected); + Assert.False(record.IsSpatiallyVisible); + Assert.False(entity.IsDrawVisible); + Assert.Equal(acceptedCell, canonical.FullCellId); + Assert.Equal(spatialAuthority, canonical.SpatialAuthorityVersion); + Assert.Equal(placementCommit, canonical.PlacementCommitVersion); + Assert.Equal(clockEpoch, canonical.ObjectClockEpoch); + Assert.Equal(clockActive, canonical.ObjectClock.IsActive); + Assert.Equal(physicsOwnership, runtime.Physics.CaptureOwnership()); + Assert.Equal(0, runtime.SpatialRootObjectCount); + Assert.Equal(1, resources.RegisterCount); + Assert.Empty(spatial.Entities); + Assert.Empty(runtime.VisibleRecords); + Assert.Empty(visibilityEdges); + + WorldEntity retained = Assert.IsType( + runtime.MaterializeLiveEntity( + canonical, + cell, + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out LiveEntityRecord? retainedRecord, + LiveEntityMaterializationResidence.AwaitRuntimePlacement)); + Assert.Same(entity, retained); + Assert.Same(record, retainedRecord); + Assert.Equal(1, resources.RegisterCount); + Assert.False(runtime.RebucketLiveEntity(guid, cell)); + Assert.Throws(() => + runtime.MaterializeLiveEntity( + canonical, + cell, + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out _, + LiveEntityMaterializationResidence.LegacyImmediate)); + Assert.Empty(spatial.Entities); + } + + [Fact] + public void LegacyMaterialization_CannotSwitchToRuntimePlacementResidence() + { + const uint guid = 0x70000082u; + const uint cell = 0x01010001u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + var runtime = LiveEntityRuntimeFixture.Create(spatial, resources); + RuntimeEntityRecord canonical = Assert.IsType( + runtime.RegisterLiveEntity( + Spawn(guid, instance: 1, positionSequence: 1, cell)).Canonical); + WorldEntity entity = Assert.IsType( + runtime.MaterializeLiveEntity( + canonical, + cell, + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out LiveEntityRecord? record)); + + Assert.Throws(() => + runtime.MaterializeLiveEntity( + canonical, + cell, + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out _, + LiveEntityMaterializationResidence.AwaitRuntimePlacement)); + + Assert.True(record!.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.True(entity.IsDrawVisible); + Assert.Single(spatial.Entities); + Assert.Equal(1, resources.RegisterCount); + } + [Fact] public void StaticRootCommit_HiddenObjectUpdatesPoseWithoutRestoringShadow() { From 38fd4b8dc952236d4b98518c67335026c7815656 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 19:35:08 +0200 Subject: [PATCH 43/73] feat(runtime): own initial create residence transaction --- .../Physics/PhysicsTimestampGate.cs | 19 + .../Entities/InboundPhysicsStateController.cs | 6 + .../Entities/RuntimeEntityDirectory.cs | 4 + .../Entities/RuntimeEntityObjectLifetime.cs | 292 ++- .../RuntimeInitialCreateResidenceState.cs | 743 +++++++ ...imeAuthoritativePositionRouteClassifier.cs | 10 +- .../Physics/RuntimeSetPositionState.cs | 147 ++ .../Physics/MotionSequenceGateTests.cs | 29 + ...RuntimeInitialCreateResidenceStateTests.cs | 1792 +++++++++++++++++ ...thoritativePositionRouteClassifierTests.cs | 6 +- 10 files changed, 3031 insertions(+), 17 deletions(-) create mode 100644 src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs create mode 100644 tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs diff --git a/src/AcDream.Core/Physics/PhysicsTimestampGate.cs b/src/AcDream.Core/Physics/PhysicsTimestampGate.cs index baae0594..0b9e5ad2 100644 --- a/src/AcDream.Core/Physics/PhysicsTimestampGate.cs +++ b/src/AcDream.Core/Physics/PhysicsTimestampGate.cs @@ -102,6 +102,25 @@ public sealed class PhysicsTimestampGate return CreateObjectTimestampDisposition.ExistingGeneration; } + /// + /// Classifies an incoming CreateObject generation without consuming any + /// channel timestamp. Runtime uses this to preflight only admissions which + /// can create or replace a canonical incarnation; equal/stale packets must + /// still reach the normal per-channel retail gates unchanged. + /// + public CreateObjectTimestampDisposition PreviewCreateObject( + ushort instance) + { + if (!_seeded) + return CreateObjectTimestampDisposition.InitialGeneration; + ushort currentInstance = _timestamps[Instance]; + if (IsNewer(currentInstance, instance)) + return CreateObjectTimestampDisposition.NewGeneration; + if (IsNewer(instance, currentInstance)) + return CreateObjectTimestampDisposition.StaleGeneration; + return CreateObjectTimestampDisposition.ExistingGeneration; + } + public bool TryAcceptMovementEvent( ushort instance, ushort movement, diff --git a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs index 2b949929..a88a9058 100644 --- a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs +++ b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs @@ -20,6 +20,12 @@ public sealed class InboundPhysicsStateController public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) => _snapshots.TryGetValue(guid, out spawn); + public CreateObjectTimestampDisposition PreviewCreateDisposition( + WorldSession.EntitySpawn incoming) => + _gates.TryGetValue(incoming.Guid, out PhysicsTimestampGate? gate) + ? gate.PreviewCreateObject(incoming.InstanceSequence) + : CreateObjectTimestampDisposition.InitialGeneration; + public InboundCreateResult AcceptCreate(WorldSession.EntitySpawn incoming) { if (!_gates.TryGetValue(incoming.Guid, out PhysicsTimestampGate? gate)) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index e9b028ae..293f34db 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -47,6 +47,10 @@ public sealed class RuntimeEntityDirectory public InboundCreateResult AcceptCreate(WorldSession.EntitySpawn incoming) => _inbound.AcceptCreate(incoming); + public CreateObjectTimestampDisposition PreviewCreateDisposition( + WorldSession.EntitySpawn incoming) => + _inbound.PreviewCreateDisposition(incoming); + public bool TryDelete( AcDream.Core.Net.Messages.DeleteObject.Parsed delete, bool isLocalPlayer) => diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 03dd3cc1..3c377d6c 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -27,6 +27,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( int ContainerProjectionCount, int EquipmentOwnerCount, int PendingMoveCount, + int InitialCreateResidenceLeaseCount, int StreamSubscriberCount, int PlacementStreamSubscriberCount, long StreamDispatchFailureCount, @@ -51,6 +52,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && ContainerProjectionCount == 0 && EquipmentOwnerCount == 0 && PendingMoveCount == 0 + && InitialCreateResidenceLeaseCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -118,6 +120,9 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); Physics.SetPosition.BindEventStream(Events); + InitialCreateResidences = new RuntimeInitialCreateResidenceState( + Entities, + Physics.SetPosition); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -143,6 +148,9 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); Physics.SetPosition.BindEventStream(Events); + InitialCreateResidences = new RuntimeInitialCreateResidenceState( + Entities, + Physics.SetPosition); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -168,6 +176,9 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); Physics.SetPosition.BindEventStream(Events); + InitialCreateResidences = new RuntimeInitialCreateResidenceState( + Entities, + Physics.SetPosition); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -180,10 +191,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable public IRuntimeInventoryView InventoryView { get; } public RuntimeEntityObjectEventStream Events { get; } public RuntimePlacementProjectionChannel Placements { get; } + internal RuntimeInitialCreateResidenceState InitialCreateResidences + { get; } public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership() { ParentAttachmentState parents = Entities.ParentAttachments; + RuntimeInitialCreateResidenceOwnershipSnapshot initialResidence = + InitialCreateResidences.CaptureOwnership(); return new RuntimeEntityObjectOwnershipSnapshot( Entities.Count, Entities.PendingTeardownCount, @@ -198,6 +213,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Objects.ContainerProjectionCount, Objects.EquipmentOwnerCount, Objects.PendingMoveCount, + initialResidence.ActiveLeaseCount + + initialResidence.PendingAdoptionCount, Events.SubscriberCount, Events.PlacementSubscriberCount, Events.DispatchFailureCount, @@ -215,6 +232,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable EnsureNotDisposed(); Events.BindContext(generation, frameNumber); Placements.BindGeneration(generation); + InitialCreateResidences.BindGeneration(generation); } /// @@ -225,7 +243,28 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable /// public RuntimeEntityRegistrationResult RegisterEntity( WorldSession.EntitySpawn incoming, - Func? retirePriorProjection = null) + Func? retirePriorProjection = null) => + RegisterEntityCore( + incoming, + beginInitialResidence: false, + isLocalPlayer: false, + retirePriorProjection); + + internal RuntimeEntityRegistrationResult RegisterEntityWithInitialResidence( + WorldSession.EntitySpawn incoming, + bool isLocalPlayer, + Func? retirePriorProjection = null) => + RegisterEntityCore( + incoming, + beginInitialResidence: true, + isLocalPlayer, + retirePriorProjection); + + private RuntimeEntityRegistrationResult RegisterEntityCore( + WorldSession.EntitySpawn incoming, + bool beginInitialResidence, + bool isLocalPlayer, + Func? retirePriorProjection) { EnsureNotDisposed(); if (_sessionClearInProgress) @@ -233,6 +272,18 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable throw new InvalidOperationException( "A Runtime entity cannot register while its session lifetime is clearing."); } + CreateObjectTimestampDisposition preview = + Entities.PreviewCreateDisposition(incoming); + bool requiresFreshResidenceAdmission = preview is + CreateObjectTimestampDisposition.InitialGeneration + or CreateObjectTimestampDisposition.NewGeneration; + if (beginInitialResidence + && requiresFreshResidenceAdmission + && !InitialCreateResidences.CanAcceptCreate(incoming)) + { + throw new InvalidOperationException( + $"CreateObject 0x{incoming.Guid:X8} cannot acquire a structurally valid initial residence lease."); + } InboundCreateResult result = Entities.AcceptCreate(incoming); if (result.Disposition @@ -256,11 +307,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable incoming.Guid, out RuntimeEntityRecord retained)) { + // Existing-generation CreateObject contributes untimestamped + // description fields here. Position/Parent/Pickup/State/etc. + // remain separate freshness-gated events and must not churn a + // pending initial placement or re-preclaim its wire cell. Entities.RefreshSnapshot( retained, result.Snapshot, - refreshPosition: true); - Entities.AdvanceCreateAuthority(retained); + refreshPosition: !beginInitialResidence); + if (!beginInitialResidence) + Entities.AdvanceCreateAuthority(retained); PublishEntity(RuntimeEntityChange.Updated, retained); if (!IsCurrentOperation( incoming.Guid, @@ -291,7 +347,24 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable ReplacedExistingGeneration: false); } + if (beginInitialResidence + && !InitialCreateResidences.CanAcceptCreate(result.Snapshot)) + { + throw new InvalidOperationException( + $"Recovered CreateObject 0x{incoming.Guid:X8} cannot acquire a structurally valid initial residence lease."); + } + RuntimeEntityRecord recovered = Entities.AddActive(result.Snapshot); + if (!InitializeAcceptedCreateResidence( + recovered, + result, + beginInitialResidence, + isLocalPlayer)) + { + throw FailInitialResidenceRegistration( + recovered, + publishDeleted: false); + } PublishEntity(RuntimeEntityChange.Registered, recovered); if (!IsCurrentOperation( incoming.Guid, @@ -366,6 +439,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable } RuntimeEntityRecord canonical = Entities.AddActive(result.Snapshot); + if (!InitializeAcceptedCreateResidence( + canonical, + result, + beginInitialResidence, + isLocalPlayer)) + { + throw FailInitialResidenceRegistration( + canonical, + publishDeleted: false); + } try { PublishEntity(RuntimeEntityChange.Registered, canonical); @@ -446,10 +529,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(canonical); - RuntimePlacementCancellationReceipt cancellation = + RuntimePlacementCancellationReceipt initialCancellation = + ForgetInitialCreateResidence(canonical); + RuntimePlacementCancellationReceipt ordinaryCancellation = Physics.SetPosition.Forget( canonical, releasePreparedMover: true); + RuntimePlacementCancellationReceipt cancellation = + PreferCancellation(initialCancellation, ordinaryCancellation); Physics.CollisionReports.Forget(canonical); Physics.RemoveSpatialProjection(canonical); Entities.SetRemoteMotion(canonical, null); @@ -540,10 +627,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable } Entities.RefreshSnapshot(canonical, accepted); + RuntimePlacementCancellationReceipt initialCancellation = + ForgetInitialCreateResidence(canonical); Entities.AdvancePositionAuthority(canonical); Physics.CollisionReports.LeaveWorld(canonical); - RuntimePlacementCancellationReceipt cancellation = + RuntimePlacementCancellationReceipt ordinaryCancellation = Physics.SetPosition.Forget(canonical); + RuntimePlacementCancellationReceipt cancellation = + PreferCancellation(initialCancellation, ordinaryCancellation); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); Entities.ParentAttachments.EndChildProjection(update.Guid); @@ -631,8 +722,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return false; } - RuntimePlacementCancellationReceipt cancellation = + RuntimePlacementCancellationReceipt initialCancellation = + ForgetInitialCreateResidence(canonical); + RuntimePlacementCancellationReceipt ordinaryCancellation = Physics.SetPosition.Forget(canonical); + RuntimePlacementCancellationReceipt cancellation = + PreferCancellation(initialCancellation, ordinaryCancellation); Physics.CollisionReports.LeaveWorld(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); @@ -788,6 +883,25 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable out AcceptedPhysicsTimestamps timestamps) { EnsureNotDisposed(); + RuntimeInitialCreateResidenceLease priorInitialResidence = default; + bool hadPendingInitialResidence = + Entities.TryGetActive( + update.Guid, + out RuntimeEntityRecord pendingCanonical) + && InitialCreateResidences.TryGetTransaction( + pendingCanonical, + out priorInitialResidence); + if (hadPendingInitialResidence + && !InitialCreateResidences.CanEnqueueAcceptedPosition( + pendingCanonical, + priorInitialResidence, + update)) + { + disposition = PositionTimestampDisposition.Rejected; + accepted = default; + timestamps = default; + return false; + } bool hadCanonical = Entities.TryGetActive( update.Guid, out RuntimeEntityRecord beforeCanonical); @@ -825,9 +939,47 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable }; } - RuntimePlacementCancellationReceipt cancellation = acceptedPosition - ? Physics.SetPosition.Forget(canonical) - : default; + + // Initial CreateObject residence is one immutable transaction. A + // Position packet accepted before its 0x11 placement completes is + // retained in the transaction's ordered continuation batch; it must + // not replace the initial mover operation, mutate the Runtime record + // used to prepare that operation, or escape through a host callback. + if (hadPendingInitialResidence) + { + if (!acceptedPosition) + return true; + if (!ReferenceEquals(canonical, pendingCanonical)) + throw FailInitialResidenceRegistration( + canonical, + publishDeleted: true); + + RuntimeInitialCreateResidenceLease retained = + InitialCreateResidences.EnqueueAcceptedPosition( + canonical, + priorInitialResidence, + update, + accepted, + disposition, + timestamps, + isLocalPlayer, + forcePositionRotation, + currentLocalVelocity, + projectionRequiresTeleportHook); + if (!retained.IsValid) + { + throw FailInitialResidenceRegistration( + canonical, + publishDeleted: true); + } + return true; + } + + RuntimePlacementCancellationReceipt cancellation = default; + if (acceptedPosition) + { + cancellation = Physics.SetPosition.Forget(canonical); + } Entities.RefreshSnapshot( canonical, snapshot, @@ -903,6 +1055,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable if (!Entities.IsCurrent(canonical)) return false; + RuntimePlacementCancellationReceipt cancellation = + ForgetInitialCreateResidence(canonical); Physics.CollisionReports.LeaveWorld(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); @@ -911,7 +1065,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable canonical, () => acknowledgeProjection?.Invoke(canonical), RuntimeEntityChange.Withdrawn, - () => canonical.SpatialAuthorityVersion == spatialVersion); + () => canonical.SpatialAuthorityVersion == spatialVersion, + cancellation); } public bool CommitChildNoDraw( @@ -981,11 +1136,17 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable && active.Incarnation == delete.InstanceSequence && Entities.RemoveActive(active)) { + RuntimePlacementCancellationReceipt initialCancellation = + ForgetInitialCreateResidence(active); Physics.CollisionReports.Forget(active); - RuntimePlacementCancellationReceipt cancellation = + RuntimePlacementCancellationReceipt ordinaryCancellation = Physics.SetPosition.Forget( active, releasePreparedMover: true); + RuntimePlacementCancellationReceipt cancellation = + PreferCancellation( + initialCancellation, + ordinaryCancellation); retiredCanonical = active; Entities.RetainTeardown(active); Physics.SetPosition.PublishCancellation(cancellation); @@ -1058,6 +1219,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable _sessionClearInProgress = true; RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); + InitialCreateResidences.Clear(); Physics.CollisionReports.LeaveWorldBatch(active); Physics.ResetSessionPhysics(); Entities.BeginSessionClear(); @@ -1193,10 +1355,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable } Entities.RefreshSnapshot(canonical, accepted); + RuntimePlacementCancellationReceipt initialCancellation = + ForgetInitialCreateResidence(canonical); Entities.AdvancePositionAuthority(canonical); Physics.CollisionReports.LeaveWorld(canonical); - RuntimePlacementCancellationReceipt cancellation = + RuntimePlacementCancellationReceipt ordinaryCancellation = Physics.SetPosition.Forget(canonical); + RuntimePlacementCancellationReceipt cancellation = + PreferCancellation(initialCancellation, ordinaryCancellation); ulong positionVersion = canonical.PositionAuthorityVersion; ulong spatialVersion = canonical.SpatialAuthorityVersion; return AcknowledgeProjectionAndPublish( @@ -1251,6 +1417,108 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable private void EnsureNotDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + internal bool TryGetInitialCreateResidence( + RuntimeEntityRecord canonical, + out RuntimeInitialCreateResidenceLease lease) + { + EnsureNotDisposed(); + return InitialCreateResidences.TryGetCurrent(canonical, out lease); + } + + internal RuntimeInitialCreateResidenceCompletionStatus + CompleteInitialCreateResidence( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + out RuntimeInitialCreateResidenceReceipt receipt) + { + EnsureNotDisposed(); + return InitialCreateResidences.Complete( + canonical, + token, + out receipt); + } + + internal bool AcknowledgeInitialCreateResidenceAdoption( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceAdoptionToken token) + { + EnsureNotDisposed(); + return InitialCreateResidences.AcknowledgeAdoption( + canonical, + token); + } + + private RuntimePlacementCancellationReceipt ForgetInitialCreateResidence( + RuntimeEntityRecord canonical) + { + return InitialCreateResidences.Forget( + canonical, + out _, + out RuntimePlacementCancellationReceipt cancellation) + ? cancellation + : default; + } + + private static RuntimePlacementCancellationReceipt PreferCancellation( + in RuntimePlacementCancellationReceipt initial, + in RuntimePlacementCancellationReceipt ordinary) => + initial.IsValid ? initial : ordinary; + + private bool InitializeAcceptedCreateResidence( + RuntimeEntityRecord canonical, + in InboundCreateResult accepted, + bool beginInitialResidence, + bool isLocalPlayer) + { + if (!beginInitialResidence) + return true; + + // The explicit cutover path separates accepted wire authority from + // committed residence before any observer can hydrate it. Failure to + // own the exact route is fail-closed; the caller removes the canonical + // record before publishing Registered/Updated. + if (canonical.PositionAuthorityVersion == 0UL) + Entities.AdvancePositionAuthority(canonical); + if (canonical.FullCellId != 0u) + Entities.SetFullCell(canonical, 0u, 0u); + RuntimeInitialCreateResidenceLease lease = + InitialCreateResidences.Begin( + canonical, + accepted, + isLocalPlayer); + return lease.IsValid; + } + + private Exception FailInitialResidenceRegistration( + RuntimeEntityRecord canonical, + bool publishDeleted) + { + if (!Entities.RemoveActive(canonical)) + { + return new InvalidOperationException( + $"Initial residence for 0x{canonical.ServerGuid:X8} failed after its canonical incarnation was superseded."); + } + + Exception? failure = null; + if (publishDeleted) + { + try + { + PublishEntity(RuntimeEntityChange.Deleted, canonical); + } + catch (Exception error) + { + failure = error; + } + } + failure = Combine(failure, RetireCanonicalOnly(canonical)); + var cause = new InvalidOperationException( + $"Initial residence for 0x{canonical.ServerGuid:X8} could not acquire its exact Runtime placement lease."); + return failure is null + ? cause + : new AggregateException(cause, failure); + } + private static Exception? Combine( Exception? first, Exception? second) => diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs new file mode 100644 index 00000000..f35fa9f5 --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs @@ -0,0 +1,743 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Entities; + +internal readonly record struct RuntimeInitialCreateResidenceToken( + RuntimeEntityKey Entity, + ulong LeaseId, + ulong SessionLifetimeVersion, + ulong PositionAuthorityVersion, + ulong CreateIntegrationVersion, + ulong SourcePlacementCommitVersion) +{ + internal bool IsValid => Entity.LocalEntityId != 0u + && LeaseId != 0UL + && PositionAuthorityVersion != 0UL + && CreateIntegrationVersion != 0UL; +} + +/// +/// Exact, presentation-free initial CreateObject residence authority. The +/// accepted wire frame remains on the canonical record while FullCell stays +/// zero until the authored Runtime SetPosition operation commits. +/// +internal readonly record struct RuntimeInitialCreateResidenceLease( + RuntimeInitialCreateResidenceToken Token, + RuntimeAuthoritativePositionRoute Route, + RuntimeEntityPlacementToken Placement, + ImmutableArray Continuations) +{ + internal bool IsValid => Token.IsValid + && Route.Accepted + && Route.Authority.Entity == Token.Entity + && !Continuations.IsDefault + && HasValidContinuationChain() + && (!Route.PerformsSetPosition + || Placement.IsValid + && Placement.Entity == Token.Entity); + + private bool HasValidContinuationChain() + { + ushort previousTeleport = Route.Authority.AcceptedTeleportSequence; + for (int index = 0; index < Continuations.Length; index++) + { + RuntimeInitialCreateResidenceContinuation continuation = + Continuations[index]; + if (!continuation.IsValid + || continuation.Sequence != (ulong)index + 1UL + || continuation.InstanceSequence != Token.Entity.Incarnation + || continuation.PositionAuthorityVersion + != Token.PositionAuthorityVersion + || continuation.PreviousTeleportSequence + != previousTeleport) + { + return false; + } + previousTeleport = continuation.AcceptedTeleportSequence; + } + return true; + } +} + +/// +/// One accepted Position packet which arrived before the immutable initial +/// CreateObject admission completed. These records remain dormant until a +/// host adopts the completed batch; accepting them never replaces or mutates +/// the initial 0x11 SetPosition operation. +/// +internal readonly record struct RuntimeInitialCreateResidenceContinuation( + ulong Sequence, + RuntimePositionEntityKind EntityKind, + ushort InstanceSequence, + ushort PositionSequence, + ushort PreviousTeleportSequence, + ushort TeleportSequence, + ushort ForcePositionSequence, + ushort AcceptedTeleportSequence, + ushort AcceptedForcePositionSequence, + PositionTimestampDisposition TimestampDisposition, + CreateObject.ServerPosition AcceptedWirePosition, + Vector3? PositionPackVelocity, + Vector3? AcceptedVelocity, + Quaternion? ForcePositionRotation, + Vector3? CurrentLocalVelocity, + bool ProjectionRequiresTeleportHook, + uint PlacementFrame, + ulong PositionAuthorityVersion) +{ + internal bool IsValid => Sequence != 0UL + && EntityKind is RuntimePositionEntityKind.LocalPlayer + or RuntimePositionEntityKind.Remote + or RuntimePositionEntityKind.Projectile + && PositionAuthorityVersion != 0UL + && TimestampDisposition is PositionTimestampDisposition.Apply + or PositionTimestampDisposition.ForcePosition; +} + +internal readonly record struct RuntimeInitialCreateResidenceAdoptionToken( + RuntimeEntityKey Entity, + ulong LeaseId, + ulong AdoptionId, + ulong SessionLifetimeVersion, + ulong Revision) +{ + internal bool IsValid => Entity.LocalEntityId != 0u + && LeaseId != 0UL + && AdoptionId != 0UL + && Revision != 0UL; +} + +internal enum RuntimeInitialCreateResidenceCompletionStatus : byte +{ + Completed, + PendingPlacement, + RejectedToken, + RejectedAuthority, +} + +/// +/// Exact post-residence receipt. A local graphical or no-window host may run +/// the retail after-enter teleport suffix only when this receipt carries +/// . +/// +internal readonly record struct RuntimeInitialCreateResidenceReceipt( + RuntimeInitialCreateResidenceToken Token, + RuntimeTeleportHookPhase TeleportHookPhase, + RuntimePlacementProjectionToken Projection, + uint FullCellId, + ulong PlacementCommitVersion, + RuntimeInitialCreateResidenceAdoptionToken Adoption, + ImmutableArray Continuations); + +internal readonly record struct RuntimeInitialCreateResidenceOwnershipSnapshot( + int ActiveLeaseCount, + int PendingAdoptionCount, + ulong LastLeaseId) +{ + internal bool IsConverged => ActiveLeaseCount == 0 + && PendingAdoptionCount == 0; +} + +/// +/// Owns only initial CreateObject residence leases. DAT lookup, body creation, +/// and presentation stay outside this owner; their immutable preparation is +/// submitted through the lease's canonical Runtime SetPosition token. +/// +internal sealed class RuntimeInitialCreateResidenceState +{ + private sealed class Entry + { + internal required RuntimeEntityRecord Record { get; init; } + internal required RuntimeInitialCreateResidenceLease Lease { get; set; } + } + + private sealed class CompletedEntry + { + internal required RuntimeEntityRecord Record { get; init; } + internal required RuntimeInitialCreateResidenceLease Lease { get; set; } + internal required RuntimeInitialCreateResidenceReceipt Receipt { get; set; } + } + + private readonly RuntimeEntityDirectory _entities; + private readonly RuntimeSetPositionState _setPosition; + private readonly Dictionary _entries = []; + private readonly Dictionary _completed = []; + private Func? _generation; + private ulong _nextLeaseId; + + internal RuntimeInitialCreateResidenceState( + RuntimeEntityDirectory entities, + RuntimeSetPositionState setPosition) + { + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _setPosition = setPosition + ?? throw new ArgumentNullException(nameof(setPosition)); + } + + internal void BindGeneration(Func generation) + { + ArgumentNullException.ThrowIfNull(generation); + if (_generation is not null) + { + throw new InvalidOperationException( + "The initial Create residence generation source is already bound."); + } + _generation = generation; + } + + internal bool CanAcceptCreate(WorldSession.EntitySpawn incoming) + { + bool parented = (incoming.ParentGuid + ?? incoming.Physics?.Parent?.Guid) + is not null and not 0u; + bool topLevel = !parented + && incoming.Position is { LandblockId: not 0u }; + return CurrentGeneration().Value != 0UL + && _nextLeaseId != ulong.MaxValue + && (!topLevel + || _setPosition.CanBeginAuthoredPlacementSequence + && RuntimeAuthoritativePositionRouteClassifier + .IsValidCreateWirePosition( + incoming.Position!.Value)); + } + + internal RuntimeInitialCreateResidenceLease Begin( + RuntimeEntityRecord record, + in InboundCreateResult accepted, + bool isLocalPlayer) + { + ArgumentNullException.ThrowIfNull(record); + if (!_entities.IsCurrent(record) + || record.Key is not { } key + || record.FullCellId != 0u + || accepted.Snapshot.Guid != record.ServerGuid + || accepted.Snapshot.InstanceSequence != record.Incarnation) + { + return default; + } + + RuntimeGenerationToken generation = CurrentGeneration(); + RuntimePositionEntityKind entityKind = isLocalPlayer + ? RuntimePositionEntityKind.LocalPlayer + : (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0 + ? RuntimePositionEntityKind.Projectile + : RuntimePositionEntityKind.Remote; + WorldSession.EntitySpawn snapshot = accepted.Snapshot; + // PhysicsDesc parent ownership precedes its optional position frame in + // retail set_description. A parented Create is not a top-level world + // admission merely because ACE also supplied a position payload. + RuntimeCreateResidenceKind residence = + (snapshot.ParentGuid ?? snapshot.Physics?.Parent?.Guid) + is not null and not 0u + ? RuntimeCreateResidenceKind.Parented + : snapshot.Position is { LandblockId: not 0u } + ? RuntimeCreateResidenceKind.TopLevel + : RuntimeCreateResidenceKind.PickedUp; + var authority = new RuntimeAuthoritativePositionAuthority( + generation, + key, + record.PositionAuthorityVersion, + snapshot.PositionSequence, + accepted.Timestamps.Teleport, + accepted.Timestamps.Teleport, + PositionTimestampDisposition.Apply); + RuntimeAuthoritativePositionRoute route = + RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate( + new RuntimeCreatePositionRouteRequest( + authority, + entityKind, + residence, + snapshot.Position, + new RuntimePositionPlacementFacts( + record.FinalPhysicsState, + HasAuthoredMoverShape: snapshot.SetupTableId is not null))); + return Own(record, route); + } + + internal RuntimeInitialCreateResidenceLease EnqueueAcceptedPosition( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceLease prior, + WorldSession.EntityPositionUpdate update, + in WorldSession.EntitySpawn accepted, + PositionTimestampDisposition disposition, + in AcceptedPhysicsTimestamps timestamps, + bool isLocalPlayer, + Quaternion? forcePositionRotation, + Vector3? currentLocalVelocity, + bool projectionRequiresTeleportHook) + { + ArgumentNullException.ThrowIfNull(record); + if (!_entities.IsCurrent(record) + || record.Key is not { } key + || update.Guid != record.ServerGuid + || accepted.Guid != record.ServerGuid + || disposition is PositionTimestampDisposition.Rejected) + { + return default; + } + + RuntimeInitialCreateResidenceLease current; + Entry? active = null; + CompletedEntry? completed = null; + if (_entries.TryGetValue(key, out active) + && ReferenceEquals(active.Record, record) + && active.Lease.Token == prior.Token + && IsCurrent(active)) + { + current = active.Lease; + } + else if (_completed.TryGetValue(key, out completed) + && ReferenceEquals(completed.Record, record) + && completed.Lease.Token == prior.Token + && IsCompletedCurrent(completed)) + { + current = completed.Lease; + } + else + { + return default; + } + if (current.Continuations.Length == int.MaxValue + || completed is not null + && completed.Receipt.Adoption.Revision == ulong.MaxValue) + return default; + + RuntimePositionEntityKind entityKind = isLocalPlayer + ? RuntimePositionEntityKind.LocalPlayer + : (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0 + ? RuntimePositionEntityKind.Projectile + : RuntimePositionEntityKind.Remote; + CreateObject.ServerPosition acceptedPosition = + accepted.Position ?? update.Position; + ushort previousTeleport = current.Continuations.IsEmpty + ? current.Route.Authority.AcceptedTeleportSequence + : current.Continuations[^1].AcceptedTeleportSequence; + ulong sequence = (ulong)current.Continuations.Length + 1UL; + var continuation = new RuntimeInitialCreateResidenceContinuation( + sequence, + entityKind, + update.InstanceSequence, + update.PositionSequence, + previousTeleport, + update.TeleportSequence, + update.ForcePositionSequence, + timestamps.Teleport, + timestamps.ForcePosition, + disposition, + acceptedPosition, + update.Velocity, + accepted.Physics?.Velocity, + forcePositionRotation, + currentLocalVelocity, + projectionRequiresTeleportHook, + accepted.PlacementId ?? 0u, + record.PositionAuthorityVersion); + if (!continuation.IsValid) + return default; + + RuntimeInitialCreateResidenceLease revised = current with + { + Continuations = current.Continuations.Add(continuation), + }; + if (active is not null) + { + active.Lease = revised; + } + else + { + completed!.Lease = revised; + RuntimeInitialCreateResidenceAdoptionToken revisedAdoption = + completed.Receipt.Adoption with + { + Revision = completed.Receipt.Adoption.Revision + 1UL, + }; + completed.Receipt = completed.Receipt with + { + Adoption = revisedAdoption, + Continuations = revised.Continuations, + }; + } + return revised; + } + + internal bool CanEnqueueAcceptedPosition( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceLease prior, + in WorldSession.EntityPositionUpdate update) + { + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key + || update.Guid != record.ServerGuid + || !RuntimeAuthoritativePositionRouteClassifier + .IsValidCreateWirePosition(update.Position)) + { + return false; + } + if (_entries.TryGetValue(key, out Entry? entry)) + { + return ReferenceEquals(entry.Record, record) + && entry.Lease.Token == prior.Token + && IsCurrent(entry) + && entry.Lease.Continuations.Length < int.MaxValue; + } + return _completed.TryGetValue(key, out CompletedEntry? completed) + && ReferenceEquals(completed.Record, record) + && completed.Lease.Token == prior.Token + && IsCompletedCurrent(completed) + && completed.Lease.Continuations.Length < int.MaxValue + && completed.Receipt.Adoption.Revision < ulong.MaxValue; + } + + internal bool TryGetTransaction( + RuntimeEntityRecord record, + out RuntimeInitialCreateResidenceLease lease) + { + if (TryGetCurrent(record, out lease)) + return true; + if (record.Key is { } key + && _completed.TryGetValue(key, out CompletedEntry? completed) + && ReferenceEquals(completed.Record, record)) + { + if (IsCompletedCurrent(completed)) + { + lease = completed.Lease; + return true; + } + Retire(completed); + } + lease = default; + return false; + } + + private RuntimeInitialCreateResidenceLease Own( + RuntimeEntityRecord record, + in RuntimeAuthoritativePositionRoute route) + { + RuntimeEntityKey key = record.Key!.Value; + if (_entries.ContainsKey(key) + || _nextLeaseId == ulong.MaxValue) + { + return default; + } + ulong leaseId = _nextLeaseId + 1UL; + + RuntimeEntityPlacementToken placement = default; + if (route.PerformsSetPosition) + { + placement = _setPosition.TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + route.OperationKind); + if (!placement.IsValid) + return default; + if (!_setPosition.WatchPlacementCompletion(placement)) + { + _ = _setPosition.ForgetExactPlacement(placement); + return default; + } + } + else if (!route.Accepted) + { + return default; + } + + var token = new RuntimeInitialCreateResidenceToken( + key, + leaseId, + _entities.SessionLifetimeVersion, + record.PositionAuthorityVersion, + record.CreateIntegrationVersion, + record.PlacementCommitVersion); + var lease = new RuntimeInitialCreateResidenceLease( + token, + route, + placement, + ImmutableArray.Empty); + _entries.Add(key, new Entry + { + Record = record, + Lease = lease, + }); + _nextLeaseId = leaseId; + return lease; + } + + internal bool TryGetCurrent( + RuntimeEntityRecord record, + out RuntimeInitialCreateResidenceLease lease) + { + ArgumentNullException.ThrowIfNull(record); + if (record.Key is { } key + && _entries.TryGetValue(key, out Entry? entry) + && ReferenceEquals(entry.Record, record)) + { + if (IsCurrent(entry)) + { + lease = entry.Lease; + return true; + } + Retire(entry); + } + lease = default; + return false; + } + + internal RuntimeInitialCreateResidenceCompletionStatus Complete( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken token, + out RuntimeInitialCreateResidenceReceipt receipt) + { + ArgumentNullException.ThrowIfNull(record); + receipt = default; + if (token.IsValid + && _completed.TryGetValue(token.Entity, out CompletedEntry? completed) + && completed.Receipt.Token == token + && ReferenceEquals(completed.Record, record)) + { + if (IsCompletedCurrent(completed)) + { + receipt = completed.Receipt; + return RuntimeInitialCreateResidenceCompletionStatus.Completed; + } + Retire(completed); + return RuntimeInitialCreateResidenceCompletionStatus + .RejectedAuthority; + } + if (!token.IsValid + || !_entries.TryGetValue(token.Entity, out Entry? entry) + || entry.Lease.Token != token + || !ReferenceEquals(entry.Record, record)) + { + return RuntimeInitialCreateResidenceCompletionStatus.RejectedToken; + } + if (!IsCurrent(entry)) + { + Retire(entry); + return RuntimeInitialCreateResidenceCompletionStatus + .RejectedAuthority; + } + + RuntimeInitialCreateResidenceLease lease = entry.Lease; + RuntimePlacementProjectionToken projection = default; + if (lease.Route.PerformsSetPosition) + { + if (_setPosition.IsPlacementCurrent(lease.Placement)) + { + return RuntimeInitialCreateResidenceCompletionStatus + .PendingPlacement; + } + if (!_setPosition.TryPeekAcknowledgedPlacement( + lease.Placement, + out projection) + || projection.Entity != token.Entity + || projection.PositionAuthorityVersion + != token.PositionAuthorityVersion + || projection.SessionLifetimeVersion + != token.SessionLifetimeVersion + || projection.ExactCellId == 0u + || projection.ExactCellId != record.FullCellId + || projection.PlacementCommitVersion + <= token.SourcePlacementCommitVersion + || projection.PlacementCommitVersion + != record.PlacementCommitVersion) + { + Retire(entry); + return RuntimeInitialCreateResidenceCompletionStatus + .RejectedAuthority; + } + } + else if (record.FullCellId != 0u) + { + Retire(entry); + return RuntimeInitialCreateResidenceCompletionStatus + .RejectedAuthority; + } + + var adoption = new RuntimeInitialCreateResidenceAdoptionToken( + token.Entity, + token.LeaseId, + token.LeaseId, + token.SessionLifetimeVersion, + Revision: 1UL); + receipt = new RuntimeInitialCreateResidenceReceipt( + token, + lease.Route.TeleportHookPhase, + projection, + record.FullCellId, + record.PlacementCommitVersion, + adoption, + lease.Continuations); + _entries.Remove(token.Entity); + _completed.Add(token.Entity, new CompletedEntry + { + Record = record, + Lease = lease, + Receipt = receipt, + }); + return RuntimeInitialCreateResidenceCompletionStatus.Completed; + } + + internal bool AcknowledgeAdoption( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceAdoptionToken token) + { + ArgumentNullException.ThrowIfNull(record); + if (!token.IsValid) + return false; + if (!_completed.TryGetValue(token.Entity, out CompletedEntry? current) + || !ReferenceEquals(current.Record, record) + || current.Receipt.Adoption != token) + { + return false; + } + if (!IsCompletedCurrent(current)) + { + Retire(current); + return false; + } + if (current.Lease.Route.PerformsSetPosition + && !_setPosition.ConsumeAcknowledgedPlacement( + current.Lease.Placement, + current.Receipt.Projection)) + { + return false; + } + return _completed.Remove(token.Entity); + } + + internal bool Forget( + RuntimeEntityRecord record, + out RuntimeInitialCreateResidenceLease lease, + out RuntimePlacementCancellationReceipt cancellation) + { + ArgumentNullException.ThrowIfNull(record); + cancellation = default; + if (record.Key is { } key + && _entries.TryGetValue(key, out Entry? entry) + && ReferenceEquals(entry.Record, record) + && _entries.Remove(key)) + { + lease = entry.Lease; + cancellation = _setPosition.ForgetExactPlacement( + lease.Placement); + return true; + } + if (record.Key is { } completedKey + && _completed.TryGetValue( + completedKey, + out CompletedEntry? completed) + && ReferenceEquals(completed.Record, record) + && _completed.Remove(completedKey)) + { + lease = completed.Lease; + cancellation = _setPosition.ForgetExactPlacement( + lease.Placement); + return true; + } + lease = default; + return false; + } + + internal void Clear() + { + Entry[] active = _entries.Values.ToArray(); + CompletedEntry[] completed = _completed.Values.ToArray(); + var cancellations = new RuntimePlacementCancellationReceipt[ + active.Length + completed.Length]; + _entries.Clear(); + _completed.Clear(); + int cancellationCount = 0; + foreach (Entry entry in active) + { + RuntimePlacementCancellationReceipt cancellation = + _setPosition.ForgetExactPlacement( + entry.Lease.Placement); + if (cancellation.IsValid) + cancellations[cancellationCount++] = cancellation; + } + foreach (CompletedEntry entry in completed) + { + RuntimePlacementCancellationReceipt cancellation = + _setPosition.ForgetExactPlacement( + entry.Lease.Placement); + if (cancellation.IsValid) + cancellations[cancellationCount++] = cancellation; + } + for (int index = 0; index < cancellationCount; index++) + { + _setPosition.PublishCancellation(cancellations[index]); + } + } + + internal RuntimeInitialCreateResidenceOwnershipSnapshot CaptureOwnership() => + new(_entries.Count, _completed.Count, _nextLeaseId); + + private bool IsCurrent(Entry entry) + { + RuntimeInitialCreateResidenceToken token = entry.Lease.Token; + bool placementCurrent = !entry.Lease.Route.PerformsSetPosition + || _setPosition.IsPlacementCompletionTracked( + entry.Lease.Placement); + return placementCurrent + && _entities.IsCurrent(entry.Record) + && entry.Record.Key == token.Entity + && _entities.SessionLifetimeVersion + == token.SessionLifetimeVersion + && entry.Record.PositionAuthorityVersion + == token.PositionAuthorityVersion + && entry.Record.CreateIntegrationVersion + == token.CreateIntegrationVersion + && entry.Lease.Route.Authority.Generation + == CurrentGeneration(); + } + + private RuntimeGenerationToken CurrentGeneration() + { + return _generation?.Invoke() ?? default; + } + + private bool IsCompletedCurrent(CompletedEntry entry) + { + RuntimeInitialCreateResidenceReceipt receipt = entry.Receipt; + return _entities.IsCurrent(entry.Record) + && entry.Record.Key == receipt.Token.Entity + && _entities.SessionLifetimeVersion + == receipt.Token.SessionLifetimeVersion + && entry.Record.PositionAuthorityVersion + == receipt.Token.PositionAuthorityVersion + && entry.Record.CreateIntegrationVersion + == receipt.Token.CreateIntegrationVersion + && entry.Record.FullCellId == receipt.FullCellId + && entry.Record.PlacementCommitVersion + == receipt.PlacementCommitVersion + && entry.Lease.Route.Authority.Generation + == CurrentGeneration() + && receipt.Token.SessionLifetimeVersion + == receipt.Adoption.SessionLifetimeVersion + && receipt.Token.LeaseId == receipt.Adoption.LeaseId + && (!entry.Lease.Route.PerformsSetPosition + || _setPosition.IsPlacementCompletionTracked( + entry.Lease.Placement)); + } + + private void Retire(Entry entry) + { + _entries.Remove(entry.Lease.Token.Entity); + RuntimePlacementCancellationReceipt cancellation = + _setPosition.ForgetExactPlacement(entry.Lease.Placement); + _setPosition.PublishCancellation(cancellation); + } + + private void Retire(CompletedEntry entry) + { + _completed.Remove(entry.Receipt.Token.Entity); + RuntimePlacementCancellationReceipt cancellation = + _setPosition.ForgetExactPlacement(entry.Lease.Placement); + _setPosition.PublishCancellation(cancellation); + } +} diff --git a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs index ab849c48..28c70f8e 100644 --- a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs +++ b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs @@ -180,6 +180,9 @@ internal static class RuntimeAuthoritativePositionRouteClassifier | PhysicsSetPositionFlags.Slide | PhysicsSetPositionFlags.SendPositionEvent; + internal static bool IsValidCreateWirePosition( + in CreateObject.ServerPosition position) => ValidPosition(position); + internal static RuntimeAuthoritativePositionRoute ClassifyCreate( in RuntimeCreatePositionRouteRequest request) { @@ -205,7 +208,10 @@ internal static class RuntimeAuthoritativePositionRouteClassifier 0u, UnparentBeforeRouting: false, ApplyPlacementFrameBeforeRouting: false, - LeaveWorld: true, + // A fresh cellless CreateObject has never entered world. + // Parent composition and later pickup events own their own + // callbacks; initial residence invents no withdrawal edge. + LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.None, StopInterpolating: false, ConstrainAfterRouting: false, @@ -294,7 +300,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier { return new RuntimeAuthoritativePositionRoute( request.Authority, - RuntimeAuthoritativePositionDisposition.SetPosition, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, operation, AuthoritativeTeleportFlags, placement, diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 86aae1d4..4f6dc5bb 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -250,6 +250,8 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot( int UnboundDeferredCellOrderCount, int PreparedMoverCount, int MoverPreparationAuthorityCount, + int PlacementCompletionWatchCount, + int AcknowledgedPlacementCompletionCount, int CollisionPrefixQuiescenceCount, int PendingQuiescenceProjectionCount) { @@ -276,6 +278,8 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot( && UnboundDeferredCellOrderCount == 0 && PreparedMoverCount == 0 && MoverPreparationAuthorityCount == 0 + && PlacementCompletionWatchCount == 0 + && AcknowledgedPlacementCompletionCount == 0 && CollisionPrefixQuiescenceCount == 0 && PendingQuiescenceProjectionCount == 0; } @@ -414,6 +418,10 @@ internal sealed class RuntimeSetPositionState : IDisposable _preparedMovers = []; private readonly Dictionary _moverPreparationAuthorities = []; + private readonly HashSet + _placementCompletionWatches = []; + private readonly Dictionary _acknowledgedPlacementCompletions = []; private readonly Dictionary _collisionPrefixQuiescence = []; private readonly LinkedList _expiredLostCells = []; @@ -477,11 +485,15 @@ internal sealed class RuntimeSetPositionState : IDisposable _unboundDeferredCellOrder.Count, _preparedMovers.Count, _moverPreparationAuthorities.Count, + _placementCompletionWatches.Count, + _acknowledgedPlacementCompletions.Count, _collisionPrefixQuiescence.Count, pendingQuiescenceProjections); } internal int PendingProjectionCount => _pendingProjection.Count; + internal bool CanBeginAuthoredPlacementSequence => + _nextOperationId != ulong.MaxValue; internal bool IsCollisionPrefixQuiescing(uint landblockId) => _collisionPrefixQuiescence.ContainsKey( @@ -812,6 +824,111 @@ internal sealed class RuntimeSetPositionState : IDisposable portal, captureMoverPreparationAuthority: true); + internal bool IsPlacementCurrent( + in RuntimeEntityPlacementToken token) + { + EnsureNotDisposed(); + return token.IsValid + && _operations.TryGetValue(token.Entity, out Operation? operation) + && operation.Token == token + && IsCurrent(operation); + } + + /// + /// Reserves the exact acknowledgement edge for a higher-level Runtime + /// transaction. Ordinary SetPosition operations retain no completion + /// history; only explicitly watched tokens survive operation retirement. + /// + internal bool WatchPlacementCompletion( + in RuntimeEntityPlacementToken token) + { + EnsureNotDisposed(); + return IsPlacementCurrent(token) + && _placementCompletionWatches.Add(token); + } + + internal bool IsPlacementCompletionTracked( + in RuntimeEntityPlacementToken token) + { + EnsureNotDisposed(); + return token.IsValid + && (IsPlacementCurrent(token) + && _placementCompletionWatches.Contains(token) + || _acknowledgedPlacementCompletions.ContainsKey(token)); + } + + internal bool TryPeekAcknowledgedPlacement( + in RuntimeEntityPlacementToken token, + out RuntimePlacementProjectionToken projection) + { + EnsureNotDisposed(); + if (token.IsValid + && _acknowledgedPlacementCompletions.TryGetValue( + token, + out projection)) + { + return true; + } + projection = default; + return false; + } + + internal bool ConsumeAcknowledgedPlacement( + in RuntimeEntityPlacementToken token, + in RuntimePlacementProjectionToken expected) + { + EnsureNotDisposed(); + return token.IsValid + && _acknowledgedPlacementCompletions.TryGetValue( + token, + out RuntimePlacementProjectionToken current) + && current == expected + && _acknowledgedPlacementCompletions.Remove(token); + } + + internal void ForgetPlacementCompletion( + in RuntimeEntityPlacementToken token) + { + EnsureNotDisposed(); + ForgetPlacementCompletionCore(token); + } + + internal RuntimePlacementCancellationReceipt ForgetExactPlacement( + in RuntimeEntityPlacementToken token) + { + EnsureNotDisposed(); + ForgetPlacementCompletionCore(token); + if (!token.IsValid + || !_operations.TryGetValue(token.Entity, out Operation? operation) + || operation.Token != token) + { + return default; + } + return CancelCore(operation); + } + + internal RuntimeEntityPlacementToken TryBeginExclusiveAuthoredPlacement( + RuntimeEntityRecord record, + ulong expectedPositionAuthorityVersion, + RuntimeSetPositionOperationKind kind, + RuntimePortalPlacementAuthority portal = default) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key + || _operations.ContainsKey(key) + || HasRetainedCompletion(key)) + { + return default; + } + return BeginAcceptedPlacementCore( + record, + expectedPositionAuthorityVersion, + kind, + portal, + captureMoverPreparationAuthority: true); + } + internal void PrepareDormantLocalActivationOwnership( RuntimeEntityRecord record, PhysicsBody body, @@ -853,6 +970,7 @@ internal sealed class RuntimeSetPositionState : IDisposable record.Snapshot.Physics?.Position ?? record.Snapshot.Position; if (record.Key is not { } key || !_entities.IsCurrent(record) + || HasRetainedCompletion(key) || record.PositionAuthorityVersion != expectedPositionAuthorityVersion || (captureMoverPreparationAuthority @@ -947,6 +1065,17 @@ internal sealed class RuntimeSetPositionState : IDisposable return IsCurrent(replacement) ? token : default; } + private bool HasRetainedCompletion(RuntimeEntityKey key) + { + foreach (RuntimeEntityPlacementToken token + in _acknowledgedPlacementCompletions.Keys) + { + if (token.Entity == key) + return true; + } + return false; + } + internal RuntimeSetPositionMoverPreparationStatus PrepareMover( in RuntimeEntityPlacementToken token, in RuntimeSetPositionMoverPreparation preparation, @@ -2206,6 +2335,12 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.ProjectionSequence = 0UL; if (pending.Kind is RuntimePlacementProjectionKind.Place) { + if (_placementCompletionWatches.Remove(operation.Token)) + { + _acknowledgedPlacementCompletions.Add( + operation.Token, + pending.Token); + } operation.Stage = RuntimeEntityPlacementStage.AwaitingCommitAcknowledgement; _moverPreparationAuthorities.Remove(operation.Key); return _operations.Remove(operation.Key); @@ -3041,6 +3176,8 @@ internal sealed class RuntimeSetPositionState : IDisposable _lostDeadlineNodeIndex.Clear(); _preparedMovers.Clear(); _moverPreparationAuthorities.Clear(); + _placementCompletionWatches.Clear(); + _acknowledgedPlacementCompletions.Clear(); _collisionPrefixQuiescence.Clear(); _pendingProjection.Clear(); _expiredLostCells.Clear(); @@ -3859,6 +3996,7 @@ internal sealed class RuntimeSetPositionState : IDisposable CancelExactLostKey(key); if (!_operations.Remove(key, out Operation? operation)) return false; + ForgetPlacementCompletionCore(operation.Token); _moverPreparationAuthorities.Remove(key); UnindexDeferred(operation); if (!preserveLostFamily && cancelLostFamily) @@ -3929,6 +4067,15 @@ internal sealed class RuntimeSetPositionState : IDisposable : default; } + private void ForgetPlacementCompletionCore( + in RuntimeEntityPlacementToken token) + { + if (!token.IsValid) + return; + _placementCompletionWatches.Remove(token); + _acknowledgedPlacementCompletions.Remove(token); + } + private void IndexDeferred(Operation operation) { if (operation.ExactCellId == 0u diff --git a/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs b/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs index 8941dcf3..e8562f3d 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs @@ -118,6 +118,35 @@ public class PhysicsTimestampGateTests Assert.True(gate.TryAcceptMovementEvent(4, 0x9011, 2)); } + [Fact] + public void PreviewCreateObject_MatchesSeedDispositionWithoutMutation() + { + var gate = new PhysicsTimestampGate(); + + Assert.Equal(CreateObjectTimestampDisposition.InitialGeneration, + gate.PreviewCreateObject(instance: 7)); + Assert.Equal(CreateObjectTimestampDisposition.InitialGeneration, + gate.SeedForCreateObject(10, 20, 30, 40, 50, 60, 70, 80, 7)); + + Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, + gate.PreviewCreateObject(instance: 7)); + Assert.Equal(CreateObjectTimestampDisposition.NewGeneration, + gate.PreviewCreateObject(instance: 8)); + Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, + gate.PreviewCreateObject(instance: 6)); + + // Preview is observational: all timestamp channels still compare + // against the original instance-seven seed afterward. + Assert.True(gate.TryAcceptMovementEvent( + instance: 7, + movement: 21, + serverControlledMove: 60)); + Assert.False(gate.TryAcceptMovementEvent( + instance: 8, + movement: 22, + serverControlledMove: 60)); + } + [Fact] public void SameGenerationCreateObject_MixedChannelsAreAcceptedIndependently() { diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs new file mode 100644 index 00000000..1ebebabb --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs @@ -0,0 +1,1792 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Entities; + +public sealed class RuntimeInitialCreateResidenceStateTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = Landblock | 0x0001u; + + [Theory] + [InlineData(true, false, + RuntimeSetPositionOperationKind.InitialLogin, + RuntimeTeleportHookPhase.AfterEnterWorld)] + [InlineData(false, false, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + RuntimeTeleportHookPhase.None)] + [InlineData(false, true, + RuntimeSetPositionOperationKind.ProjectileAuthoritative, + RuntimeTeleportHookPhase.None)] + internal void TopLevelRegistrationOwnsExactCelllessLeaseBeforeObservers( + bool isLocalPlayer, + bool missile, + RuntimeSetPositionOperationKind expectedOperation, + RuntimeTeleportHookPhase expectedHook) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 7UL); + RuntimeInitialCreateResidenceLease observed = default; + uint observedCell = uint.MaxValue; + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Registered) + return; + Assert.True(lifetime.Entities.TryGetByLocalId( + delta.Entity.Identity.LocalEntityId, + out RuntimeEntityRecord record)); + observedCell = record.FullCellId; + Assert.True(lifetime.TryGetInitialCreateResidence( + record, + out observed)); + })); + + RuntimeEntityRegistrationResult registration = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003001u, 1, missile: missile), + isLocalPlayer); + RuntimeEntityRecord canonical = registration.Canonical!; + + Assert.Equal(0u, observedCell); + Assert.Equal(0u, canonical.FullCellId); + Assert.Equal(Cell, canonical.Snapshot.Position!.Value.LandblockId); + Assert.True(observed.IsValid); + Assert.Equal(observed.Token.Entity, observed.Route.Authority.Entity); + Assert.Equal(expectedOperation, observed.Route.OperationKind); + Assert.Equal(expectedHook, observed.Route.TeleportHookPhase); + Assert.Equal(0x11u, (uint)observed.Route.SetPositionFlags); + Assert.True(observed.Placement.IsValid); + Assert.Equal(1, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void FreshParentedAndPickedCreateRemainCelllessWithoutWithdrawal( + bool parented) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 8UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + 0x70003002u, + 1, + includePosition: false, + parentGuid: parented ? 0x70004000u : null), + isLocalPlayer: false) + .Canonical!; + + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal(0u, canonical.FullCellId); + Assert.False(lease.Placement.IsValid); + Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, + lease.Route.Disposition); + Assert.False(lease.Route.LeaveWorld); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out RuntimeInitialCreateResidenceReceipt receipt)); + Assert.Equal(0u, receipt.FullCellId); + Assert.Equal(RuntimeTeleportHookPhase.None, + receipt.TeleportHookPhase); + Assert.True(lifetime.AcknowledgeInitialCreateResidenceAdoption( + canonical, + receipt.Adoption)); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + } + + [Fact] + public void MissingCellDefersExactLeaseWithoutCommittingWireResidence() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 9UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003003u, 1, setupId: null), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + RuntimeSetPositionCommand command = Prepare( + lifetime, + lease, + RuntimeSetPositionMoverSetup.ResolvedAbsent); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(lease.Placement, command); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, outcome.Status); + Assert.Equal(0u, canonical.FullCellId); + Assert.Equal(Cell, canonical.Snapshot.Position!.Value.LandblockId); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.PendingPlacement, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out _)); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease retained)); + Assert.Equal(lease, retained); + } + + [Theory] + [InlineData(true, RuntimeTeleportHookPhase.AfterEnterWorld)] + [InlineData(false, RuntimeTeleportHookPhase.None)] + internal void CommittedAndAcknowledgedCreateYieldsExactEnterWorldReceipt( + bool isLocalPlayer, + RuntimeTeleportHookPhase expectedHook) + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + Bind(lifetime, 10UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003004u, 1, setupId: null), + isLocalPlayer) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + RuntimeSetPositionCommand command = Prepare( + lifetime, + lease, + RuntimeSetPositionMoverSetup.ResolvedAbsent); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(lease.Placement, command); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.PendingPlacement, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out _)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out RuntimeInitialCreateResidenceReceipt receipt)); + Assert.Equal(expectedHook, receipt.TeleportHookPhase); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(canonical.PlacementCommitVersion, + receipt.PlacementCommitVersion); + Assert.True(receipt.Adoption.IsValid); + Assert.Empty(receipt.Continuations); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out RuntimeInitialCreateResidenceReceipt repeated)); + Assert.Equal(receipt, repeated); + Assert.False(lifetime.Physics.SetPosition + .TryBeginExclusiveAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative) + .IsValid); + Assert.True(lifetime.AcknowledgeInitialCreateResidenceAdoption( + canonical, + receipt.Adoption)); + RuntimeEntityPlacementToken afterAdoption = lifetime.Physics.SetPosition + .TryBeginExclusiveAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(afterAdoption.IsValid); + _ = lifetime.Physics.SetPosition.ForgetExactPlacement(afterAdoption); + Assert.False(lifetime.TryGetInitialCreateResidence( + canonical, + out _)); + } + + [Fact] + public void GenerationReplacementForgetsOldLeaseAndOwnsOnlySuccessor() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 11UL); + RuntimeEntityRecord first = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003005u, 1), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + first, + out RuntimeInitialCreateResidenceLease oldLease)); + + RuntimeEntityRecord second = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003005u, 2), + isLocalPlayer: false) + .Canonical!; + + Assert.False(lifetime.TryGetInitialCreateResidence(first, out _)); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.RejectedToken, + lifetime.CompleteInitialCreateResidence( + first, + oldLease.Token, + out _)); + Assert.True(lifetime.TryGetInitialCreateResidence( + second, + out RuntimeInitialCreateResidenceLease successor)); + Assert.NotEqual(oldLease.Token, successor.Token); + Assert.Equal(1, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + } + + [Fact] + public void RegistrationObserverReentryCannotResurrectOuterLease() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 12UL); + RuntimeEntityRegistrationResult nested = default; + bool reentered = false; + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => + { + if (reentered + || delta.Change is not RuntimeEntityChange.Registered) + return; + reentered = true; + nested = lifetime.RegisterEntityWithInitialResidence( + Spawn(0x70003006u, 2), + isLocalPlayer: false); + })); + + RuntimeEntityRegistrationResult outer = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003006u, 1), + isLocalPlayer: false); + + Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, + outer.Inbound.Disposition); + RuntimeEntityRecord current = nested.Canonical!; + Assert.True(lifetime.Entities.IsCurrent(current)); + Assert.Equal((ushort)2, current.Incarnation); + Assert.True(lifetime.TryGetInitialCreateResidence( + current, + out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal(current.Key, lease.Token.Entity); + Assert.Equal(1, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + } + + [Fact] + public void DeleteResetAndDisposeConvergeEveryInitialResidenceOwner() + { + var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 13UL); + RuntimeEntityRecord deleted = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003007u, 1), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(deleted.ServerGuid, deleted.Incarnation), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(deleted)); + + _ = lifetime.RegisterEntityWithInitialResidence( + Spawn(0x70003008u, 1), + isLocalPlayer: false); + IReadOnlyList retirements = + lifetime.BeginSessionClear(); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + + lifetime.Dispose(); + Assert.True(lifetime.CaptureOwnership().IsConverged); + } + + [Fact] + public void ResetCancelsCompletedUnadoptedContinuationBatch() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + Bind(lifetime, 32UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x7000301Eu, 1, setupId: null), + isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + lease.Placement, + Prepare( + lifetime, + lease, + RuntimeSetPositionMoverSetup.ResolvedAbsent)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out RuntimeInitialCreateResidenceReceipt receipt)); + Assert.Equal(1, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + + IReadOnlyList retirements = + lifetime.BeginSessionClear(); + + Assert.False(lifetime.AcknowledgeInitialCreateResidenceAdoption( + canonical, + receipt.Adoption)); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + RuntimeSetPositionOwnershipSnapshot ownership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(0, ownership.ActiveOperationCount); + Assert.Equal(0, ownership.PlacementCompletionWatchCount); + Assert.Equal(0, ownership.AcknowledgedPlacementCompletionCount); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + [Fact] + public void PostCompletionRebucketRejectsTopLevelAdoptionAndConverges() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + Bind(lifetime, 33UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x7000301Fu, 1, setupId: null), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + lease.Placement, + Prepare( + lifetime, + lease, + RuntimeSetPositionMoverSetup.ResolvedAbsent)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out RuntimeInitialCreateResidenceReceipt receipt)); + + lifetime.Entities.SetFullCell( + canonical, + Landblock | 0x0002u, + Landblock); + + Assert.False(lifetime.AcknowledgeInitialCreateResidenceAdoption( + canonical, + receipt.Adoption)); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + RuntimeSetPositionOwnershipSnapshot ownership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(0, ownership.ActiveOperationCount); + Assert.Equal(0, ownership.PlacementCompletionWatchCount); + Assert.Equal(0, ownership.AcknowledgedPlacementCompletionCount); + } + + [Fact] + public void PostCompletionAuthorityChangeRejectsCelllessAdoption() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 34UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + 0x70003020u, + 1, + includePosition: false, + parentGuid: 0x70004020u), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out RuntimeInitialCreateResidenceReceipt receipt)); + + lifetime.Entities.AdvancePositionAuthority(canonical); + + Assert.False(lifetime.AcknowledgeInitialCreateResidenceAdoption( + canonical, + receipt.Adoption)); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.True(lifetime.Physics.SetPosition.CaptureOwnership() + .IsConverged); + } + + [Fact] + public void CelllessPendingAdoptionRevisionsForFreshPositionWithoutLeak() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 36UL); + const uint guid = 0x70003024u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + guid, + 1, + includePosition: false, + parentGuid: 0x70004024u), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + ulong positionAuthority = canonical.PositionAuthorityVersion; + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out RuntimeInitialCreateResidenceReceipt original)); + int callbacks = 0; + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, + positionSequence: 2, + teleportSequence: 1, + forcePositionSequence: 0, + positionX: 25f); + + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: _ => callbacks++, + out PositionTimestampDisposition disposition, + out _, + out _)); + + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.Equal(0, callbacks); + Assert.Equal(positionAuthority, canonical.PositionAuthorityVersion); + Assert.Null(canonical.Snapshot.Position); + Assert.Equal(0u, canonical.FullCellId); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out RuntimeInitialCreateResidenceReceipt revised)); + Assert.Equal(original.Adoption.Revision + 1UL, + revised.Adoption.Revision); + RuntimeInitialCreateResidenceContinuation continuation = + Assert.Single(revised.Continuations); + Assert.Equal(25f, continuation.AcceptedWirePosition.PositionX); + Assert.Equal((ushort)1, continuation.AcceptedTeleportSequence); + Assert.False(lifetime.AcknowledgeInitialCreateResidenceAdoption( + canonical, + original.Adoption)); + Assert.True(lifetime.AcknowledgeInitialCreateResidenceAdoption( + canonical, + revised.Adoption)); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.True(lifetime.Physics.SetPosition.CaptureOwnership() + .IsConverged); + } + + [Fact] + public void ResetSnapshotsAllResidenceOwnersBeforeReentrantDiscardObserver() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + Bind(lifetime, 35UL); + RuntimeEntityRecord completedRecord = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + 0x70003023u, + 1, + setupId: null, + positionX: 70f), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + completedRecord, + out RuntimeInitialCreateResidenceLease completedLease)); + AttachDormantBody(lifetime, completedRecord); + RuntimeSetPositionOutcome completedOutcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + completedLease.Placement, + Prepare( + lifetime, + completedLease, + RuntimeSetPositionMoverSetup.ResolvedAbsent)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + completedOutcome.Projection)); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + completedRecord, + completedLease.Token, + out _)); + + var pending = new List(); + uint[] pendingGuids = [0x70003021u, 0x70003022u]; + for (int index = 0; index < pendingGuids.Length; index++) + { + uint guid = pendingGuids[index]; + RuntimeEntityRecord record = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + guid, + 1, + setupId: null, + positionX: 10f + index * 30f), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + record, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, record); + pending.Add(lifetime.Physics.SetPosition.SubmitPreparedPlacement( + lease.Placement, + Prepare( + lifetime, + lease, + RuntimeSetPositionMoverSetup.ResolvedAbsent))); + } + + var discards = new List(); + int nestedRetirementCount = -1; + using IDisposable subscription = lifetime.Events.SubscribePlacement( + new PlacementObserver(delta => + { + if (delta.Placement.Kind + is not RuntimePlacementProjectionKind.Discard) + { + return; + } + discards.Add(delta.Placement.Token.Sequence); + if (nestedRetirementCount < 0) + { + nestedRetirementCount = + lifetime.BeginSessionClear().Count; + } + })); + + IReadOnlyList retirements = + lifetime.BeginSessionClear(); + + Assert.Equal(0, nestedRetirementCount); + Assert.Equal( + pending.Select(static item => item.Projection.Sequence) + .Order(), + discards.Order()); + Assert.Equal(0, lifetime.Events.DispatchFailureCount); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + [Fact] + public void InvalidGenerationFailsBeforeInboundOrCanonicalAcceptance() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + ulong generation = 0UL; + lifetime.BindEventContext( + () => new RuntimeGenerationToken(generation), + static () => 1UL); + WorldSession.EntitySpawn spawn = Spawn(0x70003009u, 1); + + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence(spawn, isLocalPlayer: false)); + Assert.Equal(0, lifetime.Entities.Count); + Assert.Equal(0, lifetime.Entities.ClaimedLocalIdCount); + Assert.Empty(lifetime.Entities.Snapshots); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.True(lifetime.Physics.SetPosition.CaptureOwnership() + .IsConverged); + + generation = 14UL; + RuntimeEntityRegistrationResult recovered = lifetime + .RegisterEntityWithInitialResidence(spawn, isLocalPlayer: false); + Assert.Equal(CreateObjectTimestampDisposition.InitialGeneration, + recovered.Inbound.Disposition); + Assert.NotNull(recovered.Canonical); + } + + [Fact] + public void UnboundGenerationFailsWithoutFabricatingSessionAuthority() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003014u, 1), + isLocalPlayer: false)); + + Assert.Empty(lifetime.Entities.Snapshots); + Assert.Equal(0, lifetime.Entities.Count); + Assert.Equal(0, lifetime.Entities.ClaimedLocalIdCount); + } + + [Theory] + [InlineData((ushort)1)] + [InlineData((ushort)2)] + public void EqualOrStaleMalformedCreateDoesNotDisplaceCurrentAdmission( + ushort currentIncarnation) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 24UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003015u, currentIncarnation), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease initial)); + ushort incomingIncarnation = 1; + + RuntimeEntityRegistrationResult result = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + 0x70003015u, + incomingIncarnation, + positionX: float.NaN), + isLocalPlayer: false); + + Assert.Equal( + currentIncarnation == incomingIncarnation + ? CreateObjectTimestampDisposition.ExistingGeneration + : CreateObjectTimestampDisposition.StaleGeneration, + result.Inbound.Disposition); + Assert.True(lifetime.Entities.IsCurrent(canonical)); + Assert.Equal(10f, canonical.Snapshot.Position!.Value.PositionX); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease retained)); + Assert.Equal(initial, retained); + } + + [Fact] + public void MalformedInitialPositionDoesNotPoisonSameInstanceRecovery() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 15UL); + WorldSession.EntitySpawn malformed = Spawn( + 0x7000300Au, + 1, + positionX: float.NaN); + + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence( + malformed, + isLocalPlayer: false)); + Assert.Empty(lifetime.Entities.Snapshots); + Assert.Equal(0, lifetime.Entities.Count); + + WorldSession.EntitySpawn corrected = Spawn(0x7000300Au, 1); + RuntimeEntityRegistrationResult recovered = lifetime + .RegisterEntityWithInitialResidence( + corrected, + isLocalPlayer: false); + Assert.Equal(CreateObjectTimestampDisposition.InitialGeneration, + recovered.Inbound.Disposition); + Assert.Equal(corrected.Position, + recovered.Canonical!.Snapshot.Position); + Assert.Equal(0u, recovered.Canonical.FullCellId); + } + + [Fact] + public void ExistingPlacementCollisionCannotStealOrPartiallyOwnLease() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 16UL); + RuntimeEntityRegistrationResult registration = lifetime.RegisterEntity( + Spawn(0x7000300Bu, 1)); + RuntimeEntityRecord canonical = registration.Canonical!; + lifetime.Entities.SetFullCell(canonical, 0u, 0u); + lifetime.Entities.AdvancePositionAuthority(canonical); + RuntimeEntityPlacementToken conflict = lifetime.Physics.SetPosition + .BeginAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(conflict.IsValid); + + RuntimeInitialCreateResidenceLease rejected = + lifetime.InitialCreateResidences.Begin( + canonical, + registration.Inbound, + isLocalPlayer: false); + + Assert.False(rejected.IsValid); + Assert.True(lifetime.Physics.SetPosition.IsPlacementCurrent(conflict)); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .PlacementCompletionWatchCount); + } + + [Fact] + public void ParentPrecedesCombinedWirePositionAndCreatesNoWorldPlacement() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 17UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + 0x7000300Cu, + 1, + includePosition: true, + parentGuid: 0x70004001u), + isLocalPlayer: false) + .Canonical!; + + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, + lease.Route.Disposition); + Assert.False(lease.Placement.IsValid); + Assert.Equal(0u, canonical.FullCellId); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AbsentAndPresentZeroCellCreateAwaitFreshPosition( + bool presentZeroCell) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 25UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + presentZeroCell ? 0x70003016u : 0x70003017u, + 1, + includePosition: presentZeroCell, + positionCell: 0u), + isLocalPlayer: false) + .Canonical!; + + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, + lease.Route.Disposition); + Assert.False(lease.Placement.IsValid); + Assert.Equal(0u, canonical.FullCellId); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + } + + [Fact] + public void EqualAndNewerSameGenerationCreateDoNotChurnPendingPlacement() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 18UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x7000300Du, 1), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease initial)); + ulong positionAuthority = canonical.PositionAuthorityVersion; + ulong spatialAuthority = canonical.SpatialAuthorityVersion; + + RuntimeEntityRegistrationResult duplicate = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x7000300Du, 1), + isLocalPlayer: false); + Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, + duplicate.Inbound.Disposition); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease afterDuplicate)); + Assert.Equal(initial, afterDuplicate); + Assert.Equal(positionAuthority, canonical.PositionAuthorityVersion); + Assert.Equal(spatialAuthority, canonical.SpatialAuthorityVersion); + + RuntimeEntityRegistrationResult newer = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + 0x7000300Du, + 1, + positionSequence: 2, + positionX: 30f), + isLocalPlayer: false); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease beforeFreshRoute)); + Assert.Equal(initial, beforeFreshRoute); + Assert.Equal(10f, canonical.Snapshot.Position!.Value.PositionX); + Assert.NotNull(newer.Inbound.SameGenerationEvents); + + WorldSession.EntityPositionUpdate update = + newer.Inbound.SameGenerationEvents!.Value.Position!.Value; + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.Equal(0u, canonical.FullCellId); + Assert.Equal(10f, canonical.Snapshot.Position!.Value.PositionX); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease successor)); + Assert.Equal(initial.Token, successor.Token); + Assert.Equal(RuntimeSetPositionOperationKind.RemoteAuthoritative, + successor.Route.OperationKind); + RuntimeInitialCreateResidenceContinuation continuation = + Assert.Single(successor.Continuations); + Assert.Equal(30f, continuation.AcceptedWirePosition.PositionX); + Assert.Equal((ushort)2, continuation.PositionSequence); + Assert.Equal(PositionTimestampDisposition.Apply, + continuation.TimestampDisposition); + Assert.True(successor.Placement.IsValid); + Assert.Equal(1, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + } + + [Fact] + public void AlreadyPlacedSameGenerationCreateCannotReclaimWireCell() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + Bind(lifetime, 19UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x7000300Eu, 1, setupId: null), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + lease.Placement, + Prepare( + lifetime, + lease, + RuntimeSetPositionMoverSetup.ResolvedAbsent)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out _)); + ulong spatialAuthority = canonical.SpatialAuthorityVersion; + + _ = lifetime.RegisterEntityWithInitialResidence( + Spawn(0x7000300Eu, 1, setupId: null), + isLocalPlayer: false); + + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal(spatialAuthority, canonical.SpatialAuthorityVersion); + Assert.False(lifetime.TryGetInitialCreateResidence(canonical, out _)); + } + + [Fact] + public void LocalNoTeleportFreshPositionRemainsInitialWorldAdmission() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 22UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003012u, 1), + isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease initial)); + RuntimeEntityRegistrationResult newer = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + 0x70003012u, + 1, + positionSequence: 2, + positionX: 40f), + isLocalPlayer: true); + WorldSession.EntityPositionUpdate update = + newer.Inbound.SameGenerationEvents!.Value.Position!.Value; + + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer: true, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out AcceptedPhysicsTimestamps timestamps)); + + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.False(timestamps.TeleportAdvanced); + Assert.Equal(0u, canonical.FullCellId); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease successor)); + Assert.Equal(initial.Token, successor.Token); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPosition, + successor.Route.Disposition); + Assert.Equal(RuntimeSetPositionOperationKind.InitialLogin, + successor.Route.OperationKind); + Assert.Equal(RuntimeTeleportHookPhase.AfterEnterWorld, + successor.Route.TeleportHookPhase); + Assert.True(successor.Placement.IsValid); + RuntimeInitialCreateResidenceContinuation continuation = + Assert.Single(successor.Continuations); + Assert.Equal(PositionTimestampDisposition.Apply, + continuation.TimestampDisposition); + Assert.Equal((ushort)0, continuation.PreviousTeleportSequence); + Assert.Equal((ushort)0, continuation.TeleportSequence); + } + + [Fact] + public void LocalForcePositionSuffixIsQueuedBehindInitialAdmission() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 26UL); + RuntimeInitialCreateResidenceLease successor = ApplyFreshSuccessor( + lifetime, + guid: 0x70003018u, + isLocalPlayer: true, + teleportSequence: 0, + forcePositionSequence: 1, + out PositionTimestampDisposition disposition); + + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + Assert.Equal(RuntimeSetPositionOperationKind.InitialLogin, + successor.Route.OperationKind); + RuntimeInitialCreateResidenceContinuation continuation = + Assert.Single(successor.Continuations); + Assert.Equal(RuntimePositionEntityKind.LocalPlayer, + continuation.EntityKind); + Assert.Equal((ushort)1, continuation.ForcePositionSequence); + Assert.Equal((ushort)0, continuation.PreviousTeleportSequence); + Assert.Equal((ushort)0, continuation.TeleportSequence); + Assert.NotNull(continuation.ForcePositionRotation); + } + + [Fact] + public void LocalTeleportSuffixIsQueuedBehindInitialAdmission() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 27UL); + RuntimeInitialCreateResidenceLease successor = ApplyFreshSuccessor( + lifetime, + guid: 0x70003019u, + isLocalPlayer: true, + teleportSequence: 1, + forcePositionSequence: 0, + out PositionTimestampDisposition disposition); + + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + RuntimeInitialCreateResidenceContinuation continuation = + Assert.Single(successor.Continuations); + Assert.Equal(RuntimePositionEntityKind.LocalPlayer, + continuation.EntityKind); + Assert.Equal((ushort)0, continuation.PreviousTeleportSequence); + Assert.Equal((ushort)1, continuation.TeleportSequence); + Assert.Equal(Vector3.Zero, continuation.AcceptedVelocity); + } + + [Fact] + public void RemoteTeleportSuffixIsQueuedBehindInitialAdmission() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 28UL); + RuntimeInitialCreateResidenceLease successor = ApplyFreshSuccessor( + lifetime, + guid: 0x7000301Au, + isLocalPlayer: false, + teleportSequence: 1, + forcePositionSequence: 0, + out PositionTimestampDisposition disposition); + + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + RuntimeInitialCreateResidenceContinuation continuation = + Assert.Single(successor.Continuations); + Assert.Equal(RuntimePositionEntityKind.Remote, + continuation.EntityKind); + Assert.Equal((ushort)0, continuation.PreviousTeleportSequence); + Assert.Equal((ushort)1, continuation.TeleportSequence); + } + + [Fact] + public void AcceptedPositionsRemainMonotonicRawFifoWithoutMutatingInitialAdmission() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 29UL); + const uint guid = 0x7000301Bu; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1), + isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease initial)); + ulong positionAuthority = canonical.PositionAuthorityVersion; + int callbacks = 0; + + ApplyQueuedPosition( + lifetime, + guid, + positionSequence: 2, + teleportSequence: 0, + forcePositionSequence: 0, + positionX: 20f, + projectionRequiresTeleportHook: false, + () => callbacks++); + ApplyQueuedPosition( + lifetime, + guid, + positionSequence: 3, + teleportSequence: 1, + forcePositionSequence: 0, + positionX: 30f, + projectionRequiresTeleportHook: true, + () => callbacks++); + ApplyQueuedPosition( + lifetime, + guid, + positionSequence: 4, + teleportSequence: 2, + forcePositionSequence: 0, + positionX: 40f, + projectionRequiresTeleportHook: false, + () => callbacks++); + + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease retained)); + Assert.Equal(initial.Token, retained.Token); + Assert.Equal(initial.Route, retained.Route); + Assert.Equal(initial.Placement, retained.Placement); + Assert.Equal(10f, canonical.Snapshot.Position!.Value.PositionX); + Assert.Equal(positionAuthority, canonical.PositionAuthorityVersion); + Assert.Equal(0, callbacks); + Assert.Equal([1UL, 2UL, 3UL], + retained.Continuations.Select(static item => item.Sequence)); + Assert.Equal([20f, 30f, 40f], + retained.Continuations.Select( + static item => item.AcceptedWirePosition.PositionX)); + Assert.Equal([(ushort)0, (ushort)0, (ushort)1], + retained.Continuations.Select( + static item => item.PreviousTeleportSequence)); + Assert.Equal([(ushort)0, (ushort)1, (ushort)2], + retained.Continuations.Select( + static item => item.TeleportSequence)); + Assert.False(retained.Continuations[0] + .ProjectionRequiresTeleportHook); + Assert.True(retained.Continuations[1] + .ProjectionRequiresTeleportHook); + } + + [Fact] + public void MalformedQueuedPositionIsRejectedBeforeTimestampConsumption() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 30UL); + const uint guid = 0x7000301Cu; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease initial)); + WorldSession.EntityPositionUpdate malformed = PositionUpdate( + guid, + positionSequence: 2, + teleportSequence: 0, + forcePositionSequence: 0, + positionX: float.NaN); + + Assert.False(lifetime.TryApplyPosition( + malformed, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out _)); + + Assert.Equal(PositionTimestampDisposition.Rejected, disposition); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease retained)); + Assert.Equal(initial, retained); + Assert.Equal(10f, canonical.Snapshot.Position!.Value.PositionX); + + ApplyQueuedPosition( + lifetime, + guid, + positionSequence: 2, + teleportSequence: 0, + forcePositionSequence: 0, + positionX: 25f, + projectionRequiresTeleportHook: false, + callback: null, + isLocalPlayer: false); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out retained)); + Assert.Equal((ushort)2, + Assert.Single(retained.Continuations).PositionSequence); + } + + [Fact] + public void CompletionRequiresExactAcknowledgedOriginalPlacement() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + Bind(lifetime, 20UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x7000300Fu, 1, setupId: null), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease original)); + AttachDormantBody(lifetime, canonical); + + RuntimeEntityPlacementToken replacement = lifetime.Physics.SetPosition + .BeginAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + original.Route.SetPositionFlags); + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + replacement, + preparation, + out RuntimeSetPositionCommand command)); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(replacement, command); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.RejectedAuthority, + lifetime.CompleteInitialCreateResidence( + canonical, + original.Token, + out _)); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .AcknowledgedPlacementCompletionCount); + } + + [Fact] + public void FreshPositionQueuesBehindCommittedUnacknowledgedInitialAdmission() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + Bind(lifetime, 23UL); + int deleted = 0; + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => + { + if (delta.Change is RuntimeEntityChange.Deleted) + deleted++; + })); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003013u, 1, setupId: null), + isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease initial)); + AttachDormantBody(lifetime, canonical); + RuntimeSetPositionOutcome first = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + initial.Placement, + Prepare( + lifetime, + initial, + RuntimeSetPositionMoverSetup.ResolvedAbsent)); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + first.Status); + Assert.Equal(Cell, canonical.FullCellId); + + RuntimeEntityRegistrationResult newer = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + 0x70003013u, + 1, + setupId: null, + positionSequence: 2, + positionX: 45f), + isLocalPlayer: true); + WorldSession.EntityPositionUpdate update = + newer.Inbound.SameGenerationEvents!.Value.Position!.Value; + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer: true, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.Equal(0, deleted); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot place)); + Assert.Equal(RuntimePlacementProjectionKind.Place, place.Kind); + Assert.Equal(first.Projection.Sequence, place.Token.Sequence); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + place.Token)); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease successor)); + Assert.Equal(initial.Token, successor.Token); + Assert.Equal(initial.Route, successor.Route); + Assert.Equal(initial.Placement, successor.Placement); + Assert.Equal(RuntimeTeleportHookPhase.AfterEnterWorld, + successor.Route.TeleportHookPhase); + RuntimeInitialCreateResidenceContinuation continuation = + Assert.Single(successor.Continuations); + Assert.Equal(45f, continuation.AcceptedWirePosition.PositionX); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + successor.Token, + out RuntimeInitialCreateResidenceReceipt receipt)); + Assert.Equal(RuntimeTeleportHookPhase.AfterEnterWorld, + receipt.TeleportHookPhase); + Assert.Equal(first.Projection, receipt.Projection); + Assert.Equal(successor.Continuations, receipt.Continuations); + ApplyQueuedPosition( + lifetime, + canonical.ServerGuid, + positionSequence: 3, + teleportSequence: 1, + forcePositionSequence: 0, + positionX: 55f, + projectionRequiresTeleportHook: true, + callback: null); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + successor.Token, + out RuntimeInitialCreateResidenceReceipt repeated)); + Assert.Equal(receipt.Adoption.Revision + 1UL, + repeated.Adoption.Revision); + Assert.Equal(2, repeated.Continuations.Length); + Assert.Equal(55f, + repeated.Continuations[1].AcceptedWirePosition.PositionX); + Assert.False(lifetime.AcknowledgeInitialCreateResidenceAdoption( + canonical, + receipt.Adoption)); + Assert.True(lifetime.AcknowledgeInitialCreateResidenceAdoption( + canonical, + repeated.Adoption)); + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.RejectedToken, + lifetime.CompleteInitialCreateResidence( + canonical, + successor.Token, + out _)); + Assert.Equal(0, deleted); + } + + [Fact] + public void PreAcknowledgementSpatialCorruptionRetiresOperationAsDiscard() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + Bind(lifetime, 31UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x7000301Du, 1, setupId: null), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + lease.Placement, + Prepare( + lifetime, + lease, + RuntimeSetPositionMoverSetup.ResolvedAbsent)); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + + lifetime.Entities.SetFullCell( + canonical, + Landblock | 0x0002u, + Landblock); + + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.RejectedAuthority, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out _)); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot discard)); + Assert.Equal(RuntimePlacementProjectionKind.Discard, discard.Kind); + Assert.Equal(outcome.Projection.Sequence, discard.Token.Sequence); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + discard.Token)); + RuntimeSetPositionOwnershipSnapshot ownership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(0, ownership.ActiveOperationCount); + Assert.Equal(0, ownership.PlacementCompletionWatchCount); + Assert.Equal(0, ownership.AcknowledgedPlacementCompletionCount); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + } + + [Fact] + public void CorruptedPostAcknowledgementAuthorityRetiresProofAndLease() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + Bind(lifetime, 21UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(0x70003010u, 1, setupId: null), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + lease.Placement, + Prepare( + lifetime, + lease, + RuntimeSetPositionMoverSetup.ResolvedAbsent)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + lifetime.Entities.AdvancePlacementCommit(canonical); + + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.RejectedAuthority, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out _)); + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + RuntimeSetPositionOwnershipSnapshot ownership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(0, ownership.PlacementCompletionWatchCount); + Assert.Equal(0, ownership.AcknowledgedPlacementCompletionCount); + } + + [Fact] + public void LegacyRegistrationAdvancesSpatialAuthorityOnlyOnce() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord canonical = lifetime.RegisterEntity( + Spawn(0x70003011u, 1)).Canonical!; + + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal(1UL, canonical.SpatialAuthorityVersion); + } + + private static RuntimeSetPositionCommand Prepare( + RuntimeEntityObjectLifetime lifetime, + in RuntimeInitialCreateResidenceLease lease, + in RuntimeSetPositionMoverSetup setup) + { + var preparation = new RuntimeSetPositionMoverPreparation( + setup, + lease.Route.OperationKind, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + lease.Route.SetPositionFlags); + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + lease.Placement, + preparation, + out RuntimeSetPositionCommand command)); + return command; + } + + private static RuntimeInitialCreateResidenceLease ApplyFreshSuccessor( + RuntimeEntityObjectLifetime lifetime, + uint guid, + bool isLocalPlayer, + ushort teleportSequence, + ushort forcePositionSequence, + out PositionTimestampDisposition disposition) + { + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1), + isLocalPlayer) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease initial)); + RuntimeEntityRegistrationResult newer = lifetime + .RegisterEntityWithInitialResidence( + Spawn( + guid, + 1, + positionSequence: 2, + positionX: 40f, + teleportSequence: teleportSequence, + forcePositionSequence: forcePositionSequence), + isLocalPlayer); + WorldSession.EntityPositionUpdate update = + newer.Inbound.SameGenerationEvents!.Value.Position!.Value; + var preservedRotation = Quaternion.CreateFromYawPitchRoll( + 0.5f, + 0.25f, + 0.125f); + var currentVelocity = new Vector3(1f, 2f, 3f); + + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer, + forcePositionRotation: preservedRotation, + currentLocalVelocity: currentVelocity, + projectionRequiresTeleportHook: true, + acknowledgeProjection: null, + out disposition, + out _, + out _)); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease retained)); + Assert.Equal(initial.Token, retained.Token); + Assert.Equal(initial.Route, retained.Route); + Assert.Equal(initial.Placement, retained.Placement); + return retained; + } + + private static void ApplyQueuedPosition( + RuntimeEntityObjectLifetime lifetime, + uint guid, + ushort positionSequence, + ushort teleportSequence, + ushort forcePositionSequence, + float positionX, + bool projectionRequiresTeleportHook, + Action? callback, + bool isLocalPlayer = true) + { + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, + positionSequence, + teleportSequence, + forcePositionSequence, + positionX); + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer, + forcePositionRotation: Quaternion.Identity, + currentLocalVelocity: new Vector3(1f, 2f, 3f), + projectionRequiresTeleportHook, + acknowledgeProjection: callback is null + ? null + : _ => callback(), + out PositionTimestampDisposition disposition, + out _, + out _)); + Assert.True(disposition is PositionTimestampDisposition.Apply + or PositionTimestampDisposition.ForcePosition); + } + + private static WorldSession.EntityPositionUpdate PositionUpdate( + uint guid, + ushort positionSequence, + ushort teleportSequence, + ushort forcePositionSequence, + float positionX) + { + return new WorldSession.EntityPositionUpdate( + guid, + new CreateObject.ServerPosition( + Cell, + positionX, + 20f, + 7f, + 1f, + 0f, + 0f, + 0f), + new Vector3(positionSequence, 2f, 3f), + PlacementId: positionSequence, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: positionSequence, + TeleportSequence: teleportSequence, + ForcePositionSequence: forcePositionSequence); + } + + private static void AttachDormantBody( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord canonical) + { + var body = new PhysicsBody + { + State = canonical.FinalPhysicsState, + Orientation = Quaternion.Identity, + InWorld = false, + }; + lifetime.Entities.SetPhysicsBody(canonical, body); + } + + private static void Bind( + RuntimeEntityObjectLifetime lifetime, + ulong generation) + { + var token = new RuntimeGenerationToken(generation); + lifetime.BindEventContext(() => token, static () => 1UL); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation, + bool missile = false, + bool includePosition = true, + uint? parentGuid = null, + uint? setupId = 0x02000001u, + ushort positionSequence = 1, + float positionX = 10f, + uint positionCell = Cell, + ushort teleportSequence = 0, + ushort forcePositionSequence = 0) + { + CreateObject.ServerPosition? position = includePosition + ? new CreateObject.ServerPosition( + positionCell, + positionX, + 20f, + 7f, + 1f, + 0f, + 0f, + 0f) + : null; + uint rawState = (uint)(PhysicsStateFlags.Gravity + | (missile ? PhysicsStateFlags.Missile : 0)); + var timestamps = new PhysicsTimestamps( + Position: positionSequence, + Movement: 1, + State: 1, + Vector: 1, + Teleport: teleportSequence, + ServerControlledMove: 1, + ForcePosition: forcePositionSequence, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + rawState, + position, + Movement: null, + AnimationFrame: null, + setupId, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: parentGuid is { } parent + ? new PhysicsAttachment(parent, 1u) + : null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + setupId, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "initial-create", + ItemType: null, + MotionState: null, + MotionTableId: null, + PhysicsState: rawState, + InstanceSequence: incarnation, + PositionSequence: positionSequence, + ParentGuid: parentGuid, + ParentLocation: parentGuid is null ? null : 1u, + Physics: physics); + } + + private sealed class EntityObserver(Action onEntity) + : IRuntimeEntityObjectObserver + { + public void OnEntity(in RuntimeEntityDelta delta) => onEntity(delta); + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + } + + private sealed class PlacementObserver( + Action onPlacement) + : IRuntimePlacementObserver + { + public void OnPlacement(in RuntimePlacementDelta delta) => + onPlacement(delta); + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs index d895998e..5db0b5fa 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs @@ -48,7 +48,7 @@ public sealed class RuntimeAuthoritativePositionRouteClassifierTests [Theory] [InlineData(RuntimeCreateResidenceKind.Parented)] [InlineData(RuntimeCreateResidenceKind.PickedUp)] - internal void NewParentedOrPickedUpCreate_LeavesWorldAndAwaitsPosition( + internal void NewParentedOrPickedUpCreate_RemainsCelllessAndAwaitsPosition( RuntimeCreateResidenceKind residence) { RuntimeAuthoritativePositionRoute route = @@ -62,7 +62,7 @@ public sealed class RuntimeAuthoritativePositionRouteClassifierTests Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, route.Disposition); - Assert.True(route.LeaveWorld); + Assert.False(route.LeaveWorld); Assert.False(route.PerformsSetPosition); } @@ -294,7 +294,7 @@ public sealed class RuntimeAuthoritativePositionRouteClassifierTests RuntimeAuthoritativePositionRoute route = ClassifyLocal( Authority(ushort.MaxValue, 0)); - Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPosition, + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); Assert.Equal(0x1012u, (uint)route.SetPositionFlags); Assert.True(route.UnparentBeforeRouting); From 9d601817b85c5d719c3cbd7bdd91e615f1405d33 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 19:42:19 +0200 Subject: [PATCH 44/73] docs(physics): hand off initial create residence --- docs/plans/2026-04-11-roadmap.md | 22 +- docs/plans/2026-05-12-milestones.md | 17 +- ...untime-initial-create-residence-handoff.md | 297 ++++++++++++++++++ memory/project_collision_port.md | 17 + 4 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 docs/research/2026-08-01-runtime-initial-create-residence-handoff.md diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index f631d63a..a4337243 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -66,17 +66,17 @@ waived the general sweep and explicitly deferred the barred-house gate as authorized retirement of the remaining proven collision/placement gaps before vendor work resumes. Nested retry, edge/StepDown/Path-6 ordering, exact cell availability, atomic collision generations, canonical Core SetPosition, -Runtime lost-cell residence, and the dormant placement receipt channel are -landed. Placement Slice 4B2 checkpoint 2 adds the presentation-free Runtime -owner for retail collision tracking, environment latch, ordered callbacks, -missile-state clearing, expiry/force-end lifetime, and SetPosition's exact -report-result boolean. It deliberately does not activate an App or Headless -production route. Next are exact authored mover preparation, the atomic shared -body/controller transaction, presentation-only placement observers, collision- -prefix quiescence, and the all-route cutover which can retire AP-1/AD-1. AP-22 -authored object shapes and AD-10 remote contact-plane projection follow, then -the final matrix and ledger closeout. Detailed handoff: -[`2026-07-31-runtime-set-position-collision-reporting-handoff.md`](../research/2026-07-31-runtime-set-position-collision-reporting-handoff.md). +Runtime lost-cell residence, authored mover/body preparation, placement +receipts and observers, collision-prefix replacement, authoritative route +classification, and initial Create residence are landed as bisectable +checkpoints. Commit `38fd4b8d` retains the accepted Create placement plus +fresher Position FIFO until exact placement and ordered adoption complete. +Production Create registration is not yet cut over. Next are the synchronous +Runtime continuation executor, retail's exact Create tail ordering, and the +all-host/all-route cutover which can retire AP-1/AD-1. AP-22 authored object +shapes and AD-10 remote contact-plane projection follow, then the final matrix +and ledger closeout. Current handoff: +[`2026-08-01-runtime-initial-create-residence-handoff.md`](../research/2026-08-01-runtime-initial-create-residence-handoff.md). --- diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 5c22e9c5..85ec123c 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -91,14 +91,15 @@ broaden the feature surface. Slices 1–4 are user-accepted. Campaign P's connected feel matrix closed on 2026-07-31 with tight-gap collision clearance (#273) and the deferred restricted-house gate (#274) explicitly carried. The user subsequently authorized the remaining physics-divergence closeout before -vendor work. Placement Slice 4B2 checkpoint 2 now gives Runtime the retail -SetPosition collision table, environment latch, ordered report callbacks, and -exact report-result owner without activating production placement. The -remaining order is authored mover/body preparation, atomic graphical/no-window -body publication, presentation-only projection and prefix quiescence, then the -all-route SetPosition cutover; AP-22 shape fidelity and AD-10 remote contact- -plane projection follow. Resume Slice 5 vendor browsing only after that -closeout or a new explicit user direction. +vendor work. Placement Slice 4B2 is now complete through dormant SetPosition +activation, graphical/no-window placement receipts, collision-prefix +replacement, authoritative route classification, pre-placement App staging, +and Runtime's initial Create residence/FIFO transaction at `38fd4b8d`. +Production Create registration is not yet cut over. The remaining order is one +synchronous continuation executor with retail's exact Create tail, the all- +host/all-route cutover, AP-22 shape fidelity, AD-10 remote contact-plane +projection, and the final matrix/ledger closeout. Resume Slice 5 vendor +browsing only after that closeout or a new explicit user direction. The separately authorized modern-runtime performance program has completed Slices A–D: corrected measurement, prepared-package bake/dedup, package-only diff --git a/docs/research/2026-08-01-runtime-initial-create-residence-handoff.md b/docs/research/2026-08-01-runtime-initial-create-residence-handoff.md new file mode 100644 index 00000000..9327c452 --- /dev/null +++ b/docs/research/2026-08-01-runtime-initial-create-residence-handoff.md @@ -0,0 +1,297 @@ +# Runtime initial Create residence handoff - 2026-08-01 + +## Purpose and exact stopping point + +Commit `38fd4b8dc952236d4b98518c67335026c7815656` adds the dormant Runtime +transaction which retains an entity's initial authored CreateObject placement +until canonical SetPosition succeeds and the ordered remainder of the Create +packet can be adopted. It does not yet cut the production App/Headless Create +route over, so AP-1 and AD-1 remain open. + +This is the deliberate clean handoff requested by the user. In plain terms, +Runtime now has a tested holding area for a newly created world object while +its exact collision placement is being resolved. The object cannot become +half-visible, consume later position packets, or be silently replaced during +that interval. The next model starts at the executor/cutover boundary; it does +not need to repair or redesign this ownership transaction. + +Do not start production cutover from an earlier checkpoint. Do not call this +campaign complete: AP-1, AD-1, AP-22, and AD-10 remain open. + +## Exact workspace and Git state + +- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream` +- Branch: `codex/port-claude-agents` +- Code checkpoint: `38fd4b8dc952236d4b98518c67335026c7815656` +- Immediately preceding host-staging checkpoint: `74103f75` +- No push or merge is part of this stopping point. + +The worktree intentionally contains unrelated user changes/stat noise. Do not +stage, restore, normalize, or rewrite these paths as part of the continuation: + +- `AGENTS.md` (real unrelated content change); +- `src/AcDream.App/Input/PlayerModeController.cs`; +- `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs`; +- `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs`; +- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`; +- `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs`; +- `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs`; +- `tools/A8CellAudit/A8CellAudit.csproj`. + +The paths after `AGENTS.md` currently have no content diff and are reported +because of pre-existing line-ending/stat noise. Always stage exact paths; +never use `git add -A`. + +## Placement checkpoint chain + +The current mechanism was built as bisectable commits. The directly relevant +chain, oldest first, is: + +- `e84a388e` - pure Core retail SetPosition transaction; +- `4c02ac42` - Runtime deferred/lost-cell residence owner; +- `270f5154` - dormant placement receipt channel; +- `237d1184` - retail SetPosition collision-report owner; +- `442cb8f9` - exact authored mover preparation; +- `22651c82` - dormant Runtime local physics publication; +- `99f867f0` - sealed dormant SetPosition evaluation; +- `5785a07b` - dormant SetPosition activation; +- `ef436678` - placement acknowledgement ownership; +- `74c9b155`, `378ca95a`, `f05ed5c3` - graphical/headless projection seams; +- `99bf1751`, `9b0f59bd` - collision-prefix quiescence and atomic replacement; +- `0fbc7a1f` - hidden-object SetPosition ownership correction; +- `3f800a4a` - authoritative route classification; +- `74103f75` - inert App materialization before Runtime placement; +- `38fd4b8d` - initial Create residence, continuation FIFO, and adoption. + +## Owned mechanism in `38fd4b8d` + +`RuntimeInitialCreateResidenceState` now owns, per exact entity incarnation: + +- the accepted initial Create frame and exact SetPosition operation; +- a cellless logical entity while authored placement is pending; +- a monotonic immutable FIFO for fresher Position continuations; +- accepted timestamp, position, vector, rotation, placement, and wire payloads; +- completion/adoption tokens and a revision which reject stale observers; +- exact authority revalidation across generation, identity, Create, position, + placement, full-cell, deletion, reset, GUID reuse, and disposal; +- reentrant-safe cancellation at the lifetime commit boundary. + +The public legacy registration path is intentionally unchanged. Production +behavior remains on the previous route until the continuation executor and +all-host cutover land together. + +### Exact behavior now protected + +- Initial/New Create admission is previewed without consuming timestamps; + Existing and Stale packets still use the established gates. +- No collision generation is guessed. An initial residence can exist only + after binding a real, nonzero generation. +- Fresh Parent wins over Position, matching the packet's relation priority. +- An absent or present-zero position cell remains cellless instead of being + fabricated as an outdoor placement. +- Later accepted Position packets append to one immutable ordered FIFO. They + cannot mutate the original placement operation or bypass it. +- A completed but not yet adopted transaction remains exclusive. A later + Position revises the retained batch and invalidates the old adoption token; + it cannot disappear between completion and acknowledgement. +- Placement acknowledgement uses exact identity, operation, generation, + position authority, Create integration, full-cell, and placement-commit + versions. +- Reset first detaches and clears ownership, then publishes cancellation, so a + reentrant observer cannot invalidate enumeration or resurrect an owner. +- Delete, replacement, pickup, parent, withdrawal, reset, and disposal return + cancellation receipts to the caller's safe publication boundary instead of + invoking observers before later canonical mutation. +- Malformed initial or continuation packets fail before timestamp or canonical + state consumption. A corrected packet with the same instance can recover. + +The FIFO stores raw accepted Position facts rather than prematurely choosing +a final movement route. That is intentional: contact, animation state, the +server-position option, and player distance must be sampled at the same point +where retail makes the routing decision. + +## Exact files in `38fd4b8d` + +- `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` +- `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs` +- `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs` +- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` +- `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs` +- `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs` +- `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` +- `tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs` +- `tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs` +- `tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs` + +## Retail order for the next slice + +The next slice must preserve `SmartBox::HandleCreateObject` at `0x00454C80`: + +1. visual description; +2. exactly one of Parent, Position, or Pickup relation; +3. Movement; +4. State; +5. Vector; +6. Weenie description; +7. final resident-cell validity cleanup. + +Position routing must also preserve these named-retail distinctions: + +- a same-incarnation Create position is not equivalent to standalone F748; +- ForcePosition performs its own timestamp/parent/placement route; +- remote near-contact interpolates, remote far-contact stops interpolation and + performs SetPosition, and remote teleport invokes the teleport hook before + SetPosition; +- local teleport performs SetPosition, then the player-teleported hook, then + constrains to the authoritative frame and clears velocity; +- local ordinary Position constrains first and interpolates only when the + server-position option and contact gate permit it. + +Therefore every retained continuation must include its +`RuntimeAcceptedPositionSource`, and executor-time inputs must pin HasAnims, +UsePositionFromServer, contact, and distance before mutation. Parent/Pickup and +the same-Create Movement -> State -> Vector order must be part of the same +synchronous adoption transaction. + +The principal named-retail anchors are: + +- `SmartBox::HandleCreateObject` `0x00454C80`; +- `CPhysicsObj::SetPositionInternal` `0x00515330`; +- `PhysicsDesc::UnPack` `0x0051DDD0`; +- `CPhysicsObj::set_description` `0x00514F40`. + +Use `docs/research/named-retail/acclient_2013_pseudo_c.txt` first and the older +Ghidra chunks only as a fallback. + +## Validation and reviews + +The implementation agent and reviewers reported: + +- focused initial-residence and classifier tests: 79/79; +- complete Runtime tests: 819/819; +- Runtime Release build: zero warnings and errors; +- focused Core timestamp tests: 31/31; +- retail-conformance review: clean; +- architecture/adversarial review: clean; +- `git diff --check`: clean. + +Primary-agent final gates after the behavior commit: + +- complete Release solution build: succeeded, 0 errors; +- complete Release solution tests: 10,612 passed / 4 intentional skips; +- App: 4,027 passed / 3 skips; +- Core: 4,242 passed / 1 skip; +- Runtime: 819 passed; +- Core.Net: 762 passed; +- UI abstractions: 543 passed; +- Headless: 76 passed; +- Content: 124 passed; +- Bake: 15 passed; +- CLI: 4 passed. + +The build reports 21 pre-existing test-project nullable/analyzer warnings. The +checkpoint introduces no build errors or new production warning. + +Both independent reviews initially found real edge cases and the final code +includes their root-cause fixes: + +- completed-but-unadopted Position packets could bypass the FIFO; +- cancellation callbacks could re-enter before the caller's canonical mutation; +- reset could enumerate live dictionaries while a callback mutated them; +- adoption did not initially validate every spatial/authority version. + +Final retail-conformance and architecture/adversarial rereviews both passed. +No connected visual gate was required because the new API is dormant and no +production App or Headless route calls it yet. + +## Production routes intentionally unchanged + +This is the key handoff boundary. At this checkpoint: + +- graphical Create still flows through + `LiveEntityHydrationController.OnCreateCore`, + `LiveEntityRuntime.RegisterLiveEntity`, and legacy `RegisterEntity`; +- graphical materialization still defaults to `LegacyImmediate` rather than + the new `AwaitRuntimePlacement` residence; +- graphical Position still performs its existing world-position, rebucket, + projectile, remote-motion, and shadow work; +- headless Create still uses `RuntimeLiveEntitySessionController.OnSpawned`, + `HeadlessSessionWorldProjection.ProjectSpawn`, and its independent initial + resolve/body construction; +- headless Position still uses its existing projection path; +- the new Runtime initial-residence API is reached by focused tests only. + +Existing host adapters already observe Runtime placement receipts. Do not add +another observer architecture or a second GUID map. + +## Next implementation boundary + +Implement one Runtime continuation executor and exact ordered Create tail, +then route graphical and no-window registration through it without a mirror. +The executor must be synchronous or retry-idempotent around adoption revision; +failure must leave the exact FIFO head retryable. Only after both production +hosts and every Create/Position/ForcePosition/parent/pickup route use the same +owner may AP-1 and AD-1 retire. + +### Required order for the next model + +1. Add `RuntimeAcceptedPositionSource` to every retained continuation. A + same-incarnation Create position and standalone F748 are not interchangeable. +2. Implement one Runtime-owned synchronous continuation executor. Capture + `UsePositionFromServer`, animation/contact state, and player distance at the + retail-equivalent decision point. +3. Execute initial placement once, consume its exact host acknowledgement, + then drain the continuation FIFO in order with retail's hook ordering. +4. Serialize one Create packet as relation + (Parent/Position/Pickup), Movement, State, Vector, WeenieDesc, cleanup. +5. Keep every side effect exactly-once. If execution can yield, make adoption + revision/idempotence explicit so retry cannot replay hooks or position sends. +6. Switch graphical and headless registration together to the same Runtime + owner. Hosts may project immutable results only; they may not resolve a + second placement or create another body. +7. Route later Create, Position, ForcePosition, teleport, parent, pickup, + withdrawal, delete, remote, projectile, and dropped-item edges through the + same owner before deleting legacy paths. +8. Run focused tests, full Release build/tests, exact lifecycle/reconnect and + nine-stop connected gates, then perform the user visual matrix. Only then + retire AP-1 and AD-1. + +Do not begin AP-22 or AD-10 until the production placement cutover is green. + +### Subsequent independent slices + +- **AP-22:** make `ShadowShapeBuilder` the only prepared Setup-shape authority; + preserve authored cylinder order, use spheres only when cylinders are absent, + allow truly shapeless Setups, and remove radius/height synthesis and sphere- + to-cylinder coercion across graphical/headless/live publication. +- **AD-10:** remove terrain-normal preprojection from remote motion. Let the + canonical transition resolver project against the actual retained contact + plane, with tests where terrain and BSP/prop normals deliberately differ. +- Run the final connected matrix, synchronize ledgers/docs, and only then close + the remaining physics-divergence campaign. + +## Rollback + +Revert the behavior checkpoint without disturbing the earlier placement +foundation: + +```powershell +git revert 38fd4b8dc952236d4b98518c67335026c7815656 +``` + +The documentation checkpoint containing this file is a separate commit and +can be reverted independently if only the handoff text needs correction. + +## Resume checklist + +1. Continue in the exact worktree and branch recorded above. +2. Confirm `git rev-parse HEAD` includes both the behavior and documentation + checkpoint commits. +3. Read this file, `docs/architecture/acdream-architecture.md`, + `docs/research/2026-07-31-canonical-set-position.md`, and + `docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md`. +4. Run `git status --short` and preserve every unrelated path listed above. +5. Re-run the focused 79-test residence/classifier gate before modifying the + transaction. +6. Begin only the continuation executor and ordered Create tail. Do not start + AP-22/AD-10 or vendor work in the same commit. diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md index 5d8409ed..bf488e54 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -1,5 +1,22 @@ # Collision System Port - Status and Plan +## 2026-08-01 initial Create residence checkpoint + +The dormant Runtime SetPosition path is now complete through activation, +graphical/no-window placement receipts, collision-prefix quiescence, exact +authoritative route classification, pre-placement App staging, and initial +Create residence. Commit `38fd4b8d` owns the accepted initial Create frame, +cell-less pending lifetime, fresher Position FIFO, exact completion/adoption +revision, and reentrant teardown. Its retail and continuation handoff is: + +- `docs/research/2026-08-01-runtime-initial-create-residence-handoff.md` + +Production Create registration has not yet been cut over. Next, implement the +synchronous Runtime continuation executor and exact Create tail order +(visual, Parent/Position/Pickup, Movement, State, Vector, WeenieDesc, cleanup), +then cut graphical and no-window hosts over together. AP-1/AD-1 remain open +until that cutover. AP-22 and AD-10 follow as separate behavior slices. + ## 2026-07-31 placement checkpoint 2 Runtime now owns the retail collision-report state required by canonical From 30012361e12222e8271b1531574257ba910c77cb Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 21:00:03 +0200 Subject: [PATCH 45/73] feat(runtime): freeze initial placement inbound admission --- .../Entities/InboundPhysicsStateController.cs | 216 +++- .../Entities/ParentAttachmentState.cs | 147 +++ .../Entities/RuntimeEntityDirectory.cs | 62 + .../Entities/RuntimeEntityObjectLifetime.cs | 687 ++++++++++- .../RuntimeInitialCreateAdmissionFreezer.cs | 92 ++ .../RuntimeInitialCreateResidenceState.cs | 496 +++++--- ...imeAuthoritativePositionRouteClassifier.cs | 43 +- ...RuntimeInitialCreateResidenceStateTests.cs | 1082 +++++++++++++++-- ...thoritativePositionRouteClassifierTests.cs | 4 +- 9 files changed, 2506 insertions(+), 323 deletions(-) create mode 100644 src/AcDream.Runtime/Entities/RuntimeInitialCreateAdmissionFreezer.cs diff --git a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs index a88a9058..8f7674f0 100644 --- a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs +++ b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs @@ -20,13 +20,35 @@ public sealed class InboundPhysicsStateController public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) => _snapshots.TryGetValue(guid, out spawn); + internal bool TryGetAcceptedTimestamps( + uint guid, + out AcceptedPhysicsTimestamps timestamps) + { + if (_gates.TryGetValue(guid, out PhysicsTimestampGate? gate)) + { + timestamps = Current(gate); + return true; + } + timestamps = default; + return false; + } + public CreateObjectTimestampDisposition PreviewCreateDisposition( WorldSession.EntitySpawn incoming) => _gates.TryGetValue(incoming.Guid, out PhysicsTimestampGate? gate) ? gate.PreviewCreateObject(incoming.InstanceSequence) : CreateObjectTimestampDisposition.InitialGeneration; - public InboundCreateResult AcceptCreate(WorldSession.EntitySpawn incoming) + public InboundCreateResult AcceptCreate(WorldSession.EntitySpawn incoming) => + AcceptCreateCore(incoming, deferSameGenerationWeenieDescription: false); + + internal InboundCreateResult AcceptCreateDeferredSameGeneration( + WorldSession.EntitySpawn incoming) => + AcceptCreateCore(incoming, deferSameGenerationWeenieDescription: true); + + private InboundCreateResult AcceptCreateCore( + WorldSession.EntitySpawn incoming, + bool deferSameGenerationWeenieDescription) { if (!_gates.TryGetValue(incoming.Guid, out PhysicsTimestampGate? gate)) { @@ -56,8 +78,11 @@ public sealed class InboundPhysicsStateController return new InboundCreateResult(disposition, incoming, null, Current(gate)); } - WorldSession.EntitySpawn merged = MergeUntimestampedCreate(retained, incoming); - _snapshots[incoming.Guid] = merged; + WorldSession.EntitySpawn merged = deferSameGenerationWeenieDescription + ? retained + : MergeUntimestampedCreate(retained, incoming); + if (!deferSameGenerationWeenieDescription) + _snapshots[incoming.Guid] = merged; return new InboundCreateResult( disposition, merged, @@ -422,6 +447,181 @@ public sealed class InboundPhysicsStateController return true; } + /// + /// Consumes only the retail Position timestamp gates for a deferred + /// initial-Create continuation. The raw PositionPack is not transformed + /// and no pose, parent, placement, or velocity field is installed. The + /// returned authority is sufficient for one later execution without + /// running the gate a second time. + /// + internal bool TryAcceptDeferredPosition( + WorldSession.EntityPositionUpdate update, + bool isLocalPlayer, + out PositionTimestampDisposition disposition, + out AcceptedPhysicsTimestamps timestamps, + out bool hasTimestampMutation) + { + if (!TryGet( + update.Guid, + out PhysicsTimestampGate? gate, + out WorldSession.EntitySpawn old)) + { + disposition = PositionTimestampDisposition.Rejected; + timestamps = default; + hasTimestampMutation = false; + return false; + } + + ushort previousPosition = gate.PositionTimestamp; + ushort previousTeleport = gate.TeleportTimestamp; + ushort previousForcePosition = gate.ForcePositionTimestamp; + bool advancesTeleport = PhysicsTimestampGate.IsNewer( + previousTeleport, + update.TeleportSequence); + disposition = gate.TryAcceptPositionEvent( + update.InstanceSequence, + update.PositionSequence, + update.TeleportSequence, + update.ForcePositionSequence, + isLocalPlayer); + timestamps = Current( + gate, + teleportAdvanced: disposition is PositionTimestampDisposition.Apply + && advancesTeleport, + previousTeleport: previousTeleport); + hasTimestampMutation = previousPosition != gate.PositionTimestamp + || previousTeleport != gate.TeleportTimestamp + || previousForcePosition != gate.ForcePositionTimestamp; + return true; + } + + internal bool TryAcceptDeferredObjDesc( + ObjDescEvent.Parsed update, + out AcceptedPhysicsTimestamps timestamps) + { + if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out _) + || !gate.TryAcceptObjDescEvent( + update.InstanceSequence, + update.ObjDescSequence)) + { + timestamps = default; + return false; + } + timestamps = Current(gate); + return true; + } + + internal bool TryAcceptDeferredPickup( + PickupEvent.Parsed update, + out AcceptedPhysicsTimestamps timestamps) + { + if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out _) + || !gate.TryAcceptPositionChannelEvent( + update.InstanceSequence, + update.PositionSequence)) + { + timestamps = default; + return false; + } + timestamps = Current(gate); + return true; + } + + internal bool TryAcceptDeferredCreateParent( + CreateParentUpdate update, + out AcceptedPhysicsTimestamps timestamps) + { + if (!TryGet(update.ChildGuid, out PhysicsTimestampGate? gate, out _) + || !gate.TryAcceptPositionChannelEvent( + update.ChildInstanceSequence, + update.ChildPositionSequence)) + { + timestamps = default; + return false; + } + timestamps = Current(gate); + return true; + } + + internal bool TryAcceptDeferredParent( + ParentEvent.Parsed update, + out AcceptedPhysicsTimestamps timestamps) + { + if (!_gates.TryGetValue( + update.ParentGuid, + out PhysicsTimestampGate? parentGate) + || !parentGate.IsCurrentInstance(update.ParentInstanceSequence) + || !TryGet( + update.ChildGuid, + out PhysicsTimestampGate? childGate, + out _) + || !childGate.TryAcceptPositionChannelEvent( + childGate.InstanceTimestamp, + update.ChildPositionSequence)) + { + timestamps = default; + return false; + } + timestamps = Current(childGate); + return true; + } + + internal bool TryAcceptDeferredMotion( + WorldSession.EntityMotionUpdate update, + out AcceptedPhysicsTimestamps timestamps, + out bool hasTimestampMutation) + { + if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out _)) + { + timestamps = default; + hasTimestampMutation = false; + return false; + } + + ushort previousMovement = gate.MovementTimestamp; + ushort previousServerControl = gate.ServerControlledMoveTimestamp; + bool accepted = gate.TryAcceptMovementEvent( + update.InstanceSequence, + update.MovementSequence, + update.ServerControlSequence); + timestamps = Current(gate); + hasTimestampMutation = previousMovement != gate.MovementTimestamp + || previousServerControl != gate.ServerControlledMoveTimestamp; + return accepted; + } + + internal bool TryAcceptDeferredState( + SetState.Parsed update, + out AcceptedPhysicsTimestamps timestamps) + { + if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out _) + || !gate.TryAcceptStateEvent( + update.InstanceSequence, + update.StateSequence)) + { + timestamps = default; + return false; + } + timestamps = Current(gate); + return true; + } + + internal bool TryAcceptDeferredVector( + VectorUpdate.Parsed update, + out AcceptedPhysicsTimestamps timestamps) + { + if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out _) + || !gate.TryAcceptVectorEvent( + update.InstanceSequence, + update.VectorSequence)) + { + timestamps = default; + return false; + } + timestamps = Current(gate); + return true; + } + /// /// F751 is a notification gate only. Retail compares it to TELEPORT_TS but /// advances that timestamp later, with the accepted Position packet. @@ -465,12 +665,15 @@ public sealed class InboundPhysicsStateController private static AcceptedPhysicsTimestamps Current( PhysicsTimestampGate gate, - bool teleportAdvanced = false) => new( + bool teleportAdvanced = false, + ushort previousTeleport = 0) => new( gate.InstanceTimestamp, gate.ServerControlledMoveTimestamp, gate.TeleportTimestamp, gate.ForcePositionTimestamp, - teleportAdvanced); + teleportAdvanced, + TeleportHookRequired: false, + previousTeleport); private static WorldSession.EntitySpawn MirrorGateTimestamps( WorldSession.EntitySpawn spawn, @@ -671,7 +874,8 @@ public readonly record struct AcceptedPhysicsTimestamps( ushort Teleport, ushort ForcePosition, bool TeleportAdvanced = false, - bool TeleportHookRequired = false); + bool TeleportHookRequired = false, + ushort PreviousTeleport = 0); public readonly record struct CreateParentUpdate( uint ChildGuid, diff --git a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs index 355d139e..5405ecc7 100644 --- a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs +++ b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs @@ -1,3 +1,4 @@ +using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -17,12 +18,119 @@ public sealed class ParentAttachmentState private readonly Dictionary _recoveryByChild = new(); private readonly Dictionary _lastAcceptedByChild = new(); private readonly Dictionary> _committedChildrenByParent = new(); + private readonly Dictionary> + _deferredCreatesByParent = []; + private ulong _nextDeferredCreateAdmissionId; public int UnresolvedRelationCount => _unresolvedByChild.Values.Sum(queue => queue.Count); public int StagedRelationCount => _stagedByChild.Count; public int RecoveryRelationCount => _recoveryByChild.Count; public int CommittedRelationCount => _lastAcceptedByChild.Count; + internal int DeferredCreateCount => + _deferredCreatesByParent.Values.Sum(queue => queue.Count); + + /// + /// Retains the complete unaccepted CreateObject packet when its nonzero + /// parent is not addressable. Retail queues the raw blob before object + /// lookup and before any child timestamp is consumed; storing a decoded + /// relation after AcceptCreate would partially admit the child. + /// + internal void EnqueueDeferredCreate( + WorldSession.EntitySpawn spawn, + bool isLocalPlayer) + { + uint parentGuid = spawn.ParentGuid + ?? spawn.Physics?.Parent?.Guid + ?? 0u; + if (spawn.Guid == 0u || parentGuid == 0u) + { + throw new ArgumentException( + "A deferred parent CreateObject requires nonzero child and parent GUIDs.", + nameof(spawn)); + } + if (_nextDeferredCreateAdmissionId == ulong.MaxValue) + { + throw new InvalidOperationException( + "The deferred parent CreateObject admission sequence is exhausted."); + } + if (!_deferredCreatesByParent.TryGetValue( + parentGuid, + out Queue? queue)) + { + queue = new Queue(); + _deferredCreatesByParent.Add(parentGuid, queue); + } + ulong admissionId = _nextDeferredCreateAdmissionId + 1UL; + queue.Enqueue(new DeferredParentCreate( + admissionId, + RuntimeInitialCreateAdmissionFreezer.Freeze(spawn), + isLocalPlayer)); + _nextDeferredCreateAdmissionId = admissionId; + } + + internal bool TryPeekDeferredCreate( + uint parentGuid, + out DeferredParentCreate deferred) + { + if (!_deferredCreatesByParent.TryGetValue( + parentGuid, + out Queue? queue) + || !queue.TryPeek(out deferred)) + { + deferred = default; + return false; + } + return true; + } + + internal bool ConsumeDeferredCreate( + uint parentGuid, + in DeferredParentCreate expected) + { + if (!_deferredCreatesByParent.TryGetValue( + parentGuid, + out Queue? queue) + || !queue.TryPeek(out DeferredParentCreate current) + || current != expected) + { + return false; + } + _ = queue.Dequeue(); + if (queue.Count == 0) + _deferredCreatesByParent.Remove(parentGuid); + return true; + } + + internal bool ContainsDeferredCreate( + uint childGuid, + ushort instanceSequence) + { + foreach (Queue queue + in _deferredCreatesByParent.Values) + { + if (queue.Any(candidate => + candidate.Spawn.Guid == childGuid + && candidate.Spawn.InstanceSequence == instanceSequence)) + { + return true; + } + } + return false; + } + + /// + /// Cancels only the raw, still-unaccepted child generation addressed by a + /// terminal packet. Instance zero is a normal retail timestamp and is not + /// treated as an empty sentinel. + /// + internal void CancelDeferredChildGeneration( + uint childGuid, + ushort terminalInstanceSequence) => FilterDeferredCreates( + candidate => candidate.Spawn.Guid != childGuid + || PhysicsTimestampGate.IsNewer( + terminalInstanceSequence, + candidate.Spawn.InstanceSequence)); public void AcceptCreateObjectRelation(ParentAttachmentRelation relation) { @@ -270,6 +378,7 @@ public sealed class ParentAttachmentState public void RemoveObject(uint guid) { + RemoveDeferredChildCreates(guid); _stagedByChild.Remove(guid); _recoveryByChild.Remove(guid); RemoveCommittedChild(guid); @@ -298,6 +407,12 @@ public sealed class ParentAttachmentState /// public void EndGeneration(uint guid, ushort replacementGeneration) { + FilterDeferredCreates(candidate => + candidate.Spawn.Guid != guid + || candidate.Spawn.InstanceSequence == replacementGeneration + || PhysicsTimestampGate.IsNewer( + replacementGeneration, + candidate.Spawn.InstanceSequence)); FilterChildCandidates( guid, relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); @@ -322,6 +437,7 @@ public sealed class ParentAttachmentState /// public void DeleteGeneration(uint guid, ushort deletedGeneration) { + CancelDeferredChildGeneration(guid, deletedGeneration); FilterChildCandidates( guid, relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); @@ -351,6 +467,7 @@ public sealed class ParentAttachmentState public void RemoveChild(uint childGuid) { + RemoveDeferredChildCreates(childGuid); _stagedByChild.Remove(childGuid); _recoveryByChild.Remove(childGuid); RemoveCommittedChild(childGuid); @@ -359,6 +476,7 @@ public sealed class ParentAttachmentState public void Clear() { + _deferredCreatesByParent.Clear(); _unresolvedByChild.Clear(); _stagedByChild.Clear(); _recoveryByChild.Clear(); @@ -368,6 +486,26 @@ public sealed class ParentAttachmentState _committedChildrenByParent.Clear(); } + private void RemoveDeferredChildCreates(uint childGuid) + => FilterDeferredCreates( + candidate => candidate.Spawn.Guid != childGuid); + + private void FilterDeferredCreates( + Func retain) + { + uint[] parents = _deferredCreatesByParent.Keys.ToArray(); + for (int index = 0; index < parents.Length; index++) + { + uint parentGuid = parents[index]; + Queue retained = new( + _deferredCreatesByParent[parentGuid].Where(retain)); + if (retained.Count == 0) + _deferredCreatesByParent.Remove(parentGuid); + else + _deferredCreatesByParent[parentGuid] = retained; + } + } + private void RemoveCommittedChild(uint childGuid) { if (!_lastAcceptedByChild.Remove( @@ -462,6 +600,15 @@ public sealed class ParentAttachmentState ushort InstanceSequence); } +internal readonly record struct DeferredParentCreate( + ulong AdmissionId, + WorldSession.EntitySpawn Spawn, + bool IsLocalPlayer) +{ + internal bool IsValid => AdmissionId != 0UL + && Spawn.Guid != 0u; +} + public readonly record struct ParentAttachmentRelation( uint ParentGuid, uint ChildGuid, diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 293f34db..f037dbf5 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -1,4 +1,5 @@ using AcDream.Core.Net; +using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime.Physics; @@ -47,6 +48,10 @@ public sealed class RuntimeEntityDirectory public InboundCreateResult AcceptCreate(WorldSession.EntitySpawn incoming) => _inbound.AcceptCreate(incoming); + internal InboundCreateResult AcceptCreateDeferredSameGeneration( + WorldSession.EntitySpawn incoming) => + _inbound.AcceptCreateDeferredSameGeneration(incoming); + public CreateObjectTimestampDisposition PreviewCreateDisposition( WorldSession.EntitySpawn incoming) => _inbound.PreviewCreateDisposition(incoming); @@ -59,6 +64,11 @@ public sealed class RuntimeEntityDirectory public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) => _inbound.TryGetSnapshot(guid, out spawn); + internal bool TryGetAcceptedTimestamps( + uint guid, + out AcceptedPhysicsTimestamps timestamps) => + _inbound.TryGetAcceptedTimestamps(guid, out timestamps); + public bool TryGetActive(uint guid, out RuntimeEntityRecord record) => _activeByGuid.TryGetValue(guid, out record!); @@ -482,6 +492,58 @@ public sealed class RuntimeEntityDirectory out accepted, out timestamps); + internal bool TryAcceptDeferredPosition( + WorldSession.EntityPositionUpdate update, + bool isLocalPlayer, + out PositionTimestampDisposition disposition, + out AcceptedPhysicsTimestamps timestamps, + out bool hasTimestampMutation) => + _inbound.TryAcceptDeferredPosition( + update, + isLocalPlayer, + out disposition, + out timestamps, + out hasTimestampMutation); + + internal bool TryAcceptDeferredObjDesc( + ObjDescEvent.Parsed update, + out AcceptedPhysicsTimestamps timestamps) => + _inbound.TryAcceptDeferredObjDesc(update, out timestamps); + + internal bool TryAcceptDeferredPickup( + PickupEvent.Parsed update, + out AcceptedPhysicsTimestamps timestamps) => + _inbound.TryAcceptDeferredPickup(update, out timestamps); + + internal bool TryAcceptDeferredCreateParent( + CreateParentUpdate update, + out AcceptedPhysicsTimestamps timestamps) => + _inbound.TryAcceptDeferredCreateParent(update, out timestamps); + + internal bool TryAcceptDeferredParent( + ParentEvent.Parsed update, + out AcceptedPhysicsTimestamps timestamps) => + _inbound.TryAcceptDeferredParent(update, out timestamps); + + internal bool TryAcceptDeferredMotion( + WorldSession.EntityMotionUpdate update, + out AcceptedPhysicsTimestamps timestamps, + out bool hasTimestampMutation) => + _inbound.TryAcceptDeferredMotion( + update, + out timestamps, + out hasTimestampMutation); + + internal bool TryAcceptDeferredState( + SetState.Parsed update, + out AcceptedPhysicsTimestamps timestamps) => + _inbound.TryAcceptDeferredState(update, out timestamps); + + internal bool TryAcceptDeferredVector( + VectorUpdate.Parsed update, + out AcceptedPhysicsTimestamps timestamps) => + _inbound.TryAcceptDeferredVector(update, out timestamps); + public bool IsFreshTeleportStart(uint guid, ushort teleportSequence) => _inbound.IsFreshTeleportStart(guid, teleportSequence); diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 3c377d6c..a238d27a 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; @@ -11,7 +12,8 @@ public readonly record struct RuntimeEntityRegistrationResult( RuntimeEntityRecord? Canonical, bool LogicalRegistrationCreated, bool ReplacedExistingGeneration, - Exception? PriorGenerationCleanupFailure = null); + Exception? PriorGenerationCleanupFailure = null, + bool DeferredForParent = false); public readonly record struct RuntimeEntityObjectOwnershipSnapshot( int ActiveEntityCount, @@ -19,6 +21,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( int ClaimedLocalIdCount, int AcceptedSnapshotCount, int UnresolvedParentRelationCount, + int DeferredParentCreateCount, int StagedParentRelationCount, int RecoveryParentRelationCount, int CommittedParentRelationCount, @@ -44,6 +47,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && ClaimedLocalIdCount == 0 && AcceptedSnapshotCount == 0 && UnresolvedParentRelationCount == 0 + && DeferredParentCreateCount == 0 && StagedParentRelationCount == 0 && RecoveryParentRelationCount == 0 && CommittedParentRelationCount == 0 @@ -205,6 +209,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.ClaimedLocalIdCount, Entities.Snapshots.Count, parents.UnresolvedRelationCount, + parents.DeferredCreateCount, parents.StagedRelationCount, parents.RecoveryRelationCount, parents.CommittedRelationCount, @@ -272,6 +277,35 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable throw new InvalidOperationException( "A Runtime entity cannot register while its session lifetime is clearing."); } + if (beginInitialResidence + && !HasConsistentCreateIdentityAndParent(incoming)) + { + throw new InvalidOperationException( + $"CreateObject 0x{incoming.Guid:X8} has inconsistent instance or parent projections."); + } + if (beginInitialResidence) + incoming = RuntimeInitialCreateAdmissionFreezer.Freeze(incoming); + uint parentGuid = incoming.ParentGuid + ?? incoming.Physics?.Parent?.Guid + ?? 0u; + if (beginInitialResidence + && parentGuid != 0u + && !Entities.TryGetActive(parentGuid, out _)) + { + // SmartBox::HandleCreateObject resolves a nonzero parent before + // object lookup or timestamp admission. Retain the complete raw + // CreateObject so no child gate/canonical/projection state can + // escape before the parent becomes addressable. + Entities.ParentAttachments.EnqueueDeferredCreate( + incoming, + isLocalPlayer); + return new RuntimeEntityRegistrationResult( + SupersededCreateResult(), + Canonical: null, + LogicalRegistrationCreated: false, + ReplacedExistingGeneration: false, + DeferredForParent: true); + } CreateObjectTimestampDisposition preview = Entities.PreviewCreateDisposition(incoming); bool requiresFreshResidenceAdmission = preview is @@ -285,7 +319,44 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable $"CreateObject 0x{incoming.Guid:X8} cannot acquire a structurally valid initial residence lease."); } - InboundCreateResult result = Entities.AcceptCreate(incoming); + RuntimeEntityRecord? pendingResidenceRecord = null; + RuntimeInitialCreateResidenceLease pendingResidence = default; + bool admitIntoPendingResidence = beginInitialResidence + && preview is CreateObjectTimestampDisposition.ExistingGeneration + && Entities.TryGetActive( + incoming.Guid, + out pendingResidenceRecord) + && InitialCreateResidences.TryGetTransaction( + pendingResidenceRecord, + out pendingResidence); + if (admitIntoPendingResidence + && !InitialCreateResidences.CanEnqueue( + pendingResidenceRecord!, + pendingResidence)) + { + throw new InvalidOperationException( + $"CreateObject 0x{incoming.Guid:X8} cannot append to its pending initial residence FIFO."); + } + if (admitIntoPendingResidence + && !IsStructurallyValidDeferredCreate(incoming)) + { + _ = Entities.TryGetAcceptedTimestamps( + incoming.Guid, + out AcceptedPhysicsTimestamps timestamps); + return new RuntimeEntityRegistrationResult( + new InboundCreateResult( + CreateObjectTimestampDisposition.ExistingGeneration, + pendingResidenceRecord!.Snapshot, + SameGenerationEvents: null, + timestamps), + pendingResidenceRecord, + LogicalRegistrationCreated: false, + ReplacedExistingGeneration: false); + } + + InboundCreateResult result = admitIntoPendingResidence + ? Entities.AcceptCreateDeferredSameGeneration(incoming) + : Entities.AcceptCreate(incoming); if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration) { @@ -307,6 +378,33 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable incoming.Guid, out RuntimeEntityRecord retained)) { + if (admitIntoPendingResidence) + { + if (!ReferenceEquals(retained, pendingResidenceRecord) + || !AdmitSameGenerationCreate( + retained, + pendingResidence, + incoming, + result, + isLocalPlayer)) + { + throw FailInitialResidenceRegistration( + retained, + publishDeleted: true); + } + + InboundCreateResult dormant = result with + { + Snapshot = retained.Snapshot, + SameGenerationEvents = null, + }; + return new RuntimeEntityRegistrationResult( + dormant, + retained, + LogicalRegistrationCreated: false, + ReplacedExistingGeneration: false); + } + // Existing-generation CreateObject contributes untimestamped // description fields here. Position/Parent/Pickup/State/etc. // remain separate freshness-gated events and must not churn a @@ -592,6 +690,33 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable out WorldSession.EntitySpawn accepted) { EnsureNotDisposed(); + if (TryGetPendingInitialResidence( + update.Guid, + out RuntimeEntityRecord pending, + out RuntimeInitialCreateResidenceLease lease)) + { + if (!InitialCreateResidences.CanEnqueue(pending, lease)) + { + accepted = pending.Snapshot; + return false; + } + bool acceptedByGate = Entities.TryAcceptDeferredObjDesc( + update, + out _); + accepted = pending.Snapshot; + if (!acceptedByGate) + return false; + EnqueueDormant( + pending, + lease, + RuntimeInitialCreateContinuationKind.ObjDesc, + RuntimeAcceptedPositionSource.Unknown, + new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.ObjDesc, + update.Guid, + ObjDesc: update)); + return true; + } bool applied = Entities.TryApplyObjDesc(update, out accepted); if (!applied || !Entities.TryGetActive( @@ -617,6 +742,33 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable out WorldSession.EntitySpawn accepted) { EnsureNotDisposed(); + if (TryGetPendingInitialResidence( + update.Guid, + out RuntimeEntityRecord pending, + out RuntimeInitialCreateResidenceLease lease)) + { + if (!InitialCreateResidences.CanEnqueue(pending, lease)) + { + accepted = pending.Snapshot; + return false; + } + bool acceptedByGate = Entities.TryAcceptDeferredPickup( + update, + out _); + accepted = pending.Snapshot; + if (!acceptedByGate) + return false; + EnqueueDormant( + pending, + lease, + RuntimeInitialCreateContinuationKind.Pickup, + RuntimeAcceptedPositionSource.Unknown, + new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.Pickup, + update.Guid, + Pickup: update)); + return true; + } bool applied = Entities.TryApplyPickup(update, out accepted); if (!applied || !Entities.TryGetActive( @@ -655,6 +807,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable out WorldSession.EntitySpawn accepted) { EnsureNotDisposed(); + if (TryGetPendingInitialResidence( + update.ChildGuid, + out _, + out _)) + { + throw new InvalidOperationException( + "A CreateObject parent relation must be admitted inside its atomic same-generation Create envelope."); + } bool applied = Entities.TryApplyCreateParent(update, out accepted); return CommitPositionChannelUpdate( applied, @@ -669,6 +829,44 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable out WorldSession.EntitySpawn accepted) { EnsureNotDisposed(); + if (TryGetPendingInitialResidence( + update.ChildGuid, + out RuntimeEntityRecord pending, + out RuntimeInitialCreateResidenceLease lease)) + { + if (!InitialCreateResidences.CanEnqueue(pending, lease)) + { + accepted = pending.Snapshot; + return false; + } + if (!Entities.TryGetActive( + update.ParentGuid, + out RuntimeEntityRecord parent) + || parent.Incarnation != update.ParentInstanceSequence) + { + Entities.ParentAttachments.Enqueue(update); + accepted = pending.Snapshot; + return false; + } + + bool acceptedByGate = Entities.TryAcceptDeferredParent( + update, + out AcceptedPhysicsTimestamps timestamps); + accepted = pending.Snapshot; + if (!acceptedByGate) + return false; + EnqueueDormant( + pending, + lease, + RuntimeInitialCreateContinuationKind.Parent, + RuntimeAcceptedPositionSource.Unknown, + new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.Parent, + update.ChildGuid, + Parent: update, + AcceptedTimestamps: timestamps)); + return true; + } bool applied = Entities.TryApplyParent(update, out accepted); return CommitPositionChannelUpdate( applied, @@ -750,6 +948,39 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable out AcceptedPhysicsTimestamps timestamps) { EnsureNotDisposed(); + if (TryGetPendingInitialResidence( + update.Guid, + out RuntimeEntityRecord pending, + out RuntimeInitialCreateResidenceLease lease)) + { + if (!InitialCreateResidences.CanEnqueue(pending, lease)) + { + accepted = pending.Snapshot; + timestamps = default; + return false; + } + bool payloadApplied = Entities.TryAcceptDeferredMotion( + update, + out timestamps, + out bool timestampMutation); + accepted = pending.Snapshot; + if (!payloadApplied && !timestampMutation) + return false; + EnqueueDormant( + pending, + lease, + RuntimeInitialCreateContinuationKind.Movement, + RuntimeAcceptedPositionSource.Unknown, + new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.Movement, + update.Guid, + Movement: update, + AcceptedTimestamps: timestamps, + AppliesMovementPayload: payloadApplied, + RetainMovementPayload: retainPayload, + HasTimestampMutation: timestampMutation)); + return payloadApplied; + } bool applied = Entities.TryApplyMotion( update, retainPayload, @@ -790,6 +1021,35 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable out WorldSession.EntitySpawn accepted) { EnsureNotDisposed(); + if (TryGetPendingInitialResidence( + update.Guid, + out RuntimeEntityRecord pending, + out RuntimeInitialCreateResidenceLease lease)) + { + if (!IsFinite(update.Velocity) + || !IsFinite(update.Omega) + || !InitialCreateResidences.CanEnqueue(pending, lease)) + { + accepted = pending.Snapshot; + return false; + } + bool acceptedByGate = Entities.TryAcceptDeferredVector( + update, + out _); + accepted = pending.Snapshot; + if (!acceptedByGate) + return false; + EnqueueDormant( + pending, + lease, + RuntimeInitialCreateContinuationKind.Vector, + RuntimeAcceptedPositionSource.Unknown, + new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.Vector, + update.Guid, + Vector: update)); + return true; + } bool applied = Entities.TryApplyVector(update, out accepted); if (!applied || !Entities.TryGetActive( @@ -817,6 +1077,35 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable out RetailPhysicsStateTransition transition) { EnsureNotDisposed(); + if (TryGetPendingInitialResidence( + update.Guid, + out RuntimeEntityRecord pending, + out RuntimeInitialCreateResidenceLease lease)) + { + if (!InitialCreateResidences.CanEnqueue(pending, lease)) + { + accepted = pending.Snapshot; + transition = default; + return false; + } + bool acceptedByGate = Entities.TryAcceptDeferredState( + update, + out _); + accepted = pending.Snapshot; + transition = default; + if (!acceptedByGate) + return false; + EnqueueDormant( + pending, + lease, + RuntimeInitialCreateContinuationKind.State, + RuntimeAcceptedPositionSource.Unknown, + new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.State, + update.Guid, + State: update)); + return true; + } bool applied = Entities.TryApplyState(update, out accepted); transition = default; if (!applied @@ -883,24 +1172,58 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable out AcceptedPhysicsTimestamps timestamps) { EnsureNotDisposed(); - RuntimeInitialCreateResidenceLease priorInitialResidence = default; - bool hadPendingInitialResidence = - Entities.TryGetActive( + if (TryGetPendingInitialResidence( update.Guid, - out RuntimeEntityRecord pendingCanonical) - && InitialCreateResidences.TryGetTransaction( - pendingCanonical, - out priorInitialResidence); - if (hadPendingInitialResidence - && !InitialCreateResidences.CanEnqueueAcceptedPosition( - pendingCanonical, - priorInitialResidence, - update)) + out RuntimeEntityRecord pendingCanonical, + out RuntimeInitialCreateResidenceLease pendingLease)) { - disposition = PositionTimestampDisposition.Rejected; - accepted = default; - timestamps = default; - return false; + if (!RuntimeAuthoritativePositionRouteClassifier + .IsValidCreateWirePosition(update.Position) + || update.Velocity is { } velocity + && !IsFinite(velocity) + || !InitialCreateResidences.CanEnqueue( + pendingCanonical, + pendingLease)) + { + disposition = PositionTimestampDisposition.Rejected; + accepted = default; + timestamps = default; + return false; + } + + bool deferredKnown = Entities.TryAcceptDeferredPosition( + update, + isLocalPlayer, + out disposition, + out timestamps, + out bool timestampMutation); + accepted = pendingCanonical.Snapshot; + if (!deferredKnown) + return false; + + if (disposition is PositionTimestampDisposition.Rejected + && !timestampMutation) + { + return true; + } + + EnqueueDormant( + pendingCanonical, + pendingLease, + RuntimeInitialCreateContinuationKind.Position, + RuntimeAcceptedPositionSource.PositionEvent, + new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.Position, + update.Guid, + Position: update, + PositionSource: + RuntimeAcceptedPositionSource.PositionEvent, + PositionDisposition: disposition, + PreviousTeleportSequence: + timestamps.PreviousTeleport, + AcceptedTimestamps: timestamps, + HasTimestampMutation: timestampMutation)); + return true; } bool hadCanonical = Entities.TryGetActive( update.Guid, @@ -938,43 +1261,6 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable || projectionRequiresTeleportHook, }; } - - - // Initial CreateObject residence is one immutable transaction. A - // Position packet accepted before its 0x11 placement completes is - // retained in the transaction's ordered continuation batch; it must - // not replace the initial mover operation, mutate the Runtime record - // used to prepare that operation, or escape through a host callback. - if (hadPendingInitialResidence) - { - if (!acceptedPosition) - return true; - if (!ReferenceEquals(canonical, pendingCanonical)) - throw FailInitialResidenceRegistration( - canonical, - publishDeleted: true); - - RuntimeInitialCreateResidenceLease retained = - InitialCreateResidences.EnqueueAcceptedPosition( - canonical, - priorInitialResidence, - update, - accepted, - disposition, - timestamps, - isLocalPlayer, - forcePositionRotation, - currentLocalVelocity, - projectionRequiresTeleportHook); - if (!retained.IsValid) - { - throw FailInitialResidenceRegistration( - canonical, - publishDeleted: true); - } - return true; - } - RuntimePlacementCancellationReceipt cancellation = default; if (acceptedPosition) { @@ -1118,6 +1404,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable out RuntimeEntityDeleteAcceptance acceptance) { EnsureNotDisposed(); + if (!isLocalPlayer) + { + // A child whose complete raw CreateObject is waiting on a missing + // parent has no timestamp gate yet. Cancel its exact/older raw + // generation before the normal known-object delete gate returns. + Entities.ParentAttachments.CancelDeferredChildGeneration( + delete.Guid, + delete.InstanceSequence); + } if (!Entities.TryDelete(delete, isLocalPlayer)) { acceptance = null!; @@ -1414,6 +1709,288 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.IsCurrent(canonical) && matchesCommittedMutation(); + private bool TryGetPendingInitialResidence( + uint guid, + out RuntimeEntityRecord canonical, + out RuntimeInitialCreateResidenceLease lease) + { + if (Entities.TryGetActive(guid, out canonical) + && InitialCreateResidences.TryGetTransaction( + canonical, + out lease)) + { + return true; + } + + canonical = null!; + lease = default; + return false; + } + + private void EnqueueDormant( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceLease prior, + RuntimeInitialCreateContinuationKind kind, + RuntimeAcceptedPositionSource positionSource, + in RuntimeInitialCreateTailAction action) + { + RuntimeInitialCreateResidenceLease retained = InitialCreateResidences + .EnqueueAccepted( + canonical, + prior, + kind, + positionSource, + ImmutableArray.Create(action)); + if (!retained.IsValid) + { + throw new InvalidOperationException( + $"Accepted {kind} for 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} could not be retained by its initial-placement FIFO."); + } + } + + private static bool IsFinite(System.Numerics.Vector3 value) => + float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Z); + + private static bool HasConsistentCreateIdentityAndParent( + in WorldSession.EntitySpawn incoming) + { + if (incoming.Guid == 0u) + return false; + + bool hasTopParentGuid = incoming.ParentGuid is not null; + bool hasTopParentLocation = incoming.ParentLocation is not null; + if (hasTopParentGuid != hasTopParentLocation) + return false; + + if (incoming.Physics is not { } physics) + { + // These flattened values are parser projections of PhysicsDesc. + // Without that source block, accepting any of them would create + // contradictory admission authority before the raw create is + // either queued for a parent or assigned a residence lease. + return incoming.Position is null + && incoming.SetupTableId is null + && incoming.MotionState is null + && incoming.MotionTableId is null + && incoming.PhysicsState is null + && incoming.ObjScale is null + && incoming.Friction is null + && incoming.Elasticity is null + && incoming.InstanceSequence == 0 + && incoming.MovementSequence == 0 + && incoming.ServerControlSequence == 0 + && incoming.PositionSequence == 0 + && !hasTopParentGuid + && incoming.PlacementId is null; + } + if (physics.Timestamps.Instance != incoming.InstanceSequence + || physics.Timestamps.Position != incoming.PositionSequence + || physics.Timestamps.Movement != incoming.MovementSequence + || physics.Timestamps.ServerControlledMove + != incoming.ServerControlSequence + || physics.Position != incoming.Position) + return false; + + PhysicsAttachment? flattenedParent = incoming.ParentGuid is { } parentGuid + && incoming.ParentLocation is { } parentLocation + ? new PhysicsAttachment(parentGuid, parentLocation) + : null; + if (flattenedParent != physics.Parent + || incoming.PlacementId != physics.AnimationFrame) + { + return false; + } + return true; + } + + private static bool IsStructurallyValidDeferredCreate( + in WorldSession.EntitySpawn incoming) + { + if (incoming.Guid == 0u) + return false; + if (incoming.Physics is not { } physics) + return true; + if (physics.Parent is null + && physics.Position is { LandblockId: not 0u } position + && !RuntimeAuthoritativePositionRouteClassifier + .IsValidCreateWirePosition(position)) + { + return false; + } + if (physics.Velocity is { } velocity && !IsFinite(velocity) + || physics.Acceleration is { } acceleration + && !IsFinite(acceleration) + || physics.AngularVelocity is { } angularVelocity + && !IsFinite(angularVelocity) + || physics.Scale is { } scale && !float.IsFinite(scale) + || physics.Friction is { } friction && !float.IsFinite(friction) + || physics.Elasticity is { } elasticity + && !float.IsFinite(elasticity) + || physics.Translucency is { } translucency + && !float.IsFinite(translucency)) + { + return false; + } + return true; + } + + private bool AdmitSameGenerationCreate( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceLease prior, + in WorldSession.EntitySpawn incoming, + in InboundCreateResult admitted, + bool isLocalPlayer) + { + if (!ReferenceEquals( + canonical, + Entities.TryGetActive( + incoming.Guid, + out RuntimeEntityRecord current) + ? current + : null) + || canonical.Incarnation != incoming.InstanceSequence + || admitted.Disposition + is not CreateObjectTimestampDisposition.ExistingGeneration + || !InitialCreateResidences.CanEnqueue(canonical, prior)) + { + return false; + } + + var actions = ImmutableArray.CreateBuilder< + RuntimeInitialCreateTailAction>(); + RuntimeAcceptedPositionSource positionSource = + RuntimeAcceptedPositionSource.Unknown; + + if (admitted.SameGenerationEvents is { } events) + { + actions.Add(new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind + .PreTailDescriptionAdaptation, + incoming.Guid, + Description: events.Description)); + + if (Entities.TryAcceptDeferredObjDesc( + events.Appearance, + out _)) + { + actions.Add(new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.ObjDesc, + incoming.Guid, + ObjDesc: events.Appearance)); + } + + if (events.Parent is { } parent) + { + if (Entities.TryAcceptDeferredCreateParent( + parent, + out _)) + { + actions.Add(new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.CreateParent, + incoming.Guid, + CreateParent: parent)); + } + } + else if (events.Position is { } position) + { + if (!RuntimeAuthoritativePositionRouteClassifier + .IsValidCreateWirePosition(position.Position) + || position.Velocity is { } velocity + && !IsFinite(velocity)) + { + return false; + } + + if (!Entities.TryAcceptDeferredPosition( + position, + isLocalPlayer, + out PositionTimestampDisposition disposition, + out AcceptedPhysicsTimestamps timestamps, + out bool timestampMutation)) + { + return false; + } + if (disposition is not PositionTimestampDisposition.Rejected + || timestampMutation) + { + positionSource = RuntimeAcceptedPositionSource + .SameIncarnationCreate; + actions.Add(new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.Position, + incoming.Guid, + Position: position, + PositionSource: positionSource, + PositionDisposition: disposition, + PreviousTeleportSequence: + timestamps.PreviousTeleport, + AcceptedTimestamps: timestamps, + HasTimestampMutation: timestampMutation)); + } + } + else if (events.Pickup is { } pickup + && Entities.TryAcceptDeferredPickup(pickup, out _)) + { + actions.Add(new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.Pickup, + incoming.Guid, + Pickup: pickup)); + } + + if (events.Movement is { } movement) + { + bool payloadApplied = Entities.TryAcceptDeferredMotion( + movement, + out AcceptedPhysicsTimestamps timestamps, + out bool timestampMutation); + if (payloadApplied || timestampMutation) + { + actions.Add(new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.Movement, + incoming.Guid, + Movement: movement, + AcceptedTimestamps: timestamps, + AppliesMovementPayload: payloadApplied, + RetainMovementPayload: true, + HasTimestampMutation: timestampMutation)); + } + } + + if (Entities.TryAcceptDeferredState(events.State, out _)) + { + actions.Add(new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.State, + incoming.Guid, + State: events.State)); + } + if (Entities.TryAcceptDeferredVector(events.Vector, out _)) + { + actions.Add(new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.Vector, + incoming.Guid, + Vector: events.Vector)); + } + } + + actions.Add(new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.WeenieDescription, + incoming.Guid, + WeenieDescription: incoming)); + actions.Add(new RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind.ResidentCellCleanup, + incoming.Guid)); + + RuntimeInitialCreateResidenceLease retained = InitialCreateResidences + .EnqueueAccepted( + canonical, + prior, + RuntimeInitialCreateContinuationKind.SameIncarnationCreate, + positionSource, + actions.ToImmutable()); + return retained.IsValid; + } + private void EnsureNotDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateAdmissionFreezer.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateAdmissionFreezer.cs new file mode 100644 index 00000000..d63ecedf --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateAdmissionFreezer.cs @@ -0,0 +1,92 @@ +using System.Collections.Immutable; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.Runtime.Entities; + +/// +/// Takes ownership of decoded wire collections retained beyond the inbound +/// callback. The protocol records expose read-only views, but those views may +/// still wrap parser-owned arrays. Initial-placement admission must therefore +/// copy every collection before an asynchronous residence lease can retain it. +/// +internal static class RuntimeInitialCreateAdmissionFreezer +{ + internal static WorldSession.EntitySpawn Freeze( + in WorldSession.EntitySpawn spawn) => spawn with + { + AnimPartChanges = spawn.AnimPartChanges.ToImmutableArray(), + TextureChanges = spawn.TextureChanges.ToImmutableArray(), + SubPalettes = spawn.SubPalettes.ToImmutableArray(), + MotionState = Freeze(spawn.MotionState), + Physics = Freeze(spawn.Physics), + }; + + internal static ObjDescEvent.Parsed Freeze( + in ObjDescEvent.Parsed update) => update with + { + ModelData = Freeze(update.ModelData), + }; + + internal static WorldSession.EntityMotionUpdate Freeze( + in WorldSession.EntityMotionUpdate update) => update with + { + MotionState = Freeze(update.MotionState), + }; + + internal static RuntimeInitialCreateTailAction Freeze( + in RuntimeInitialCreateTailAction action) => action with + { + Description = Freeze(action.Description), + ObjDesc = action.ObjDesc is { } objDesc + ? Freeze(objDesc) + : null, + Movement = action.Movement is { } movement + ? Freeze(movement) + : null, + WeenieDescription = action.WeenieDescription is { } spawn + ? Freeze(spawn) + : null, + }; + + private static CreateObject.ModelData Freeze( + in CreateObject.ModelData model) => model with + { + SubPalettes = model.SubPalettes.ToImmutableArray(), + TextureChanges = model.TextureChanges.ToImmutableArray(), + AnimPartChanges = model.AnimPartChanges.ToImmutableArray(), + }; + + private static CreateObject.ServerMotionState Freeze( + in CreateObject.ServerMotionState motion) => motion with + { + Commands = motion.Commands?.ToImmutableArray(), + }; + + private static CreateObject.ServerMotionState? Freeze( + CreateObject.ServerMotionState? motion) => motion is { } value + ? Freeze(value) + : null; + + private static PhysicsSpawnData? Freeze(PhysicsSpawnData? physics) + { + if (physics is not { } value) + return null; + + PhysicsMovementData? movement = value.Movement is { } source + ? source with + { + RawData = source.RawData.ToArray(), + MotionState = Freeze(source.MotionState), + } + : null; + ReadOnlyMemory? children = value.Children is { } list + ? list.ToArray() + : null; + return value with + { + Movement = movement, + Children = children, + }; + } +} diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs index f35fa9f5..c8e1388c 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs @@ -30,11 +30,14 @@ internal readonly record struct RuntimeInitialCreateResidenceLease( RuntimeInitialCreateResidenceToken Token, RuntimeAuthoritativePositionRoute Route, RuntimeEntityPlacementToken Placement, + WorldSession.EntitySpawn InitialCreate, ImmutableArray Continuations) { internal bool IsValid => Token.IsValid && Route.Accepted && Route.Authority.Entity == Token.Entity + && InitialCreate.Guid != 0u + && InitialCreate.InstanceSequence == Token.Entity.Incarnation && !Continuations.IsDefault && HasValidContinuationChain() && (!Route.PerformsSetPosition @@ -43,7 +46,7 @@ internal readonly record struct RuntimeInitialCreateResidenceLease( private bool HasValidContinuationChain() { - ushort previousTeleport = Route.Authority.AcceptedTeleportSequence; + uint ownerGuid = InitialCreate.Guid; for (int index = 0; index < Continuations.Length; index++) { RuntimeInitialCreateResidenceContinuation continuation = @@ -51,52 +54,291 @@ internal readonly record struct RuntimeInitialCreateResidenceLease( if (!continuation.IsValid || continuation.Sequence != (ulong)index + 1UL || continuation.InstanceSequence != Token.Entity.Incarnation - || continuation.PositionAuthorityVersion - != Token.PositionAuthorityVersion - || continuation.PreviousTeleportSequence - != previousTeleport) + || continuation.Actions.Any( + action => action.OwnerGuid != ownerGuid)) { return false; } - previousTeleport = continuation.AcceptedTeleportSequence; } return true; } } +internal enum RuntimeInitialCreateContinuationKind : byte +{ + SameIncarnationCreate, + ObjDesc, + Parent, + Pickup, + Position, + Movement, + State, + Vector, +} + +internal enum RuntimeInitialCreateTailActionKind : byte +{ + /// + /// Isolated AP-119 compatibility behavior. Retail's equal-generation + /// Create tail starts at ObjDesc and does not call set_description again. + /// + PreTailDescriptionAdaptation, + ObjDesc, + CreateParent, + Parent, + Pickup, + Position, + Movement, + State, + Vector, + WeenieDescription, + ResidentCellCleanup, +} + +internal readonly record struct RuntimeInitialCreateTailAction( + RuntimeInitialCreateTailActionKind Kind, + uint OwnerGuid, + PhysicsSpawnData? Description = null, + ObjDescEvent.Parsed? ObjDesc = null, + CreateParentUpdate? CreateParent = null, + ParentEvent.Parsed? Parent = null, + PickupEvent.Parsed? Pickup = null, + WorldSession.EntityPositionUpdate? Position = null, + WorldSession.EntityMotionUpdate? Movement = null, + SetState.Parsed? State = null, + VectorUpdate.Parsed? Vector = null, + WorldSession.EntitySpawn? WeenieDescription = null, + RuntimeAcceptedPositionSource PositionSource = + RuntimeAcceptedPositionSource.Unknown, + PositionTimestampDisposition PositionDisposition = + PositionTimestampDisposition.Rejected, + ushort PreviousTeleportSequence = 0, + AcceptedPhysicsTimestamps AcceptedTimestamps = default, + bool AppliesMovementPayload = false, + bool RetainMovementPayload = true, + bool HasTimestampMutation = false) +{ + internal bool IsStructurallyValid => HasExclusivePayload() + && Kind switch + { + RuntimeInitialCreateTailActionKind.PreTailDescriptionAdaptation => + Description is not null, + RuntimeInitialCreateTailActionKind.ObjDesc => ObjDesc is { } objDesc + && objDesc.Guid == OwnerGuid, + RuntimeInitialCreateTailActionKind.CreateParent => + CreateParent is { } createParent + && createParent.ChildGuid == OwnerGuid, + RuntimeInitialCreateTailActionKind.Parent => Parent is { } parent + && parent.ChildGuid == OwnerGuid, + RuntimeInitialCreateTailActionKind.Pickup => Pickup is { } pickup + && pickup.Guid == OwnerGuid, + RuntimeInitialCreateTailActionKind.Position => Position is { } position + && position.Guid == OwnerGuid + && PositionSource is RuntimeAcceptedPositionSource.PositionEvent + or RuntimeAcceptedPositionSource.SameIncarnationCreate + && (PositionDisposition is PositionTimestampDisposition.Apply + or PositionTimestampDisposition.ForcePosition + || PositionDisposition is PositionTimestampDisposition.Rejected + && HasTimestampMutation), + RuntimeInitialCreateTailActionKind.Movement => Movement is { } movement + && movement.Guid == OwnerGuid + && (AppliesMovementPayload || HasTimestampMutation), + RuntimeInitialCreateTailActionKind.State => State is { } state + && state.Guid == OwnerGuid, + RuntimeInitialCreateTailActionKind.Vector => Vector is { } vector + && vector.Guid == OwnerGuid, + RuntimeInitialCreateTailActionKind.WeenieDescription => + WeenieDescription is { } weenie + && weenie.Guid == OwnerGuid, + RuntimeInitialCreateTailActionKind.ResidentCellCleanup => true, + _ => false, + }; + + internal bool MatchesInstance(ushort instanceSequence) => Kind switch + { + RuntimeInitialCreateTailActionKind.PreTailDescriptionAdaptation => + Description is { } description + && description.Timestamps.Instance == instanceSequence, + RuntimeInitialCreateTailActionKind.ObjDesc => + ObjDesc is { } objDesc + && objDesc.InstanceSequence == instanceSequence, + RuntimeInitialCreateTailActionKind.CreateParent => + CreateParent is { } createParent + && createParent.ChildInstanceSequence == instanceSequence, + // ParentEvent carries only the parent's INSTANCE_TS. Acceptance + // already exact-gated the child's current incarnation; preserve that + // proof in AcceptedTimestamps for the later no-regate executor. + RuntimeInitialCreateTailActionKind.Parent => + AcceptedTimestamps.Instance == instanceSequence, + RuntimeInitialCreateTailActionKind.Pickup => + Pickup is { } pickup + && pickup.InstanceSequence == instanceSequence, + RuntimeInitialCreateTailActionKind.Position => + Position is { } position + && position.InstanceSequence == instanceSequence, + RuntimeInitialCreateTailActionKind.Movement => + Movement is { } movement + && movement.InstanceSequence == instanceSequence, + RuntimeInitialCreateTailActionKind.State => + State is { } state + && state.InstanceSequence == instanceSequence, + RuntimeInitialCreateTailActionKind.Vector => + Vector is { } vector + && vector.InstanceSequence == instanceSequence, + RuntimeInitialCreateTailActionKind.WeenieDescription => + WeenieDescription is { } weenie + && weenie.InstanceSequence == instanceSequence, + RuntimeInitialCreateTailActionKind.ResidentCellCleanup => true, + _ => false, + }; + + private bool HasExclusivePayload() + { + int payloadCount = (Description is null ? 0 : 1) + + (ObjDesc is null ? 0 : 1) + + (CreateParent is null ? 0 : 1) + + (Parent is null ? 0 : 1) + + (Pickup is null ? 0 : 1) + + (Position is null ? 0 : 1) + + (Movement is null ? 0 : 1) + + (State is null ? 0 : 1) + + (Vector is null ? 0 : 1) + + (WeenieDescription is null ? 0 : 1); + return Kind is RuntimeInitialCreateTailActionKind.ResidentCellCleanup + ? payloadCount == 0 + : payloadCount == 1; + } +} + /// -/// One accepted Position packet which arrived before the immutable initial -/// CreateObject admission completed. These records remain dormant until a -/// host adopts the completed batch; accepting them never replaces or mutates -/// the initial 0x11 SetPosition operation. +/// One immutable inbound envelope which arrived while retail's synchronous +/// CreateObject critical section was virtualized by an asynchronous initial +/// SetPosition. Envelopes are never coalesced. A same-incarnation Create is +/// one atomic envelope whose ordered tail is expanded only by the executor. +/// Position retains the raw PositionPack and is classified only at the FIFO +/// head, after every earlier envelope has committed. /// internal readonly record struct RuntimeInitialCreateResidenceContinuation( ulong Sequence, - RuntimePositionEntityKind EntityKind, + RuntimeInitialCreateContinuationKind Kind, ushort InstanceSequence, - ushort PositionSequence, - ushort PreviousTeleportSequence, - ushort TeleportSequence, - ushort ForcePositionSequence, - ushort AcceptedTeleportSequence, - ushort AcceptedForcePositionSequence, - PositionTimestampDisposition TimestampDisposition, - CreateObject.ServerPosition AcceptedWirePosition, - Vector3? PositionPackVelocity, - Vector3? AcceptedVelocity, - Quaternion? ForcePositionRotation, - Vector3? CurrentLocalVelocity, - bool ProjectionRequiresTeleportHook, - uint PlacementFrame, - ulong PositionAuthorityVersion) + RuntimeAcceptedPositionSource PositionSource, + ImmutableArray Actions) { internal bool IsValid => Sequence != 0UL - && EntityKind is RuntimePositionEntityKind.LocalPlayer - or RuntimePositionEntityKind.Remote - or RuntimePositionEntityKind.Projectile - && PositionAuthorityVersion != 0UL - && TimestampDisposition is PositionTimestampDisposition.Apply - or PositionTimestampDisposition.ForcePosition; + && !Actions.IsDefaultOrEmpty + && Actions.All(static action => action.IsStructurallyValid) + && HasMatchingInstances() + && HasValidShape(); + + private bool HasMatchingInstances() + { + for (int index = 0; index < Actions.Length; index++) + { + if (!Actions[index].MatchesInstance(InstanceSequence)) + return false; + } + return true; + } + + private bool HasValidShape() + { + if (Kind is not RuntimeInitialCreateContinuationKind.SameIncarnationCreate) + { + if (Actions.Length != 1) + return false; + RuntimeInitialCreateTailAction action = Actions[0]; + RuntimeInitialCreateTailActionKind expected = Kind switch + { + RuntimeInitialCreateContinuationKind.ObjDesc => + RuntimeInitialCreateTailActionKind.ObjDesc, + RuntimeInitialCreateContinuationKind.Parent => + RuntimeInitialCreateTailActionKind.Parent, + RuntimeInitialCreateContinuationKind.Pickup => + RuntimeInitialCreateTailActionKind.Pickup, + RuntimeInitialCreateContinuationKind.Position => + RuntimeInitialCreateTailActionKind.Position, + RuntimeInitialCreateContinuationKind.Movement => + RuntimeInitialCreateTailActionKind.Movement, + RuntimeInitialCreateContinuationKind.State => + RuntimeInitialCreateTailActionKind.State, + RuntimeInitialCreateContinuationKind.Vector => + RuntimeInitialCreateTailActionKind.Vector, + _ => throw new InvalidOperationException( + $"Unsupported initial-Create continuation kind {Kind}."), + }; + return action.Kind == expected + && (Kind is RuntimeInitialCreateContinuationKind.Position + ? PositionSource is RuntimeAcceptedPositionSource.PositionEvent + && action.PositionSource == PositionSource + : PositionSource is RuntimeAcceptedPositionSource.Unknown); + } + + if (Actions.Length < 2 + || Actions[^2].Kind + is not RuntimeInitialCreateTailActionKind.WeenieDescription + || Actions[^1].Kind + is not RuntimeInitialCreateTailActionKind.ResidentCellCleanup) + { + return false; + } + + bool hasPosition = Actions.Any( + static action => action.Kind + is RuntimeInitialCreateTailActionKind.Position); + if (hasPosition + ? PositionSource + is not RuntimeAcceptedPositionSource.SameIncarnationCreate + : PositionSource is not RuntimeAcceptedPositionSource.Unknown) + { + return false; + } + + int previousStage = -1; + int positionBranchCount = 0; + for (int index = 0; index < Actions.Length; index++) + { + RuntimeInitialCreateTailAction action = Actions[index]; + int stage = SameCreateStage(action.Kind); + if (stage <= previousStage) + return false; + if (action.Kind is RuntimeInitialCreateTailActionKind.Position + && action.PositionSource != PositionSource) + { + return false; + } + if (action.Kind is RuntimeInitialCreateTailActionKind.CreateParent + or RuntimeInitialCreateTailActionKind.Pickup + or RuntimeInitialCreateTailActionKind.Position) + { + positionBranchCount++; + } + previousStage = stage; + } + bool hasPhysicsDescription = Actions.Any( + static action => action.Kind + is RuntimeInitialCreateTailActionKind + .PreTailDescriptionAdaptation); + return hasPhysicsDescription + ? positionBranchCount <= 1 + : positionBranchCount == 0; + } + + private static int SameCreateStage(RuntimeInitialCreateTailActionKind kind) => + kind switch + { + RuntimeInitialCreateTailActionKind.PreTailDescriptionAdaptation => 0, + RuntimeInitialCreateTailActionKind.ObjDesc => 1, + RuntimeInitialCreateTailActionKind.CreateParent + or RuntimeInitialCreateTailActionKind.Pickup + or RuntimeInitialCreateTailActionKind.Position => 2, + RuntimeInitialCreateTailActionKind.Movement => 3, + RuntimeInitialCreateTailActionKind.State => 4, + RuntimeInitialCreateTailActionKind.Vector => 5, + RuntimeInitialCreateTailActionKind.WeenieDescription => 6, + RuntimeInitialCreateTailActionKind.ResidentCellCleanup => 7, + _ => int.MinValue, + }; } internal readonly record struct RuntimeInitialCreateResidenceAdoptionToken( @@ -259,125 +501,37 @@ internal sealed class RuntimeInitialCreateResidenceState return Own(record, route); } - internal RuntimeInitialCreateResidenceLease EnqueueAcceptedPosition( + internal RuntimeInitialCreateResidenceLease EnqueueAccepted( RuntimeEntityRecord record, in RuntimeInitialCreateResidenceLease prior, - WorldSession.EntityPositionUpdate update, - in WorldSession.EntitySpawn accepted, - PositionTimestampDisposition disposition, - in AcceptedPhysicsTimestamps timestamps, - bool isLocalPlayer, - Quaternion? forcePositionRotation, - Vector3? currentLocalVelocity, - bool projectionRequiresTeleportHook) + RuntimeInitialCreateContinuationKind kind, + RuntimeAcceptedPositionSource positionSource, + ImmutableArray actions) { - ArgumentNullException.ThrowIfNull(record); - if (!_entities.IsCurrent(record) - || record.Key is not { } key - || update.Guid != record.ServerGuid - || accepted.Guid != record.ServerGuid - || disposition is PositionTimestampDisposition.Rejected) + var owned = ImmutableArray.CreateBuilder< + RuntimeInitialCreateTailAction>(actions.Length); + foreach (RuntimeInitialCreateTailAction action in actions) { - return default; + owned.Add(RuntimeInitialCreateAdmissionFreezer.Freeze(action)); } - - RuntimeInitialCreateResidenceLease current; - Entry? active = null; - CompletedEntry? completed = null; - if (_entries.TryGetValue(key, out active) - && ReferenceEquals(active.Record, record) - && active.Lease.Token == prior.Token - && IsCurrent(active)) - { - current = active.Lease; - } - else if (_completed.TryGetValue(key, out completed) - && ReferenceEquals(completed.Record, record) - && completed.Lease.Token == prior.Token - && IsCompletedCurrent(completed)) - { - current = completed.Lease; - } - else - { - return default; - } - if (current.Continuations.Length == int.MaxValue - || completed is not null - && completed.Receipt.Adoption.Revision == ulong.MaxValue) - return default; - - RuntimePositionEntityKind entityKind = isLocalPlayer - ? RuntimePositionEntityKind.LocalPlayer - : (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0 - ? RuntimePositionEntityKind.Projectile - : RuntimePositionEntityKind.Remote; - CreateObject.ServerPosition acceptedPosition = - accepted.Position ?? update.Position; - ushort previousTeleport = current.Continuations.IsEmpty - ? current.Route.Authority.AcceptedTeleportSequence - : current.Continuations[^1].AcceptedTeleportSequence; - ulong sequence = (ulong)current.Continuations.Length + 1UL; - var continuation = new RuntimeInitialCreateResidenceContinuation( - sequence, - entityKind, - update.InstanceSequence, - update.PositionSequence, - previousTeleport, - update.TeleportSequence, - update.ForcePositionSequence, - timestamps.Teleport, - timestamps.ForcePosition, - disposition, - acceptedPosition, - update.Velocity, - accepted.Physics?.Velocity, - forcePositionRotation, - currentLocalVelocity, - projectionRequiresTeleportHook, - accepted.PlacementId ?? 0u, - record.PositionAuthorityVersion); - if (!continuation.IsValid) - return default; - - RuntimeInitialCreateResidenceLease revised = current with - { - Continuations = current.Continuations.Add(continuation), - }; - if (active is not null) - { - active.Lease = revised; - } - else - { - completed!.Lease = revised; - RuntimeInitialCreateResidenceAdoptionToken revisedAdoption = - completed.Receipt.Adoption with - { - Revision = completed.Receipt.Adoption.Revision + 1UL, - }; - completed.Receipt = completed.Receipt with - { - Adoption = revisedAdoption, - Continuations = revised.Continuations, - }; - } - return revised; + return Enqueue( + record, + prior, + new RuntimeInitialCreateResidenceContinuation( + NextSequence(prior), + kind, + record.Incarnation, + positionSource, + owned.MoveToImmutable())); } - internal bool CanEnqueueAcceptedPosition( + internal bool CanEnqueue( RuntimeEntityRecord record, - in RuntimeInitialCreateResidenceLease prior, - in WorldSession.EntityPositionUpdate update) + in RuntimeInitialCreateResidenceLease prior) { ArgumentNullException.ThrowIfNull(record); - if (record.Key is not { } key - || update.Guid != record.ServerGuid - || !RuntimeAuthoritativePositionRouteClassifier - .IsValidCreateWirePosition(update.Position)) - { + if (record.Key is not { } key) return false; - } if (_entries.TryGetValue(key, out Entry? entry)) { return ReferenceEquals(entry.Record, record) @@ -393,6 +547,61 @@ internal sealed class RuntimeInitialCreateResidenceState && completed.Receipt.Adoption.Revision < ulong.MaxValue; } + private RuntimeInitialCreateResidenceLease Enqueue( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceLease prior, + in RuntimeInitialCreateResidenceContinuation continuation) + { + ArgumentNullException.ThrowIfNull(record); + if (!continuation.IsValid + || continuation.InstanceSequence != record.Incarnation + || !CanEnqueue(record, prior)) + { + return default; + } + + RuntimeEntityKey key = record.Key!.Value; + Entry? active = null; + CompletedEntry? completed = null; + RuntimeInitialCreateResidenceLease current; + if (_entries.TryGetValue(key, out active)) + current = active.Lease; + else if (_completed.TryGetValue(key, out completed)) + current = completed.Lease; + else + return default; + + if (continuation.Sequence != NextSequence(current)) + return default; + RuntimeInitialCreateResidenceLease revised = current with + { + Continuations = current.Continuations.Add(continuation), + }; + if (active is not null) + { + active.Lease = revised; + } + else + { + completed!.Lease = revised; + RuntimeInitialCreateResidenceAdoptionToken adoption = + completed.Receipt.Adoption with + { + Revision = completed.Receipt.Adoption.Revision + 1UL, + }; + completed.Receipt = completed.Receipt with + { + Adoption = adoption, + Continuations = revised.Continuations, + }; + } + return revised; + } + + private static ulong NextSequence( + in RuntimeInitialCreateResidenceLease lease) => + (ulong)lease.Continuations.Length + 1UL; + internal bool TryGetTransaction( RuntimeEntityRecord record, out RuntimeInitialCreateResidenceLease lease) @@ -457,6 +666,7 @@ internal sealed class RuntimeInitialCreateResidenceState token, route, placement, + RuntimeInitialCreateAdmissionFreezer.Freeze(record.Snapshot), ImmutableArray.Empty); _entries.Add(key, new Entry { @@ -600,6 +810,12 @@ internal sealed class RuntimeInitialCreateResidenceState Retire(current); return false; } + // Admission-only checkpoint: a completed initial placement remains + // owned until the next slice's continuation executor has drained the + // exact FIFO revision. Letting a host acknowledge here would silently + // discard accepted packets. + if (!current.Lease.Continuations.IsEmpty) + return false; if (current.Lease.Route.PerformsSetPosition && !_setPosition.ConsumeAcknowledgedPlacement( current.Lease.Placement, diff --git a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs index 28c70f8e..3d90357b 100644 --- a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs +++ b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs @@ -54,6 +54,19 @@ internal enum RuntimeTeleportHookPhase : byte AfterEnterWorld, } +/// +/// Exact placement of retail's ConstrainTo relative to the selected +/// position operation. Local ordinary corrections constrain before their +/// optional interpolation; teleports and remote hard moves constrain only +/// after the position operation succeeds. +/// +internal enum RuntimePositionConstrainPhase : byte +{ + None, + BeforePositionOperation, + AfterPositionOperation, +} + /// /// Exact accepted-wire authority for one presentation-independent position /// route. identifies the incarnation while @@ -147,7 +160,7 @@ internal readonly record struct RuntimeAuthoritativePositionRoute( bool LeaveWorld, RuntimeTeleportHookPhase TeleportHookPhase, bool StopInterpolating, - bool ConstrainAfterRouting, + RuntimePositionConstrainPhase ConstrainPhase, bool PreserveHeading, bool ZeroVelocity, bool SendPositionImmediately, @@ -163,6 +176,12 @@ internal readonly record struct RuntimeAuthoritativePositionRoute( internal bool RunsTeleportHook => TeleportHookPhase is not RuntimeTeleportHookPhase.None; + + internal bool ConstrainBeforeRouting => + ConstrainPhase is RuntimePositionConstrainPhase.BeforePositionOperation; + + internal bool ConstrainAfterRouting => + ConstrainPhase is RuntimePositionConstrainPhase.AfterPositionOperation; } /// @@ -214,7 +233,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.None, StopInterpolating: false, - ConstrainAfterRouting: false, + ConstrainPhase: RuntimePositionConstrainPhase.None, PreserveHeading: false, ZeroVelocity: false, SendPositionImmediately: false, @@ -246,7 +265,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier ? RuntimeTeleportHookPhase.AfterEnterWorld : RuntimeTeleportHookPhase.None, StopInterpolating: false, - ConstrainAfterRouting: false, + ConstrainPhase: RuntimePositionConstrainPhase.None, PreserveHeading: false, ZeroVelocity: false, SendPositionImmediately: false, @@ -286,7 +305,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.None, StopInterpolating: false, - ConstrainAfterRouting: false, + ConstrainPhase: RuntimePositionConstrainPhase.None, PreserveHeading: true, ZeroVelocity: false, SendPositionImmediately: true, @@ -309,7 +328,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.AfterPositionOperation, StopInterpolating: false, - ConstrainAfterRouting: true, + ConstrainPhase: RuntimePositionConstrainPhase.AfterPositionOperation, PreserveHeading: false, ZeroVelocity: true, SendPositionImmediately: false, @@ -331,7 +350,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.None, StopInterpolating: false, - ConstrainAfterRouting: true, + ConstrainPhase: RuntimePositionConstrainPhase.BeforePositionOperation, PreserveHeading: false, ZeroVelocity: false, SendPositionImmediately: false, @@ -357,7 +376,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.BeforePositionOperation, StopInterpolating: false, - ConstrainAfterRouting: true, + ConstrainPhase: RuntimePositionConstrainPhase.AfterPositionOperation, PreserveHeading: false, ZeroVelocity: false, SendPositionImmediately: false, @@ -383,7 +402,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.None, StopInterpolating: false, - ConstrainAfterRouting: false, + ConstrainPhase: RuntimePositionConstrainPhase.None, PreserveHeading: false, ZeroVelocity: false, SendPositionImmediately: false, @@ -413,7 +432,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.None, StopInterpolating: !nearby, - ConstrainAfterRouting: true, + ConstrainPhase: RuntimePositionConstrainPhase.AfterPositionOperation, PreserveHeading: false, ZeroVelocity: false, SendPositionImmediately: false, @@ -445,7 +464,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier LeaveWorld: true, TeleportHookPhase: RuntimeTeleportHookPhase.None, StopInterpolating: false, - ConstrainAfterRouting: false, + ConstrainPhase: RuntimePositionConstrainPhase.None, PreserveHeading: false, ZeroVelocity: false, SendPositionImmediately: false, @@ -532,7 +551,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.None, StopInterpolating: false, - ConstrainAfterRouting: false, + ConstrainPhase: RuntimePositionConstrainPhase.None, PreserveHeading: false, ZeroVelocity: false, SendPositionImmediately: false, @@ -552,7 +571,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.None, StopInterpolating: false, - ConstrainAfterRouting: false, + ConstrainPhase: RuntimePositionConstrainPhase.None, PreserveHeading: false, ZeroVelocity: false, SendPositionImmediately: false, diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs index 1ebebabb..046333a4 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs @@ -1,5 +1,7 @@ +using System.Collections; using System.Collections.Immutable; using System.Numerics; +using System.Reflection; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -74,6 +76,8 @@ public sealed class RuntimeInitialCreateResidenceStateTests { using var lifetime = new RuntimeEntityObjectLifetime(); Bind(lifetime, 8UL); + if (parented) + _ = lifetime.RegisterEntity(Spawn(0x70004000u, 1)); RuntimeEntityRecord canonical = lifetime .RegisterEntityWithInitialResidence( Spawn( @@ -458,6 +462,7 @@ public sealed class RuntimeInitialCreateResidenceStateTests { using var lifetime = new RuntimeEntityObjectLifetime(); Bind(lifetime, 34UL); + _ = lifetime.RegisterEntity(Spawn(0x70004020u, 1)); RuntimeEntityRecord canonical = lifetime .RegisterEntityWithInitialResidence( Spawn( @@ -494,6 +499,7 @@ public sealed class RuntimeInitialCreateResidenceStateTests using var lifetime = new RuntimeEntityObjectLifetime(); Bind(lifetime, 36UL); const uint guid = 0x70003024u; + _ = lifetime.RegisterEntity(Spawn(0x70004024u, 1)); RuntimeEntityRecord canonical = lifetime .RegisterEntityWithInitialResidence( Spawn( @@ -547,18 +553,18 @@ public sealed class RuntimeInitialCreateResidenceStateTests revised.Adoption.Revision); RuntimeInitialCreateResidenceContinuation continuation = Assert.Single(revised.Continuations); - Assert.Equal(25f, continuation.AcceptedWirePosition.PositionX); - Assert.Equal((ushort)1, continuation.AcceptedTeleportSequence); + RuntimeInitialCreateTailAction position = PositionAction(continuation); + Assert.Equal(25f, position.Position!.Value.Position.PositionX); + Assert.Equal((ushort)1, position.AcceptedTimestamps.Teleport); Assert.False(lifetime.AcknowledgeInitialCreateResidenceAdoption( canonical, original.Adoption)); - Assert.True(lifetime.AcknowledgeInitialCreateResidenceAdoption( + Assert.False(lifetime.AcknowledgeInitialCreateResidenceAdoption( canonical, revised.Adoption)); - Assert.Equal(0, lifetime.CaptureOwnership() + Assert.Equal(1, lifetime.CaptureOwnership() .InitialCreateResidenceLeaseCount); - Assert.True(lifetime.Physics.SetPosition.CaptureOwnership() - .IsConverged); + ConvergeSessionClear(lifetime); } [Fact] @@ -810,6 +816,7 @@ public sealed class RuntimeInitialCreateResidenceStateTests { using var lifetime = new RuntimeEntityObjectLifetime(); Bind(lifetime, 17UL); + _ = lifetime.RegisterEntity(Spawn(0x70004001u, 1)); RuntimeEntityRecord canonical = lifetime .RegisterEntityWithInitialResidence( Spawn( @@ -885,7 +892,17 @@ public sealed class RuntimeInitialCreateResidenceStateTests Assert.True(lifetime.TryGetInitialCreateResidence( canonical, out RuntimeInitialCreateResidenceLease afterDuplicate)); - Assert.Equal(initial, afterDuplicate); + Assert.Equal(initial.Token, afterDuplicate.Token); + Assert.Equal(initial.Route, afterDuplicate.Route); + Assert.Equal(initial.Placement, afterDuplicate.Placement); + RuntimeInitialCreateResidenceContinuation duplicateEnvelope = + Assert.Single(afterDuplicate.Continuations); + Assert.Equal(RuntimeInitialCreateContinuationKind.SameIncarnationCreate, + duplicateEnvelope.Kind); + Assert.DoesNotContain( + duplicateEnvelope.Actions, + static action => action.Kind + is RuntimeInitialCreateTailActionKind.Position); Assert.Equal(positionAuthority, canonical.PositionAuthorityVersion); Assert.Equal(spatialAuthority, canonical.SpatialAuthorityVersion); @@ -900,23 +917,9 @@ public sealed class RuntimeInitialCreateResidenceStateTests Assert.True(lifetime.TryGetInitialCreateResidence( canonical, out RuntimeInitialCreateResidenceLease beforeFreshRoute)); - Assert.Equal(initial, beforeFreshRoute); + Assert.Equal(2, beforeFreshRoute.Continuations.Length); Assert.Equal(10f, canonical.Snapshot.Position!.Value.PositionX); - Assert.NotNull(newer.Inbound.SameGenerationEvents); - - WorldSession.EntityPositionUpdate update = - newer.Inbound.SameGenerationEvents!.Value.Position!.Value; - Assert.True(lifetime.TryApplyPosition( - update, - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - projectionRequiresTeleportHook: false, - acknowledgeProjection: null, - out PositionTimestampDisposition disposition, - out _, - out _)); - Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.Null(newer.Inbound.SameGenerationEvents); Assert.Equal(0u, canonical.FullCellId); Assert.Equal(10f, canonical.Snapshot.Position!.Value.PositionX); Assert.True(lifetime.TryGetInitialCreateResidence( @@ -926,11 +929,12 @@ public sealed class RuntimeInitialCreateResidenceStateTests Assert.Equal(RuntimeSetPositionOperationKind.RemoteAuthoritative, successor.Route.OperationKind); RuntimeInitialCreateResidenceContinuation continuation = - Assert.Single(successor.Continuations); - Assert.Equal(30f, continuation.AcceptedWirePosition.PositionX); - Assert.Equal((ushort)2, continuation.PositionSequence); + successor.Continuations[1]; + RuntimeInitialCreateTailAction position = PositionAction(continuation); + Assert.Equal(30f, position.Position!.Value.Position.PositionX); + Assert.Equal((ushort)2, position.Position.Value.PositionSequence); Assert.Equal(PositionTimestampDisposition.Apply, - continuation.TimestampDisposition); + position.PositionDisposition); Assert.True(successor.Placement.IsValid); Assert.Equal(1, lifetime.Physics.SetPosition.CaptureOwnership() .ActiveOperationCount); @@ -1005,22 +1009,7 @@ public sealed class RuntimeInitialCreateResidenceStateTests positionSequence: 2, positionX: 40f), isLocalPlayer: true); - WorldSession.EntityPositionUpdate update = - newer.Inbound.SameGenerationEvents!.Value.Position!.Value; - - Assert.True(lifetime.TryApplyPosition( - update, - isLocalPlayer: true, - forcePositionRotation: null, - currentLocalVelocity: null, - projectionRequiresTeleportHook: false, - acknowledgeProjection: null, - out PositionTimestampDisposition disposition, - out _, - out AcceptedPhysicsTimestamps timestamps)); - - Assert.Equal(PositionTimestampDisposition.Apply, disposition); - Assert.False(timestamps.TeleportAdvanced); + Assert.Null(newer.Inbound.SameGenerationEvents); Assert.Equal(0u, canonical.FullCellId); Assert.True(lifetime.TryGetInitialCreateResidence( canonical, @@ -1035,10 +1024,12 @@ public sealed class RuntimeInitialCreateResidenceStateTests Assert.True(successor.Placement.IsValid); RuntimeInitialCreateResidenceContinuation continuation = Assert.Single(successor.Continuations); + RuntimeInitialCreateTailAction position = PositionAction(continuation); Assert.Equal(PositionTimestampDisposition.Apply, - continuation.TimestampDisposition); - Assert.Equal((ushort)0, continuation.PreviousTeleportSequence); - Assert.Equal((ushort)0, continuation.TeleportSequence); + position.PositionDisposition); + Assert.False(position.AcceptedTimestamps.TeleportAdvanced); + Assert.Equal((ushort)0, position.PreviousTeleportSequence); + Assert.Equal((ushort)0, position.Position!.Value.TeleportSequence); } [Fact] @@ -1059,12 +1050,13 @@ public sealed class RuntimeInitialCreateResidenceStateTests successor.Route.OperationKind); RuntimeInitialCreateResidenceContinuation continuation = Assert.Single(successor.Continuations); - Assert.Equal(RuntimePositionEntityKind.LocalPlayer, - continuation.EntityKind); - Assert.Equal((ushort)1, continuation.ForcePositionSequence); - Assert.Equal((ushort)0, continuation.PreviousTeleportSequence); - Assert.Equal((ushort)0, continuation.TeleportSequence); - Assert.NotNull(continuation.ForcePositionRotation); + RuntimeInitialCreateTailAction position = PositionAction(continuation); + Assert.Equal(PositionTimestampDisposition.ForcePosition, + position.PositionDisposition); + Assert.Equal((ushort)1, + position.Position!.Value.ForcePositionSequence); + Assert.Equal((ushort)0, position.PreviousTeleportSequence); + Assert.Equal((ushort)0, position.Position.Value.TeleportSequence); } [Fact] @@ -1083,11 +1075,10 @@ public sealed class RuntimeInitialCreateResidenceStateTests Assert.Equal(PositionTimestampDisposition.Apply, disposition); RuntimeInitialCreateResidenceContinuation continuation = Assert.Single(successor.Continuations); - Assert.Equal(RuntimePositionEntityKind.LocalPlayer, - continuation.EntityKind); - Assert.Equal((ushort)0, continuation.PreviousTeleportSequence); - Assert.Equal((ushort)1, continuation.TeleportSequence); - Assert.Equal(Vector3.Zero, continuation.AcceptedVelocity); + RuntimeInitialCreateTailAction position = PositionAction(continuation); + Assert.Equal((ushort)0, position.PreviousTeleportSequence); + Assert.Equal((ushort)1, position.Position!.Value.TeleportSequence); + Assert.Null(position.Position.Value.Velocity); } [Fact] @@ -1106,10 +1097,9 @@ public sealed class RuntimeInitialCreateResidenceStateTests Assert.Equal(PositionTimestampDisposition.Apply, disposition); RuntimeInitialCreateResidenceContinuation continuation = Assert.Single(successor.Continuations); - Assert.Equal(RuntimePositionEntityKind.Remote, - continuation.EntityKind); - Assert.Equal((ushort)0, continuation.PreviousTeleportSequence); - Assert.Equal((ushort)1, continuation.TeleportSequence); + RuntimeInitialCreateTailAction position = PositionAction(continuation); + Assert.Equal((ushort)0, position.PreviousTeleportSequence); + Assert.Equal((ushort)1, position.Position!.Value.TeleportSequence); } [Fact] @@ -1170,17 +1160,17 @@ public sealed class RuntimeInitialCreateResidenceStateTests retained.Continuations.Select(static item => item.Sequence)); Assert.Equal([20f, 30f, 40f], retained.Continuations.Select( - static item => item.AcceptedWirePosition.PositionX)); + static item => PositionAction(item).Position!.Value.Position.PositionX)); Assert.Equal([(ushort)0, (ushort)0, (ushort)1], retained.Continuations.Select( - static item => item.PreviousTeleportSequence)); + static item => PositionAction(item).PreviousTeleportSequence)); Assert.Equal([(ushort)0, (ushort)1, (ushort)2], retained.Continuations.Select( - static item => item.TeleportSequence)); - Assert.False(retained.Continuations[0] - .ProjectionRequiresTeleportHook); - Assert.True(retained.Continuations[1] - .ProjectionRequiresTeleportHook); + static item => PositionAction(item).Position!.Value.TeleportSequence)); + Assert.False(PositionAction(retained.Continuations[0]) + .AcceptedTimestamps.TeleportHookRequired); + Assert.False(PositionAction(retained.Continuations[1]) + .AcceptedTimestamps.TeleportHookRequired); } [Fact] @@ -1236,7 +1226,8 @@ public sealed class RuntimeInitialCreateResidenceStateTests canonical, out retained)); Assert.Equal((ushort)2, - Assert.Single(retained.Continuations).PositionSequence); + PositionAction(Assert.Single(retained.Continuations)) + .Position!.Value.PositionSequence); } [Fact] @@ -1346,19 +1337,7 @@ public sealed class RuntimeInitialCreateResidenceStateTests positionSequence: 2, positionX: 45f), isLocalPlayer: true); - WorldSession.EntityPositionUpdate update = - newer.Inbound.SameGenerationEvents!.Value.Position!.Value; - Assert.True(lifetime.TryApplyPosition( - update, - isLocalPlayer: true, - forcePositionRotation: null, - currentLocalVelocity: null, - projectionRequiresTeleportHook: false, - acknowledgeProjection: null, - out PositionTimestampDisposition disposition, - out _, - out _)); - Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.Null(newer.Inbound.SameGenerationEvents); Assert.Equal(0, deleted); Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( out RuntimePlacementProjectionSnapshot place)); @@ -1376,7 +1355,8 @@ public sealed class RuntimeInitialCreateResidenceStateTests successor.Route.TeleportHookPhase); RuntimeInitialCreateResidenceContinuation continuation = Assert.Single(successor.Continuations); - Assert.Equal(45f, continuation.AcceptedWirePosition.PositionX); + Assert.Equal(45f, PositionAction(continuation) + .Position!.Value.Position.PositionX); Assert.Equal( RuntimeInitialCreateResidenceCompletionStatus.Completed, lifetime.CompleteInitialCreateResidence( @@ -1406,20 +1386,18 @@ public sealed class RuntimeInitialCreateResidenceStateTests repeated.Adoption.Revision); Assert.Equal(2, repeated.Continuations.Length); Assert.Equal(55f, - repeated.Continuations[1].AcceptedWirePosition.PositionX); + PositionAction(repeated.Continuations[1]) + .Position!.Value.Position.PositionX); Assert.False(lifetime.AcknowledgeInitialCreateResidenceAdoption( canonical, receipt.Adoption)); - Assert.True(lifetime.AcknowledgeInitialCreateResidenceAdoption( + Assert.False(lifetime.AcknowledgeInitialCreateResidenceAdoption( canonical, repeated.Adoption)); - Assert.Equal( - RuntimeInitialCreateResidenceCompletionStatus.RejectedToken, - lifetime.CompleteInitialCreateResidence( - canonical, - successor.Token, - out _)); + Assert.Equal(1, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); Assert.Equal(0, deleted); + ConvergeSessionClear(lifetime); } [Fact] @@ -1539,6 +1517,868 @@ public sealed class RuntimeInitialCreateResidenceStateTests Assert.Equal(1UL, canonical.SpatialAuthorityVersion); } + [Fact] + public void MixedAcceptedPacketsRemainOneFrozenArrivalOrderedFifo() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 40UL); + const uint parentGuid = 0x70004100u; + const uint guid = 0x70003100u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1), + isLocalPlayer: false) + .Canonical!; + WorldSession.EntitySpawn frozenCanonical = canonical.Snapshot; + Assert.True(lifetime.Entities.TryGetSnapshot( + guid, + out WorldSession.EntitySpawn frozenWire)); + ulong lastPublished = lifetime.Events.LastSequence; + int callbacks = 0; + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x04000001u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc( + appearance, + _ => callbacks++, + out _)); + Assert.True(lifetime.TryApplyPosition( + PositionUpdate(guid, 2, 0, 0, 20f), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: true, + acknowledgeProjection: _ => callbacks++, + out _, + out _, + out _)); + var motion = new WorldSession.EntityMotionUpdate( + guid, + new CreateObject.ServerMotionState(0x3d, 0x11), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 1, + IsAutonomous: false); + Assert.True(lifetime.TryApplyMotion( + motion, + retainPayload: true, + acknowledgeProjection: _ => callbacks++, + out _, + out _)); + var state = new SetState.Parsed( + guid, + (uint)(PhysicsStateFlags.Gravity | PhysicsStateFlags.Hidden), + InstanceSequence: 1, + StateSequence: 2); + Assert.True(lifetime.TryApplyState( + state, + acknowledgeProjection: (_, _) => callbacks++, + out _, + out _)); + var vector = new VectorUpdate.Parsed( + guid, + new Vector3(4f, 5f, 6f), + new Vector3(0f, 0f, 0.5f), + InstanceSequence: 1, + VectorSequence: 2); + Assert.True(lifetime.TryApplyVector( + vector, + _ => callbacks++, + out _)); + var pickup = new PickupEvent.Parsed( + guid, + InstanceSequence: 1, + PositionSequence: 3); + Assert.True(lifetime.TryApplyPickup( + pickup, + _ => callbacks++, + out _)); + var parent = new ParentEvent.Parsed( + parentGuid, + guid, + ParentLocation: 1u, + PlacementId: 7u, + ParentInstanceSequence: 1, + ChildPositionSequence: 4); + Assert.True(lifetime.TryApplyParent( + parent, + _ => callbacks++, + out _)); + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, + 1, + positionSequence: 5, + positionX: 50f) with + { + Name = "deferred-description", + }; + PhysicsSpawnData samePhysics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = samePhysics with + { + Movement = new PhysicsMovementData( + new byte[] { 1 }, + new CreateObject.ServerMotionState(0x3d, 0x12), + IsAutonomous: false), + Velocity = new Vector3(7f, 8f, 9f), + AngularVelocity = new Vector3(0f, 0f, 1f), + Timestamps = samePhysics.Timestamps with + { + Position = 5, + Movement = 3, + State = 3, + Vector = 3, + ObjDesc = 3, + }, + }, + MovementSequence = 3, + }; + RuntimeEntityRegistrationResult same = lifetime + .RegisterEntityWithInitialResidence( + sameCreate, + isLocalPlayer: false); + + Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, + same.Inbound.Disposition); + Assert.Null(same.Inbound.SameGenerationEvents); + Assert.Equal(0, callbacks); + Assert.Equal(lastPublished, lifetime.Events.LastSequence); + Assert.Equal(frozenCanonical, canonical.Snapshot); + Assert.True(lifetime.Entities.TryGetSnapshot(guid, out var wireAfter)); + Assert.Equal(frozenWire, wireAfter); + Assert.False(lifetime.Entities.ParentAttachments.HasCommittedParent(guid)); + + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal( + [ + RuntimeInitialCreateContinuationKind.ObjDesc, + RuntimeInitialCreateContinuationKind.Position, + RuntimeInitialCreateContinuationKind.Movement, + RuntimeInitialCreateContinuationKind.State, + RuntimeInitialCreateContinuationKind.Vector, + RuntimeInitialCreateContinuationKind.Pickup, + RuntimeInitialCreateContinuationKind.Parent, + RuntimeInitialCreateContinuationKind.SameIncarnationCreate, + ], + lease.Continuations.Select(static item => item.Kind)); + Assert.Equal(Enumerable.Range(1, 8).Select(static value => (ulong)value), + lease.Continuations.Select(static item => item.Sequence)); + RuntimeInitialCreateResidenceContinuation atomic = + lease.Continuations[^1]; + Assert.Equal(RuntimeAcceptedPositionSource.SameIncarnationCreate, + atomic.PositionSource); + Assert.Equal(RuntimeInitialCreateTailActionKind.WeenieDescription, + atomic.Actions[^2].Kind); + Assert.Equal(RuntimeInitialCreateTailActionKind.ResidentCellCleanup, + atomic.Actions[^1].Kind); + Assert.Equal(50f, + PositionAction(atomic).Position!.Value.Position.PositionX); + } + + [Fact] + public void ZeroInstancePendingResidenceAdmitsSameInstanceContinuation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 48UL); + const uint guid = 0x7000310Bu; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 0), false) + .Canonical!; + WorldSession.EntitySpawn frozen = canonical.Snapshot; + ulong eventSequence = lifetime.Events.LastSequence; + + var update = new VectorUpdate.Parsed( + guid, + new Vector3(2f, 3f, 4f), + new Vector3(0f, 0f, 0.5f), + InstanceSequence: 0, + VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(update, null, out var accepted)); + + Assert.Equal(frozen, accepted); + Assert.Equal(frozen, canonical.Snapshot); + Assert.Equal(eventSequence, lifetime.Events.LastSequence); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + RuntimeInitialCreateResidenceContinuation queued = + Assert.Single(lease.Continuations); + Assert.Equal(RuntimeInitialCreateContinuationKind.Vector, queued.Kind); + Assert.Equal((ushort)0, Assert.Single(queued.Actions) + .Vector!.Value.InstanceSequence); + } + + [Fact] + public void MissingParentCreateIsRawUnacceptedAndExactDeleteCancelsIt() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 41UL); + const uint childGuid = 0x70003101u; + const uint missingParent = 0x70004101u; + var rawParts = new[] { new CreateObject.AnimPartChange(1, 10u) }; + byte[] rawMovement = [1, 2]; + WorldSession.EntitySpawn raw = Spawn( + childGuid, + incarnation: 0, + includePosition: false, + parentGuid: missingParent); + PhysicsSpawnData rawPhysics = raw.Physics!.Value; + raw = raw with + { + AnimPartChanges = rawParts, + Physics = rawPhysics with + { + Movement = new PhysicsMovementData( + rawMovement, + MotionState: null, + IsAutonomous: false), + }, + }; + + RuntimeEntityRegistrationResult deferred = lifetime + .RegisterEntityWithInitialResidence(raw, isLocalPlayer: false); + + Assert.True(deferred.DeferredForParent); + Assert.Null(deferred.Canonical); + Assert.Empty(lifetime.Entities.Snapshots); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.ParentAttachments.ContainsDeferredCreate( + childGuid, + instanceSequence: 0)); + rawParts[0] = new CreateObject.AnimPartChange(9, 99u); + rawMovement[0] = 99; + Assert.True(lifetime.Entities.ParentAttachments.TryPeekDeferredCreate( + missingParent, + out DeferredParentCreate retained)); + Assert.Equal((byte)1, retained.Spawn.AnimPartChanges[0].PartIndex); + Assert.Equal((byte)1, + retained.Spawn.Physics!.Value.Movement!.Value.RawData.Span[0]); + + Assert.False(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(childGuid, InstanceSequence: 0), + isLocalPlayer: false, + removeRetainedObject: true, + out _)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.Empty(lifetime.Entities.Snapshots); + } + + [Fact] + public void DeferredRawChildGenerationFiltersPreserveEqualReplacementAndFuture() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 42UL); + const uint childGuid = 0x70003102u; + const uint missingParent = 0x70004102u; + foreach (ushort incarnation in new ushort[] { 1, 2, 3 }) + { + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn( + childGuid, + incarnation, + includePosition: false, + parentGuid: missingParent), + isLocalPlayer: false).DeferredForParent); + } + + // The raw queue belongs to the child CreateObject. Ending or deleting + // a parent incarnation must not discard child packets which only name + // that parent by GUID and carry no parent incarnation proof. + lifetime.Entities.ParentAttachments.EndGeneration( + missingParent, + replacementGeneration: 7); + lifetime.Entities.ParentAttachments.DeleteGeneration( + missingParent, + deletedGeneration: 7); + Assert.Equal(3, lifetime.CaptureOwnership().DeferredParentCreateCount); + + lifetime.Entities.ParentAttachments.EndGeneration( + childGuid, + replacementGeneration: 2); + Assert.Equal(2, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.ParentAttachments.ContainsDeferredCreate( + childGuid, + 2)); + Assert.True(lifetime.Entities.ParentAttachments.ContainsDeferredCreate( + childGuid, + 3)); + + lifetime.Entities.ParentAttachments.DeleteGeneration( + childGuid, + deletedGeneration: 2); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.ParentAttachments.ContainsDeferredCreate( + childGuid, + 3)); + lifetime.Entities.ParentAttachments.DeleteGeneration( + childGuid, + deletedGeneration: 3); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + } + + [Fact] + public void MalformedDeferredVectorDoesNotConsumeItsSequence() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 43UL); + const uint guid = 0x70003103u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), false) + .Canonical!; + WorldSession.EntitySpawn frozen = canonical.Snapshot; + var malformed = new VectorUpdate.Parsed( + guid, + new Vector3(float.NaN, 0f, 0f), + Vector3.Zero, + InstanceSequence: 1, + VectorSequence: 2); + Assert.False(lifetime.TryApplyVector( + malformed, + acknowledgeProjection: null, + out _)); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease before)); + Assert.Empty(before.Continuations); + + var corrected = malformed with { Velocity = new Vector3(1f, 2f, 3f) }; + Assert.True(lifetime.TryApplyVector( + corrected, + acknowledgeProjection: null, + out _)); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease after)); + Assert.Equal(RuntimeInitialCreateContinuationKind.Vector, + Assert.Single(after.Continuations).Kind); + Assert.Equal(frozen, canonical.Snapshot); + Assert.True(lifetime.Entities.TryGetSnapshot(guid, out var wire)); + Assert.Equal(frozen, wire); + } + + [Fact] + public void DeleteAndGuidReuseCannotLeakPriorIncarnationFifo() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 44UL); + const uint guid = 0x70003104u; + RuntimeEntityRecord first = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), false) + .Canonical!; + Assert.True(lifetime.TryApplyVector( + new VectorUpdate.Parsed( + guid, + Vector3.One, + Vector3.Zero, + InstanceSequence: 1, + VectorSequence: 2), + acknowledgeProjection: null, + out _)); + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, 1), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance deletion)); + lifetime.CompleteAcceptedDelete(deletion); + Assert.Null(lifetime.RetireCanonicalOnly(first)); + + RuntimeEntityRecord replacement = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 2), false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + replacement, + out RuntimeInitialCreateResidenceLease lease)); + Assert.Empty(lease.Continuations); + Assert.Equal((ushort)2, replacement.Incarnation); + Assert.Equal(1, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + } + + [Fact] + public void RetainedAdmissionDeepFreezesEveryParserOwnedCollection() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 45UL); + const uint guid = 0x70003105u; + + var initialParts = new[] { new CreateObject.AnimPartChange(1, 10u) }; + var initialTextures = new[] + { + new CreateObject.TextureChange(2, 20u, 21u), + }; + var initialPalettes = new[] + { + new CreateObject.SubPaletteSwap(30u, 3, 4), + }; + var initialCommands = new[] + { + new CreateObject.MotionItem(0x11, 1, 1f), + }; + var movementCommands = new[] + { + new CreateObject.MotionItem(0x12, 2, 1f), + }; + byte[] rawMovement = [1, 2, 3]; + var children = new[] { new PhysicsAttachment(0x70004105u, 5u) }; + WorldSession.EntitySpawn initial = Spawn(guid, 1); + PhysicsSpawnData initialPhysics = initial.Physics!.Value; + initial = initial with + { + AnimPartChanges = initialParts, + TextureChanges = initialTextures, + SubPalettes = initialPalettes, + MotionState = new CreateObject.ServerMotionState( + 0x3d, + 0x10, + Commands: initialCommands), + Physics = initialPhysics with + { + Movement = new PhysicsMovementData( + rawMovement, + new CreateObject.ServerMotionState( + 0x3d, + 0x10, + Commands: movementCommands), + IsAutonomous: false), + Children = children, + }, + }; + + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(initial, false) + .Canonical!; + initialParts[0] = new CreateObject.AnimPartChange(9, 99u); + initialTextures[0] = new CreateObject.TextureChange(9, 99u, 100u); + initialPalettes[0] = new CreateObject.SubPaletteSwap(99u, 9, 9); + initialCommands[0] = new CreateObject.MotionItem(0x99, 9, 9f); + movementCommands[0] = new CreateObject.MotionItem(0x98, 8, 8f); + rawMovement[0] = 99; + children[0] = new PhysicsAttachment(0x70009999u, 99u); + + var objParts = new[] { new CreateObject.AnimPartChange(4, 40u) }; + var objTextures = new[] + { + new CreateObject.TextureChange(5, 50u, 51u), + }; + var objPalettes = new[] + { + new CreateObject.SubPaletteSwap(60u, 6, 7), + }; + Assert.True(lifetime.TryApplyObjDesc( + new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 70u, + objPalettes, + objTextures, + objParts), + InstanceSequence: 1, + ObjDescSequence: 2), + acknowledgeProjection: null, + out _)); + objParts[0] = new CreateObject.AnimPartChange(9, 99u); + objTextures[0] = new CreateObject.TextureChange(9, 99u, 100u); + objPalettes[0] = new CreateObject.SubPaletteSwap(99u, 9, 9); + + var motionCommands = new[] + { + new CreateObject.MotionItem(0x13, 3, 1f), + }; + Assert.True(lifetime.TryApplyMotion( + new WorldSession.EntityMotionUpdate( + guid, + new CreateObject.ServerMotionState( + 0x3d, + 0x13, + Commands: motionCommands), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 1, + IsAutonomous: false), + retainPayload: true, + acknowledgeProjection: null, + out _, + out _)); + motionCommands[0] = new CreateObject.MotionItem(0x97, 7, 7f); + + var createParts = new[] { new CreateObject.AnimPartChange(8, 80u) }; + var createCommands = new[] + { + new CreateObject.MotionItem(0x14, 4, 1f), + }; + byte[] createRawMovement = [4, 5, 6]; + WorldSession.EntitySpawn sameCreate = Spawn( + guid, + 1, + positionSequence: 2, + positionX: 30f); + PhysicsSpawnData samePhysics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + AnimPartChanges = createParts, + Physics = samePhysics with + { + Movement = new PhysicsMovementData( + createRawMovement, + new CreateObject.ServerMotionState( + 0x3d, + 0x14, + Commands: createCommands), + IsAutonomous: false), + Timestamps = samePhysics.Timestamps with + { + ObjDesc = 3, + Movement = 3, + }, + }, + MovementSequence = 3, + }; + Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, + lifetime.RegisterEntityWithInitialResidence(sameCreate, false) + .Inbound.Disposition); + createParts[0] = new CreateObject.AnimPartChange(9, 99u); + createCommands[0] = new CreateObject.MotionItem(0x96, 6, 6f); + createRawMovement[0] = 99; + + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal((byte)1, lease.InitialCreate.AnimPartChanges[0].PartIndex); + Assert.Equal((byte)2, lease.InitialCreate.TextureChanges[0].PartIndex); + Assert.Equal(30u, lease.InitialCreate.SubPalettes[0].SubPaletteId); + Assert.Equal((ushort)0x11, + lease.InitialCreate.MotionState!.Value.Commands![0].Command); + Assert.Equal((byte)1, + lease.InitialCreate.Physics!.Value.Movement!.Value.RawData.Span[0]); + Assert.Equal((ushort)0x12, + lease.InitialCreate.Physics.Value.Movement.Value.MotionState! + .Value.Commands![0].Command); + Assert.Equal(0x70004105u, + lease.InitialCreate.Physics.Value.Children!.Value.Span[0].Guid); + + RuntimeInitialCreateTailAction objDesc = Assert.Single( + lease.Continuations[0].Actions); + Assert.Equal((byte)4, + objDesc.ObjDesc!.Value.ModelData.AnimPartChanges[0].PartIndex); + Assert.Equal((byte)5, + objDesc.ObjDesc.Value.ModelData.TextureChanges[0].PartIndex); + Assert.Equal(60u, + objDesc.ObjDesc.Value.ModelData.SubPalettes[0].SubPaletteId); + RuntimeInitialCreateTailAction motion = Assert.Single( + lease.Continuations[1].Actions); + Assert.Equal((ushort)0x13, + motion.Movement!.Value.MotionState.Commands![0].Command); + RuntimeInitialCreateResidenceContinuation atomic = + lease.Continuations[2]; + RuntimeInitialCreateTailAction description = Assert.Single( + atomic.Actions.Where(static action => action.Kind is + RuntimeInitialCreateTailActionKind.PreTailDescriptionAdaptation)); + Assert.Equal((byte)4, + description.Description!.Value.Movement!.Value.RawData.Span[0]); + Assert.Equal((ushort)0x14, + description.Description.Value.Movement.Value.MotionState! + .Value.Commands![0].Command); + RuntimeInitialCreateTailAction weenie = Assert.Single( + atomic.Actions.Where(static action => action.Kind is + RuntimeInitialCreateTailActionKind.WeenieDescription)); + Assert.Equal((byte)8, + weenie.WeenieDescription!.Value.AnimPartChanges[0].PartIndex); + Assert.Equal((byte)4, + weenie.WeenieDescription.Value.Physics!.Value.Movement!.Value + .RawData.Span[0]); + } + + [Fact] + public void CreateIdentityAndParentMismatchFailBeforeAnyAdmissionMutation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 46UL); + const uint guid = 0x70003106u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), false) + .Canonical!; + WorldSession.EntitySpawn frozen = canonical.Snapshot; + ulong eventSequence = lifetime.Events.LastSequence; + + WorldSession.EntitySpawn instanceMismatch = Spawn( + guid, + 1, + positionSequence: 2, + positionX: 20f); + PhysicsSpawnData mismatchedPhysics = instanceMismatch.Physics!.Value; + instanceMismatch = instanceMismatch with + { + Physics = mismatchedPhysics with + { + Timestamps = mismatchedPhysics.Timestamps with { Instance = 2 }, + }, + }; + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence(instanceMismatch, false)); + + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease unchanged)); + Assert.Empty(unchanged.Continuations); + Assert.Equal(frozen, canonical.Snapshot); + Assert.Equal(eventSequence, lifetime.Events.LastSequence); + Assert.True(lifetime.Entities.TryGetSnapshot(guid, out var wire)); + Assert.Equal(frozen, wire); + + RuntimeEntityRegistrationResult corrected = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, positionSequence: 2, positionX: 20f), + false); + Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, + corrected.Inbound.Disposition); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease admitted)); + Assert.Equal(RuntimeInitialCreateContinuationKind.SameIncarnationCreate, + Assert.Single(admitted.Continuations).Kind); + + const uint child = 0x70003107u; + WorldSession.EntitySpawn parentMismatch = Spawn( + child, + 1, + includePosition: false, + parentGuid: 0x70004107u); + PhysicsSpawnData parentPhysics = parentMismatch.Physics!.Value; + parentMismatch = parentMismatch with + { + Physics = parentPhysics with + { + Parent = new PhysicsAttachment(0x70004108u, 1u), + }, + }; + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence(parentMismatch, false)); + Assert.False(lifetime.Entities.ParentAttachments.ContainsDeferredCreate( + child, + 1)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + + WorldSession.EntitySpawn flattenedOnlyParent = Spawn( + child, + 1, + includePosition: false, + parentGuid: 0x70004107u); + PhysicsSpawnData flattenedOnlyPhysics = + flattenedOnlyParent.Physics!.Value; + flattenedOnlyParent = flattenedOnlyParent with + { + Physics = flattenedOnlyPhysics with { Parent = null }, + }; + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence(flattenedOnlyParent, false)); + + WorldSession.EntitySpawn physicsOnlyParent = Spawn( + child, + 1, + includePosition: false, + parentGuid: 0x70004107u) with + { + ParentGuid = null, + ParentLocation = null, + }; + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence(physicsOnlyParent, false)); + + WorldSession.EntitySpawn noPhysicsTopParent = Spawn( + child, + 0, + includePosition: false, + parentGuid: 0x70004107u) with + { + Physics = null, + MovementSequence = 0, + ServerControlSequence = 0, + PositionSequence = 0, + }; + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence(noPhysicsTopParent, false)); + Assert.False(lifetime.Entities.ParentAttachments.ContainsDeferredCreate( + child, + 0)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + + const uint noPhysicsProjectionGuid = 0x7000310Cu; + WorldSession.EntitySpawn noPhysicsSetup = Spawn( + noPhysicsProjectionGuid, + 0, + includePosition: false) with + { + Physics = null, + PhysicsState = null, + MovementSequence = 0, + ServerControlSequence = 0, + PositionSequence = 0, + }; + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence(noPhysicsSetup, false)); + Assert.False(lifetime.Entities.TryGetActive( + noPhysicsProjectionGuid, + out _)); + Assert.Equal(eventSequence, lifetime.Events.LastSequence); + + WorldSession.EntitySpawn placementMismatch = Spawn( + child, + 1, + includePosition: false, + parentGuid: 0x70004107u) with + { + PlacementId = 7u, + }; + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence(placementMismatch, false)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + + WorldSession.EntitySpawn timestampMismatch = Spawn( + child, + 1, + includePosition: false, + parentGuid: 0x70004107u) with + { + MovementSequence = 2, + }; + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence(timestampMismatch, false)); + + WorldSession.EntitySpawn positionProjectionMismatch = Spawn( + guid, + 1, + positionSequence: 2, + positionX: 20f); + PhysicsSpawnData positionProjectionPhysics = + positionProjectionMismatch.Physics!.Value; + positionProjectionMismatch = positionProjectionMismatch with + { + Physics = positionProjectionPhysics with + { + Position = positionProjectionPhysics.Position!.Value with + { + PositionX = 21f, + }, + }, + }; + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence( + positionProjectionMismatch, + false)); + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease stillUnchanged)); + Assert.Single(stillUnchanged.Continuations); + Assert.Equal(frozen, canonical.Snapshot); + Assert.Equal(eventSequence, lifetime.Events.LastSequence); + + WorldSession.EntitySpawn rawInstanceMismatch = Spawn( + child, + 1, + includePosition: false, + parentGuid: 0x70004107u); + PhysicsSpawnData rawInstancePhysics = + rawInstanceMismatch.Physics!.Value; + rawInstanceMismatch = rawInstanceMismatch with + { + Physics = rawInstancePhysics with + { + Timestamps = rawInstancePhysics.Timestamps with { Instance = 2 }, + }, + }; + Assert.Throws(() => lifetime + .RegisterEntityWithInitialResidence(rawInstanceMismatch, false)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + } + + [Fact] + public void DeferredParentCreateAdmissionTokenPreventsResetAbaConsumption() + { + var state = new ParentAttachmentState(); + const uint parent = 0x70004109u; + WorldSession.EntitySpawn spawn = Spawn( + 0x70003109u, + 1, + includePosition: false, + parentGuid: parent); + state.EnqueueDeferredCreate(spawn, false); + Assert.True(state.TryPeekDeferredCreate(parent, out var oldAdmission)); + state.Clear(); + state.EnqueueDeferredCreate(spawn, false); + Assert.True(state.TryPeekDeferredCreate(parent, out var newAdmission)); + + Assert.NotEqual(oldAdmission.AdmissionId, newAdmission.AdmissionId); + Assert.False(state.ConsumeDeferredCreate(parent, oldAdmission)); + Assert.True(state.ConsumeDeferredCreate(parent, newAdmission)); + Assert.Equal(0, state.DeferredCreateCount); + + FieldInfo admissionField = typeof(ParentAttachmentState).GetField( + "_nextDeferredCreateAdmissionId", + BindingFlags.Instance | BindingFlags.NonPublic)!; + admissionField.SetValue(state, ulong.MaxValue); + Assert.Throws(() => + state.EnqueueDeferredCreate(spawn, false)); + Assert.Equal(0, state.DeferredCreateCount); + } + + [Fact] + public void CompletedFifoSaturationFailsBeforeConsumingWireSequence() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 47UL); + const uint guid = 0x7000310Au; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal(RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.CompleteInitialCreateResidence( + canonical, + lease.Token, + out RuntimeInitialCreateResidenceReceipt receipt)); + + SetCompletedAdoptionRevision( + lifetime.InitialCreateResidences, + canonical.Key!.Value, + ulong.MaxValue); + var update = new VectorUpdate.Parsed( + guid, + Vector3.One, + Vector3.Zero, + InstanceSequence: 1, + VectorSequence: 2); + Assert.False(lifetime.TryApplyVector(update, null, out _)); + Assert.Equal(canonical.Snapshot, + lifetime.Entities.Snapshots[guid]); + + SetCompletedAdoptionRevision( + lifetime.InitialCreateResidences, + canonical.Key.Value, + receipt.Adoption.Revision); + Assert.True(lifetime.TryApplyVector(update, null, out _)); + Assert.True(lifetime.InitialCreateResidences.TryGetTransaction( + canonical, + out RuntimeInitialCreateResidenceLease retained)); + Assert.Equal(RuntimeInitialCreateContinuationKind.Vector, + Assert.Single(retained.Continuations).Kind); + } + private static RuntimeSetPositionCommand Prepare( RuntimeEntityObjectLifetime lifetime, in RuntimeInitialCreateResidenceLease lease, @@ -1559,6 +2399,43 @@ public sealed class RuntimeInitialCreateResidenceStateTests return command; } + private static RuntimeInitialCreateTailAction PositionAction( + in RuntimeInitialCreateResidenceContinuation continuation) => + Assert.Single(continuation.Actions.Where( + static action => action.Kind + is RuntimeInitialCreateTailActionKind.Position)); + + private static void SetCompletedAdoptionRevision( + RuntimeInitialCreateResidenceState state, + RuntimeEntityKey key, + ulong revision) + { + FieldInfo completedField = typeof(RuntimeInitialCreateResidenceState) + .GetField("_completed", BindingFlags.Instance | BindingFlags.NonPublic)!; + var completed = (IDictionary)completedField.GetValue(state)!; + object entry = completed[key]!; + PropertyInfo receiptProperty = entry.GetType().GetProperty( + "Receipt", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)!; + var receipt = (RuntimeInitialCreateResidenceReceipt) + receiptProperty.GetValue(entry)!; + receiptProperty.SetValue( + entry, + receipt with + { + Adoption = receipt.Adoption with { Revision = revision }, + }); + } + + private static void ConvergeSessionClear( + RuntimeEntityObjectLifetime lifetime) + { + foreach (RuntimeEntityRecord record in lifetime.BeginSessionClear()) + lifetime.CompleteSessionEntityRetirement(record); + lifetime.ClearObjects(); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + private static RuntimeInitialCreateResidenceLease ApplyFreshSuccessor( RuntimeEntityObjectLifetime lifetime, uint guid, @@ -1585,27 +2462,12 @@ public sealed class RuntimeInitialCreateResidenceStateTests teleportSequence: teleportSequence, forcePositionSequence: forcePositionSequence), isLocalPlayer); - WorldSession.EntityPositionUpdate update = - newer.Inbound.SameGenerationEvents!.Value.Position!.Value; - var preservedRotation = Quaternion.CreateFromYawPitchRoll( - 0.5f, - 0.25f, - 0.125f); - var currentVelocity = new Vector3(1f, 2f, 3f); - - Assert.True(lifetime.TryApplyPosition( - update, - isLocalPlayer, - forcePositionRotation: preservedRotation, - currentLocalVelocity: currentVelocity, - projectionRequiresTeleportHook: true, - acknowledgeProjection: null, - out disposition, - out _, - out _)); + Assert.Null(newer.Inbound.SameGenerationEvents); Assert.True(lifetime.TryGetInitialCreateResidence( canonical, out RuntimeInitialCreateResidenceLease retained)); + disposition = PositionAction(Assert.Single(retained.Continuations)) + .PositionDisposition; Assert.Equal(initial.Token, retained.Token); Assert.Equal(initial.Route, retained.Route); Assert.Equal(initial.Placement, retained.Placement); @@ -1767,6 +2629,8 @@ public sealed class RuntimeInitialCreateResidenceStateTests MotionTableId: null, PhysicsState: rawState, InstanceSequence: incarnation, + MovementSequence: timestamps.Movement, + ServerControlSequence: timestamps.ServerControlledMove, PositionSequence: positionSequence, ParentGuid: parentGuid, ParentLocation: parentGuid is null ? null : 1u, diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs index 5db0b5fa..e43f97fd 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs @@ -140,6 +140,7 @@ public sealed class RuntimeAuthoritativePositionRouteClassifierTests playerDistance: distance); Assert.Equal(expected, route.Disposition); + Assert.False(route.ConstrainBeforeRouting); Assert.True(route.ConstrainAfterRouting); } @@ -326,7 +327,8 @@ public sealed class RuntimeAuthoritativePositionRouteClassifierTests : null); Assert.Equal(expected, route.Disposition); - Assert.True(route.ConstrainAfterRouting); + Assert.True(route.ConstrainBeforeRouting); + Assert.False(route.ConstrainAfterRouting); } [Theory] From 4a8f74dc7265f0ee0d1fdc0c6c7b3a05d3e5b74d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 21:03:51 +0200 Subject: [PATCH 46/73] docs(physics): hand off initial placement admission --- docs/architecture/acdream-architecture.md | 25 +- docs/plans/2026-04-11-roadmap.md | 17 +- docs/plans/2026-05-12-milestones.md | 17 +- ...untime-initial-create-residence-handoff.md | 5 + ...ime-initial-placement-admission-handoff.md | 314 ++++++++++++++++++ 5 files changed, 365 insertions(+), 13 deletions(-) create mode 100644 docs/research/2026-08-01-runtime-initial-placement-admission-handoff.md diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4caf2952..cd2422b7 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -225,8 +225,14 @@ src/ RuntimeEntityObjectLifetime.cs -> one entity/object lifetime root RuntimeEntityObjectEventStream.cs -> canonical ordered entity/object deltas RuntimeEntityObjectViews.cs -> direct allocation-free borrowed views - InboundPhysicsStateController.cs -> retail timestamp/snapshot authority - ParentAttachmentState.cs -> generation-exact parent relations + InboundPhysicsStateController.cs -> retail timestamp/snapshot authority, + including gate-only dormant acceptance + ParentAttachmentState.cs -> generation-exact parent relations plus raw + missing-parent Create admission + RuntimeInitialCreateAdmissionFreezer.cs -> immutable parser-payload copy + boundary for dormant initial placement + RuntimeInitialCreateResidenceState.cs -> exact-incarnation initial + placement lease and accepted mixed-update FIFO Gameplay/ RuntimeCommunicationState.cs -> one chat/social owner + ordered stream RuntimeInventoryState.cs -> exact object-table borrower + inventory @@ -591,6 +597,21 @@ server-GUID/incarnation map, Runtime local-ID allocation/reverse lookup, accepted snapshots and timestamp gates, parent state, session/operation versions, and exact tombstones. `RuntimeEntityRecord` is presentation-free. +Initial world placement has one deliberate dormant exception to ordinary +snapshot publication. While an exact `RuntimeInitialCreateResidenceState` +lease is waiting for its first canonical placement, retail timestamp gates may +accept later same-incarnation Create, ObjDesc, Parent, Pickup, Position, +Movement, State, and Vector packets, but neither the canonical record, public +accepted snapshot, event stream, nor presentation changes. Runtime retains +deep-frozen typed actions in one monotonic arrival-ordered FIFO under the exact +entity key. A Create whose parent is not yet addressable is retained even +earlier as a complete raw packet, before child timestamp admission, and is +guarded by a non-reused admission token. Delete, generation replacement, +reset, GUID reuse, and reentrant teardown discard only the matching ownership. +This checkpoint (`30012361`) intentionally stops before executing the FIFO or +switching graphical/no-window production routes; that later cutover must use +this owner rather than create another snapshot or placement path. + `LiveEntityRuntime` is the App projection/lifecycle host. `RegisterLiveEntity` first creates or refreshes canonical Runtime state without an App record. `MaterializeLiveEntity` claims the Runtime local ID and creates diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index a4337243..587db849 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -71,12 +71,17 @@ receipts and observers, collision-prefix replacement, authoritative route classification, and initial Create residence are landed as bisectable checkpoints. Commit `38fd4b8d` retains the accepted Create placement plus fresher Position FIFO until exact placement and ordered adoption complete. -Production Create registration is not yet cut over. Next are the synchronous -Runtime continuation executor, retail's exact Create tail ordering, and the -all-host/all-route cutover which can retire AP-1/AD-1. AP-22 authored object -shapes and AD-10 remote contact-plane projection follow, then the final matrix -and ledger closeout. Current handoff: -[`2026-08-01-runtime-initial-create-residence-handoff.md`](../research/2026-08-01-runtime-initial-create-residence-handoff.md). +Commit `30012361` completes the bounded inbound-admission checkpoint: all +accepted mixed updates remain deep-frozen in exact arrival order while the +initial placement waits, with no early canonical/public snapshot, event, or +presentation mutation. Missing-parent raw Create, delete, reconnect/reset, +GUID reuse, malformed projections, saturation, and reentrant teardown are +covered. Production Create registration is not yet cut over. Next are the +synchronous Runtime continuation executor, retail's exact Create tail +ordering, and the all-host/all-route cutover which can retire AP-1/AD-1. AP-22 +authored object shapes and AD-10 remote contact-plane projection follow, then +the final matrix and ledger closeout. Current handoff: +[`2026-08-01-runtime-initial-placement-admission-handoff.md`](../research/2026-08-01-runtime-initial-placement-admission-handoff.md). --- diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 85ec123c..3f83721d 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -95,11 +95,18 @@ vendor work. Placement Slice 4B2 is now complete through dormant SetPosition activation, graphical/no-window placement receipts, collision-prefix replacement, authoritative route classification, pre-placement App staging, and Runtime's initial Create residence/FIFO transaction at `38fd4b8d`. -Production Create registration is not yet cut over. The remaining order is one -synchronous continuation executor with retail's exact Create tail, the all- -host/all-route cutover, AP-22 shape fidelity, AD-10 remote contact-plane -projection, and the final matrix/ledger closeout. Resume Slice 5 vendor -browsing only after that closeout or a new explicit user direction. +The bounded admission checkpoint is complete at `30012361`: every accepted +same-incarnation Create, ObjDesc, Parent, Pickup, Position, Movement, State, +and Vector update is retained as a deep-frozen, arrival-ordered Runtime action +without changing the canonical/public snapshot or presentation while initial +placement waits. Raw missing-parent Create packets remain pre-timestamp, and +delete, reconnect/reset, GUID reuse, malformed projections, FIFO saturation, +and reentrant teardown are covered. Production Create registration is not yet +cut over. The remaining order is one synchronous continuation executor with +retail's exact Create tail, the all-host/all-route cutover, AP-22 shape +fidelity, AD-10 remote contact-plane projection, and the final matrix/ledger +closeout. Resume Slice 5 vendor browsing only after that closeout or a new +explicit user direction. The separately authorized modern-runtime performance program has completed Slices A–D: corrected measurement, prepared-package bake/dedup, package-only diff --git a/docs/research/2026-08-01-runtime-initial-create-residence-handoff.md b/docs/research/2026-08-01-runtime-initial-create-residence-handoff.md index 9327c452..5bab7ad8 100644 --- a/docs/research/2026-08-01-runtime-initial-create-residence-handoff.md +++ b/docs/research/2026-08-01-runtime-initial-create-residence-handoff.md @@ -1,5 +1,10 @@ # Runtime initial Create residence handoff - 2026-08-01 +> **Status:** this remains the `38fd4b8d` residence-foundation history. The +> completed inbound-admission checkpoint and current continuation boundary are +> recorded in +> [`2026-08-01-runtime-initial-placement-admission-handoff.md`](2026-08-01-runtime-initial-placement-admission-handoff.md). + ## Purpose and exact stopping point Commit `38fd4b8dc952236d4b98518c67335026c7815656` adds the dormant Runtime diff --git a/docs/research/2026-08-01-runtime-initial-placement-admission-handoff.md b/docs/research/2026-08-01-runtime-initial-placement-admission-handoff.md new file mode 100644 index 00000000..4a3ec33f --- /dev/null +++ b/docs/research/2026-08-01-runtime-initial-placement-admission-handoff.md @@ -0,0 +1,314 @@ +# Runtime initial-placement admission handoff - 2026-08-01 + +## Purpose and exact stopping point + +Behavior commit `30012361e12222e8271b1531574257ba910c77cb` +completes the bounded Runtime admission checkpoint requested by the user. +While an entity's first authored placement is waiting, every later accepted +same-incarnation update is preserved in exact arrival order without changing +or displaying the entity early. + +In plain terms, Runtime now has a sealed mailbox behind the pending initial +placement. Network sequence checks still decide which messages are fresh, but +accepted messages wait in that mailbox. The visible/canonical entity remains +at its original frozen Create state until a later executor is authorized to +apply the mailbox. + +This checkpoint deliberately does **not** implement that executor, switch the +graphical or headless production routes, begin AP-22 authored shape work, or +begin AD-10 remote slope projection. AP-1 and AD-1 therefore remain open. + +This file supersedes the admission-status portions of +`2026-08-01-runtime-initial-create-residence-handoff.md`; that earlier file +remains the foundation history for commit `38fd4b8d`. + +## Exact workspace and Git state + +- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream` +- Branch: `codex/port-claude-agents` +- Behavior checkpoint: `30012361e12222e8271b1531574257ba910c77cb` +- Residence foundation: `38fd4b8dc952236d4b98518c67335026c7815656` +- Documentation checkpoint: the commit containing this file +- No push or merge is part of this checkpoint. + +The worktree intentionally contains unrelated user changes or pre-existing +stat/line-ending noise. Do not stage, restore, normalize, or rewrite these +paths when continuing: + +- `AGENTS.md` (real unrelated content change); +- `src/AcDream.App/Input/PlayerModeController.cs`; +- `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs`; +- `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs`; +- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`; +- `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs`; +- `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs`; +- `tools/A8CellAudit/A8CellAudit.csproj`. + +Always stage exact paths. Never use `git add -A` in this worktree. + +## What `30012361` owns + +### One exact pending owner + +`RuntimeInitialCreateResidenceState` owns one transaction per exact +`RuntimeEntityKey`, not per server GUID alone. It retains: + +- the deep-frozen initial Create packet and placement operation; +- the exact residence token, generation, placement authority, and revision; +- a monotonic sequence for accepted continuations; +- one immutable, mixed-kind FIFO in original arrival order; +- completion/adoption and teardown receipts. + +The accepted continuation kinds are: + +1. same-incarnation Create; +2. ObjDesc; +3. Parent; +4. Pickup; +5. Position; +6. Movement; +7. State; +8. Vector. + +There is no coalescing, sorting by message type, or replacement of an earlier +accepted FIFO item by a later one. + +### Frozen public state + +While the initial residence is pending: + +- retail timestamp gates advance for accepted updates; +- `RuntimeEntityRecord.Snapshot` remains unchanged; +- the public accepted-snapshot view remains unchanged; +- no entity/object event is published; +- no projection acknowledgement callback runs; +- parent commitment, world placement, rendering, radar, picking, physics, + audio, and other presentation remain unchanged; +- every accepted payload is retained as an immutable typed action. + +This is intentional gate-only acceptance. It is not an alternative canonical +snapshot and must not grow into one. + +### Immutable payload boundary + +`RuntimeInitialCreateAdmissionFreezer` copies every parser-owned mutable +collection that can outlive packet dispatch: + +- EntitySpawn animation-part, texture, and sub-palette arrays; +- ObjDesc model arrays; +- motion command lists; +- Physics Movement raw bytes and motion commands; +- Physics child attachments. + +Same-incarnation Create is retained as one atomic envelope. Its actions retain +retail's packet-tail order: + +1. AP-119 pre-tail description adaptation; +2. ObjDesc; +3. exactly one of Parent, Position, or Pickup; +4. Movement; +5. State; +6. Vector; +7. Weenie description; +8. resident-cell cleanup. + +### Position facts remain raw + +A deferred Position retains the typed packet plus the timestamp disposition +and accepted gate facts. It does not prematurely choose interpolation, +teleport hooks, or final movement behavior. The new explicit +`RuntimePositionConstrainPhase` distinguishes retail's local ordinary +constrain-before route from remote/teleport constrain-after routes, but the +future executor must still sample the required live inputs at the retail +decision point. + +No selected UI target, presentation state, or host-specific route is stored in +the admission owner. + +## Missing-parent behavior + +Named retail resolves a nonzero parent before child object lookup and child +timestamp admission. Runtime now follows that order: + +- a child Create whose parent is not addressable is stored as the complete raw + frozen Create packet; +- no child entity record, accepted snapshot, timestamp gate, local ID, event, + or residence lease exists yet; +- the queue is keyed by parent GUID but each entry also has a monotonic + `AdmissionId` which is never reset, preventing reset/reconnect ABA reuse; +- a later parent Create may consume only the exact admission token it peeked; +- deleting or replacing a still-missing parent does not discard its queued + child Create, matching retail's GUID-keyed placeholder behavior; +- an exact child Delete removes an equal/older deferred child generation even + if no child timestamp gate exists; +- generation cleanup preserves an equal or newer deferred child Create and + discards only older ownership. + +Raw missing-parent replay and actual child creation belong to the future +continuation executor/cutover. They are not performed by this checkpoint. + +## Malformed and saturation behavior + +All structural and capacity checks run before consuming a timestamp gate. + +- Non-finite Vector and Position payloads are rejected without sequence + consumption. +- A full/saturated continuation owner fails before gate acceptance; there is + no fallback to ordinary immediate mutation. +- Flattened EntitySpawn projections must exactly agree with the embedded + PhysicsDesc for identity, Position, relevant timestamps, parent, and + placement. +- When PhysicsDesc is absent, every flattened PhysicsDesc projection must also + be absent or zero: Position, Setup, Motion, PhysicsState, scale, friction, + elasticity, timestamps, parent, and placement. +- Instance sequence zero remains legal and is covered on the active pending + FIFO path. + +The last rule prevents synthetic or corrupt packets from creating two +contradictory placement authorities even though the production parser normally +constructs those projections from one source. + +## Lifetime and failure guarantees + +- Delete cancels the matching residence and its FIFO before the exact entity + can be reused. +- New incarnation/GUID reuse cannot observe or adopt an older incarnation's + FIFO. +- Session reset/reconnect clears active residence, completed-unadopted batches, + deferred raw creates, accepted timestamp ownership, and operation state. +- Reentrant teardown callbacks cannot resurrect the detached owner. +- Completion/adoption revisions cannot wrap into a valid stale token. +- Parent raw-admission IDs cannot wrap or reset into an ABA match. +- Every ownership ledger converges to zero on reset/disposal. + +## Named-retail oracle + +The behavior and reviews used these named-retail anchors: + +- `SmartBox::HandleCreateObject` `0x00454C80` - Create packet ordering and + missing-parent precondition; +- `SmartBox::ProcessObjectNetBlobs` `0x00454B20` - queued packet replay order; +- `SmartBox::HandleReceivedPosition` `0x00453FD0` - standalone Position route; +- `SmartBox::HandleDeleteObject` `0x00451EA0` - GUID-keyed delete behavior; +- `ACCObjectMaint::CreateObject` `0x00558870` - logical object creation; +- `CPhysicsObj::set_description` `0x00514F40` - PhysicsDesc application order; +- `CPhysicsObj::SetPositionInternal` `0x00515330` - canonical placement. + +Research must continue from +`docs/research/named-retail/acclient_2013_pseudo_c.txt`; use the older Ghidra +chunks only as a fallback. + +## Files in the behavior checkpoint + +- `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs` +- `src/AcDream.Runtime/Entities/ParentAttachmentState.cs` +- `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs` +- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` +- `src/AcDream.Runtime/Entities/RuntimeInitialCreateAdmissionFreezer.cs` +- `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs` +- `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs` +- `tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs` +- `tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs` + +## Automated evidence + +Final primary-agent gates on the exact behavior diff: + +- focused initial-residence/classifier tests: **89 passed, 0 failed**; +- complete Runtime tests: **829 passed, 0 failed**; +- complete Release build: **0 warnings, 0 errors**; +- complete Release solution: **10,622 passed, 4 intentional skips**; +- `git diff --check`: clean. + +Per-project final solution totals: + +- App: 4,027 passed / 3 skipped; +- Bake: 15 passed; +- CLI: 4 passed; +- Content: 124 passed; +- Core.Net: 762 passed; +- Core: 4,242 passed / 1 skipped; +- Headless: 76 passed; +- Runtime: 829 passed; +- UI abstractions: 543 passed. + +Independent final results: + +- retail-conformance reviewer: **PASS**; +- architecture/adversarial reviewer: **PASS**. + +The reviews explicitly checked exact retail order, gate-only frozen state, +missing-parent placeholder lifetime, zero instance, delete/reset/GUID reuse, +deep freezing, malformed duplicated projections, capacity preflight, +reentrancy, and the absence of executor/cutover work. + +No connected visual gate is required for this checkpoint because production +graphical and headless routes remain unchanged and the new owner is exercised +only through deterministic Runtime tests. + +## Deliberately unchanged production routes + +At this checkpoint: + +- graphical Create/Position still use the existing App route; +- headless Create/Position still use the existing no-window projection route; +- no host drains `RuntimeInitialCreateResidenceState.Continuations`; +- no raw missing-parent child Create is replayed; +- no new gameplay/presentation callback is emitted; +- no AP-1 or AD-1 divergence row is retired; +- AP-22 and AD-10 are untouched. + +Do not mistake the stored FIFO for completed game behavior. The clean next +boundary is the executor that applies it. + +## Next implementation boundary + +Implement only the Runtime continuation executor and retail Create tail. + +The executor must: + +1. consume the exact initial placement acknowledgement once; +2. capture executor-time inputs at the retail decision point; +3. apply the initial Create tail in retail order; +4. drain mixed continuations strictly by retained sequence; +5. preserve same-Create atomicity; +6. keep the FIFO head retryable if an external host receipt is temporarily + unavailable; +7. make every hook, timestamp, placement, and event side effect exactly once; +8. consume a raw missing-parent Create only through its exact `AdmissionId`; +9. abandon safely on delete, reset, replacement, or generation mismatch; +10. produce host-independent immutable results rather than calling App or + headless presentation directly. + +Do not combine the executor with graphical/headless cutover. After the +executor is independently green, the following checkpoint may switch every +Create, Position, ForcePosition, Parent, Pickup, withdrawal, remote, +projectile, dropped-item, and teardown route across both hosts together. + +Only after executor plus all-host cutover and connected gates pass may AP-1 +and AD-1 retire. AP-22 and AD-10 remain later independent slices. + +## Rollback + +Revert this behavior checkpoint without disturbing the prior residence +foundation: + +```powershell +git revert 30012361e12222e8271b1531574257ba910c77cb +``` + +The documentation checkpoint containing this file is separate and may be +reverted independently if only the handoff text needs correction. + +## Resume checklist + +1. Open the exact worktree and branch above. +2. Confirm `git log -3 --oneline` contains behavior `30012361` and the + documentation commit containing this file. +3. Preserve every unrelated dirty path listed above. +4. Read this file, `docs/architecture/acdream-architecture.md`, + `docs/research/2026-08-01-runtime-initial-create-residence-handoff.md`, and + `docs/research/2026-07-31-canonical-set-position.md`. +5. Re-run the focused 89-test gate before changing admission/execution code. +6. Begin only the continuation executor. Do not begin production cutover, + AP-22, AD-10, or vendor work in the same checkpoint. From 5db3de3c7ab2c6350d11af7f34b852464fc1e0f9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 03:49:56 +0200 Subject: [PATCH 47/73] feat(runtime): execute initial placement continuations The admission checkpoint (30012361) sealed accepted updates behind a pending initial placement; nothing could apply them, so AcknowledgeAdoption refused any non-empty FIFO and the residence system had no path to completion. RuntimeInitialCreateContinuationExecutor is that missing mechanism: a synchronous, retry-idempotent Execute transaction that adopts the acknowledged initial placement exactly once (consuming the retained completion so later authored placements for the key can begin), emits the AfterEnterWorld hook request for the local player, replays deferred missing-parent raw Creates and queued parent relations by parent GUID (retail ProcessObjectNetBlobs order: whole-bucket detach, FIFO dispatch, cancellation-aware restore), and drains the mixed continuation FIFO strictly by sequence with retail route decisions taken at execution time via ClassifyAcceptedPosition on live inputs (server-asserted wire contact, data-driven animation proxy, live distance/options). Apply bodies are shared with the legacy fused paths through new gate-less instance seams on InboundPhysicsStateController that keep the one snapshot store in lockstep; SameIncarnationCreate envelopes apply atomically with per-stage idempotency and buffered publication after the final stage; every abandonment path retires the residence through the lifetime choke point and converges the ownership ledger (executor progress, deferred buckets, replay windows, placement watches all folded into IsConverged). Position/placement side effects are exactly-once under retry, external mutations are detected via a field-masked executor baseline, and AwaitingContinuationPlacement yields keep the FIFO head retryable. Production routes are deliberately untouched: graphical and headless Create still use legacy RegisterEntity, and no host calls Execute. The cutover is the next checkpoint; AP-1/AD-1 remain open until it lands. Register rows AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 document the slice's deviations in this commit. Reviewed: retail-conformance PASS + architecture/adversarial PASS after five implementation rounds (wire-contact source, snapshot lockstep, WeenieDescription merge, abandonment convergence, reentrant retirement windows, acknowledged-completion leak, baseline precision, replay containment/restore, queue-by-parent-GUID relation deferral all fixed at root cause). Runtime tests 903/903; complete Release solution 10,696 passed / 4 intentional skips; focused executor gate 161/161. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 13 +- .../Entities/InboundPhysicsStateController.cs | 589 ++- .../Entities/ParentAttachmentState.cs | 350 +- .../Entities/RuntimeEntityDirectory.cs | 95 + .../Entities/RuntimeEntityObjectLifetime.cs | 87 +- ...untimeInitialCreateContinuationExecutor.cs | 2107 ++++++++ .../RuntimeInitialCreateResidenceState.cs | 313 +- .../InboundPhysicsStateControllerTests.cs | 70 + ...eInitialCreateContinuationExecutorTests.cs | 4464 +++++++++++++++++ 9 files changed, 8002 insertions(+), 86 deletions(-) create mode 100644 src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs create mode 100644 tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0e5decb4..35d2cf94 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 44 active rows +## 2. Adaptation (AD) — 46 active rows (AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -144,10 +144,12 @@ readiness/requeue adaptation. See | AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` | | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | +| AD-59 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The `SameIncarnationCreate` envelope buffers one publish per committed stage and flushes them ALL, in stage order, only after the LAST stage commits (constant-true per-field predicate, `IsCurrent`-checked at flush - the per-field closure variant was invalidated by WeenieDescription's six-field `AdvanceCreateAuthority`). A subscriber sees N back-to-back events with no interleaved observation point, each carrying the FINAL merged post-envelope record state, not per-stage state. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyEnvelope` buffered-publish tail; `Publish`/`PublishNow`) | Retail's own tail is one synchronous critical section, and retail emits ONE notice per Create (`ECM_Physics::SendNotice_CreateObject`, fired whenever a weenie exists, independent of the physics-registration outcome) - never N per-internal-step notices. The buffered flush is closer to retail's one-signal model than per-step publication would be, though not a literal 1:1 match. | A subscriber diffing consecutive `Updated` events from the SAME envelope to isolate one stage's delta gets every stage's cumulative state on each event - silently wrong incremental-diff logic, not a crash. | `SmartBox::HandleCreateObject` 0x00454C80 same-incarnation tail (one synchronous critical section); `ACCObjectMaint::CreateObject` 0x00558870 step 11 (`ECM_Physics::SendNotice_CreateObject`) | +| AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** Executor Position-continuation merges never directly commit residency: `ApplyPositionAction` refreshes `canonical.Snapshot.Position` with the retained wire pose but withholds the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`); only a Runtime `SetPosition` commit (the continuation's own classified placement) or a later simulation full-cell commit may change residency. The LEGACY immediate-apply path's `RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)` (`RuntimeEntityObjectLifetime.cs:1338`) still derives `FullCellId` from bare wire acceptance - that coarser rule is part of the AP-1 divergence this campaign is removing, not something this row blesses. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment) | Matches retail exactly: `HandleReceivedPosition` never writes a resident cell - `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. | If a future change passes `refreshPosition: true` here, a wire Position would make a cellless canonical body resident without any placement/collision commit - the classic AP-1-shaped bug this campaign exists to close. | `SmartBox::HandleReceivedPosition` 0x00453FD0; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | --- -## 3. Documented approximation (AP) — 87 active rows (AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 90 active rows (AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -264,8 +266,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) | | ~~AP-127~~ | **RETIRED 2026-07-31 (#268).** `PlayerSkillMath` now owns retail `CACQualities::InqSkill` ordering for both panel values and Runtime run/jump prediction: intrinsic + positive 0x16D all-skills + the exact +10 category switch, then `EnchantSkill`, then 0x146 Jack of All Trades +5 and specialized-only `2 × 0x158`. Live player PropertyInt changes refresh the immutable Runtime augmentation snapshot. The separately described current-stamina local-copy nuance was re-audited: the query reads current stamina, but ordinary max-vital buffs target the max-secondary key and do not create stamina when current is zero; no independently observable residual remains. | `src/AcDream.Core/Player/PlayerSkillMath.cs`; `src/AcDream.Core/Player/LocalPlayerState.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs`; `src/AcDream.Runtime/Session/LiveSessionEventRouter.cs` | — | — | `CACQualities::InqSkill @ 0x00592660`; `CACQualities::InqRunRate @ 0x00592800`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0` | | AP-128 | **PK-timer jump-cost clock basis unconfirmed** (filed at the P3 Opus review, 2026-07-30): `PlayerWeenie.JumpStaminaCost` evaluates retail's 20-second PK-recency window (`LastPkAttackTimestamp` PropertyFloat 0x91 + 20.0 >= now) against `Environment.TickCount64` process-uptime seconds. The magnitude argument is sound (a 32-bit float cannot hold a Unix epoch with sub-second precision — a conformance test caught the ±128 s swallow), but the wire timestamp's own basis is the SERVER's, so a cross-base compare is latent. INERT today: ACE models neither property, so `_lastPkAttackTimestamp` is never pushed and the branch never fires. | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost` remarks) | Branch unreachable against every ACE-family server; non-PK cost is bit-identical to pre-P3. The basis question is cdb-answerable (`Timer::cur_time` epoch) if a PK server is ever targeted. | Against a hypothetical server that sends PropertyFloat 0x91, the PK cost bump fires arbitrarily (always/never) instead of on the 20-second window. | `CACQualities::JumpStaminaCost 0x00591b90` pc 412934-412968; `Timer::cur_time`; stat-coupled pseudocode doc §12b | +| AP-130 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The classifier's `HasAnimations` input is the static proxy `(Snapshot.MotionTableId ?? Snapshot.Physics?.MotionTableId) != 0` - "does the Create carry a nonzero motion table" - uniformly for every position source. Retail's `HasAnims` bit is live animation-QUEUE non-emptiness (`CSequence::has_anims` = `anim_list.head_ != 0`), which can differ from mere table assignment. The only confirmed retail `HasAnims` call site on this path is inside `HandleReceivedPosition` itself. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, `hasAnimations` local) | Best static proxy available without wiring a live animation-queue read into presentation-independent Position classification; deterministic and testable; gates only `ApplyPlacementFrameBeforeRouting` (placement-FRAME install), never pose or cell placement. | An entity with an assigned motion table but an empty animation queue (or vice versa) gets the wrong placement-frame decision - a one-frame animation-blend glitch on a Position-driven correction where retail would have done the opposite. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `HasAnims` gate, pseudo-C ~92992); `CPhysicsObj::HasAnims` 0x0050F770 -> `CSequence::has_anims` 0x00524BD0 | +| AP-131 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The legacy Position merge (`TryApplyPosition`, today's ONLY production Position wire caller) passes `installPlacementFrame: true, clearParent: true` to the shared `ApplyAcceptedPosition` body - byte-identical to its pre-refactor unconditional behavior. Retail gates `SetPlacementFrame` on `!HasAnims` and skips `unset_parent`/`SetPlacementFrame` entirely on the FORCE_POSITION early return (Gate A); the continuation executor's caller threads the classified route's real flags and is retail-exact. | `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs` (`TryApplyPosition` call site) | Exact pre-existing production behavior, deliberately unchanged by the executor slice; the retail-gated behavior exists in the same shared body and is exercised by the executor's tests. The legacy caller is deleted at the production cutover, retiring this row by construction. | Until cutover, an animated entity's ordinary Position update installs a placement frame retail would skip (animation snap/reset), and a ForcePosition on a parented entity unparents where retail's Gate A never reaches `unset_parent`. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `!HasAnims` `SetPlacementFrame` gate ~92992; the FORCE_POSITION early return ~92932 before `unset_parent` ~92990) | +| AP-132 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** acdream gates queued parent relations on parent INCARNATION where retail's queue-by-GUID replay is pointer-only. Retail queues a missing-parent relation blob under the PARENT's GUID (`QueueBlobForObject` ~92326; GUID-keyed `CObjectMaint` placeholder bucket ~271082-271088) and replays it on GUID (re)creation with only an addressability check (~92312) - no PARENT INSTANCE_TS comparison anywhere on that path (retail's only instance check there is on the CHILD, ~92316-92317). acdream additionally compares the relation's `ParentInstanceSequence` at admission (pre-existing `TryApplyParent`/`Resolve` rules) and at executor replay (`ApplyReplayedParentRelation`): live-parent-newer discards, relation-newer stays queued for an exact match. The replay's child-missing arm also drops where retail would re-queue under the child's GUID; child-scoped bucket filtering (`RemoveObject`/`RemoveChild`) proactively covers the same ledger tradeoff. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyReplayedParentRelation`); `RuntimeEntityObjectLifetime.cs` (`TryApplyParent` admission gate); `ParentAttachmentState.cs` (`Resolve` staleness rules) | The wire event names a SPECIFIC parent incarnation (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. acdream's own established admission-time rules (`ParentAttachmentState.Resolve`, predating this slice) already fixed incarnation-gating as the project's parent-staleness posture; the replay path only extends that SAME posture for consistency. | Server GUID reuse between admission and replay: retail would attach the old queued relation to whatever NEW object now holds the GUID (retail's own recycling quirk); acdream discards it (parent newer) or leaves it queued (parent older) - silent loss of a relation retail would have applied, tied to server GUID-recycling cadence, not ordinary play. | Standalone parent handler 0x004535D0 (~92310-92326); `CObjectMaint::QueueBlobForObject` 0x005092D0 (~271082-271088); child instance check ~92316-92317 | -## 4. Temporary stopgap (TS) — 34 active rows (TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -309,6 +314,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-60 | No 140 s dead-link declaration or referral auto-reconnect in the transport; a silent server is only visible through `LinkStatusSnapshot.SecondsSinceLastPacket` (presentational). | `src/AcDream.Core.Net/Transport/ReliableTransport.cs`; `src/AcDream.Core.Net/WorldSession.cs` (`BuildLinkStatus`) | The input (seconds since last inbound) is already exposed; session lifecycle/reconnect is Runtime's ownership domain and deserves its own campaign rather than a transport-embedded side effect. Every ACE transport death is silence, so nothing server-side depends on the client reacting at 140 s. | A dead link idles until the user acts; no automatic recall/referral reconnect where retail would attempt one. | `ClientNet::ProcessConnection @ 0x00545450` tail (the two 140.0 literals) | | TS-61 | A UDP send failure burns the reliable sequence and its ISAAC word (the encode commits before `_net.Send`); retail keeps the sealed packet at the queue head and retries with the same key. | `src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs` (`SendGameMessage`) | A connectionless-socket `SendTo` failure is effectively unreachable in practice (no route/ICMP errors surface on later receives, not sends, on Windows UDP); recovering it faithfully needs a full outbound packet queue. The N1 review accepted the exposure explicitly. | One `SocketException` on send would desync the outbound cipher permanently (session death; observable as `[net-out-EX]` followed by silence). | `FlowQueue::TransmitNewPackets @ 0x00547C2C` (retry-from-head) | | TS-56 | Chase-camera mouse input retains acdream's invented post-filter yaw/pitch scalars (`0.004`/`0.003` radians per count), and held-key pitch/zoom retain their non-retail integration shapes. Retail mouse look passes `FilterMouseInput(delta) × configured sensitivity × 1/15` as the replacement scale to `CameraSet::Rotate`, which then applies the shared 8° angle; retail held pitch uses the same angle and zoom scales the viewer offset multiplicatively. | `src/AcDream.App/Input/CameraPointerInputController.cs`; `src/AcDream.App/Input/MouseLookController.cs`; `src/AcDream.App/Rendering/CameraFrameController.cs` | Slice 8 is behavior-preserving ownership work. The named-retail audit proves the mismatch but has not yet extracted the configured mouse-sensitivity default or the exact caller flags needed for a complete feel port; changing only one scalar here would create a mixed input model. | RMB/MMB orbit, held pitch, and zoom can feel slower, faster, or differently accelerated than retail even though callback ordering and filtering are correct. | `CameraSet::Rotate @ 0x00458310`; `CameraSet::MouseLookHandler` call at `0x00458EF9`; `CameraSet::Raise @ 0x00457B00`; `CameraSet::Closer @ 0x004586D0`; `docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md` | +| TS-62 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** NO Position route in the dormant executor runs a live `ConstrainTo` binding - including the `SetPosition`/`SetPositionSimple` routes. `RuntimeAuthoritativePositionRoute.ConstrainPhase` (None/Before/After) is classified for EVERY accepted route and recorded into the execution trace, but the constrain-before-vs-after distinction exists purely as classified metadata pending a live binding at the production cutover. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`/`BuildPositionTrace`); `RuntimeAuthoritativePositionRouteClassifier.cs` (`ConstrainPhase`) | Host-cutover work with no Runtime-side owner to bind to yet; the canonical snapshot's Position IS refreshed on every accepted route, so the fact is retained - only the live constrain/smoothing behavior is deferred. The trace carries the exact phase a host must bind. | Until a host wires it, ANY Position continuation applies its raw pose with no constrain-distance clamp or smoothing - a visible pop instead of retail's constrained correction, on exactly the entities created while an authored placement was in flight. | `SmartBox::HandleReceivedPosition` 0x00453FD0, the three `ConstrainTo` sites (~93007 remote-after, ~93024 teleport-after, ~93041 local-ordinary-before) | +| TS-63 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** `ApplyResidentCellCleanup`'s three branches: (1) claimed-cell + celless + NOT under lost-cell/deferred ownership - retail's genuine `AddObjectToBeDestroyed` case - has no safe Runtime destruction owner yet, so the executor performs a typed ABANDONMENT (`RejectedAuthority`) instead of destroying; (2) claimed + celless + deferred returns `DeferredUnderLostCellOwnership` - retail's destruction bookkeeping for this exact entity is already owned by the lost-cell/deferred `SetPosition` lifetime (a statement, not a parallel mechanism); (3) claimedCell==0 returns `CelllessNoWeenieMarkUnreachable` and is NOT a divergence - every admitted envelope structurally carries a WeenieDescription (`HasValidShape`), so retail's no-weenie destruction alternative is unreachable through this construction. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyResidentCellCleanup`; the Abandon conversion in `ApplyEnvelope`) | No production caller yet; every branch is typed and test-observable; building a parallel destruction mechanism ahead of the object-table/lost-cell cutover wiring would be the exact workaround class CLAUDE.md forbids - failing closed is the honest interim. | Branch (1): a genuinely claimed-but-celless-undeferred entity aborts the drain and SURVIVES where retail destroys it, until the cutover wiring lands. Branch (3): a future envelope construction without a WeenieDescription would break the premise and needs re-examination. | `SmartBox::HandleCreateObject` 0x00454C80 tail (~93933 destruction mark; ~93942-93943 un-mark/no-weenie) | --- diff --git a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs index 8f7674f0..ac548d0e 100644 --- a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs +++ b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs @@ -114,6 +114,48 @@ public sealed class InboundPhysicsStateController return false; } + accepted = ApplyAcceptedObjDesc(old, update); + _snapshots[update.Guid] = accepted; + return true; + } + + /// + /// Shared ObjDesc snapshot mutation. A retained residence continuation was + /// only ever enqueued after the exact same + /// call already + /// succeeded at admission time, so the retained update's own + /// IS the stamped gate + /// value; the executor must not re-derive it from a live gate. + /// + /// + /// Instance seam for : reads + /// _snapshots[guid] as the merge base and writes the result back, + /// keeping this store and the continuation executor's canonical + /// RuntimeEntityRecord.Snapshot in lockstep (Round 3 A1). Without + /// this seam the executor merged directly against the record's own + /// snapshot and never touched _snapshots, so the FIRST later + /// legacy TryApplyXxx call would re-merge onto a stale base and + /// silently revert every drained continuation. + /// + internal bool ApplyAcceptedObjDescSnapshot( + uint guid, + ObjDescEvent.Parsed update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedObjDesc(old, update); + _snapshots[guid] = accepted; + return true; + } + + internal static WorldSession.EntitySpawn ApplyAcceptedObjDesc( + WorldSession.EntitySpawn old, + ObjDescEvent.Parsed update) + { PhysicsSpawnData? physics = old.Physics; if (physics is { } desc) physics = desc with @@ -121,7 +163,7 @@ public sealed class InboundPhysicsStateController Timestamps = desc.Timestamps with { ObjDesc = update.ObjDescSequence }, }; - accepted = old with + return old with { AnimPartChanges = update.ModelData.AnimPartChanges, TextureChanges = update.ModelData.TextureChanges, @@ -129,8 +171,6 @@ public sealed class InboundPhysicsStateController BasePaletteId = update.ModelData.BasePaletteId, Physics = physics, }; - _snapshots[update.Guid] = accepted; - return true; } public bool TryApplyPickup( @@ -145,11 +185,33 @@ public sealed class InboundPhysicsStateController return false; } - accepted = ApplyUnparentedPosition(old, null, update.PositionSequence); + accepted = ApplyAcceptedPickup(old, update); _snapshots[update.Guid] = accepted; return true; } + internal static WorldSession.EntitySpawn ApplyAcceptedPickup( + WorldSession.EntitySpawn old, + PickupEvent.Parsed update) => + ApplyUnparentedPosition(old, null, update.PositionSequence); + + /// Instance seam for - see the + /// remarks on . + internal bool ApplyAcceptedPickupSnapshot( + uint guid, + PickupEvent.Parsed update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedPickup(old, update); + _snapshots[guid] = accepted; + return true; + } + /// /// Applies the parent branch embedded in a same-generation PhysicsDesc. /// Unlike standalone ParentEvent it carries no parent INSTANCE_TS, so only @@ -167,11 +229,33 @@ public sealed class InboundPhysicsStateController return false; } - accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence); + accepted = ApplyAcceptedCreateParent(child, update); _snapshots[update.ChildGuid] = accepted; return true; } + internal static WorldSession.EntitySpawn ApplyAcceptedCreateParent( + WorldSession.EntitySpawn child, + CreateParentUpdate update) => + ApplyPositionTimestampOnly(child, update.ChildPositionSequence); + + /// Instance seam for - + /// see the remarks on . + internal bool ApplyAcceptedCreateParentSnapshot( + uint guid, + CreateParentUpdate update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedCreateParent(old, update); + _snapshots[guid] = accepted; + return true; + } + public bool TryApplyParent( ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted) @@ -186,11 +270,33 @@ public sealed class InboundPhysicsStateController return false; } - accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence); + accepted = ApplyAcceptedParent(child, update); _snapshots[update.ChildGuid] = accepted; return true; } + internal static WorldSession.EntitySpawn ApplyAcceptedParent( + WorldSession.EntitySpawn child, + ParentEvent.Parsed update) => + ApplyPositionTimestampOnly(child, update.ChildPositionSequence); + + /// Instance seam for - see the + /// remarks on . + internal bool ApplyAcceptedParentSnapshot( + uint guid, + ParentEvent.Parsed update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedParent(old, update); + _snapshots[guid] = accepted; + return true; + } + public bool TryCommitParent( uint childGuid, uint parentGuid, @@ -235,17 +341,28 @@ public sealed class InboundPhysicsStateController update.MovementSequence, update.ServerControlSequence); timestamps = Current(gate); - WorldSession.EntitySpawn stamped = MirrorGateTimestamps(old, gate) with - { - MovementSequence = gate.MovementTimestamp, - ServerControlSequence = gate.ServerControlledMoveTimestamp, - }; - _snapshots[update.Guid] = stamped; // Retail consumes MOVEMENT_TS before it discovers that the - // SERVER_CONTROLLED_MOVE_TS is stale. Preserve that timestamp-only - // mutation in the canonical snapshot even though no motion payload - // is applied. + // SERVER_CONTROLLED_MOVE_TS is stale (PhysicsTimestampGate. + // TryAcceptMovementEvent checks MOVEMENT_TS first and always advances + // it before the SERVER_CONTROLLED_MOVE_TS check can fail). Preserve + // that timestamp-only mutation in the canonical snapshot even though + // no motion payload is applied. Stamp from the GATE's post-call + // values, not the wire's own proposed values: TryAcceptMovementEvent + // has THREE rejection flavors (bad instance; stale MOVEMENT_TS; stale + // SERVER_CONTROLLED_MOVE_TS) and only the last one actually advances + // MOVEMENT_TS. Stamping update.MovementSequence unconditionally would + // move the snapshot to a rejected packet's value in the first two + // flavors - gate.MovementTimestamp is a no-op there and correct in + // the third, exactly like this method's legacy predecessor. + WorldSession.EntitySpawn stamped = ApplyAcceptedMotion( + old, + gate.MovementTimestamp, + gate.ServerControlledMoveTimestamp, + update, + retainPayload: false); + _snapshots[update.Guid] = stamped; + if (!applyPayload) { accepted = default; @@ -258,6 +375,76 @@ public sealed class InboundPhysicsStateController return true; } + accepted = ApplyAcceptedMotion( + stamped, + gate.MovementTimestamp, + gate.ServerControlledMoveTimestamp, + update, + retainPayload: true); + _snapshots[update.Guid] = accepted; + return true; + } + + /// + /// Shared Movement snapshot mutation. The top-level and nested + /// Movement/ServerControlledMove timestamp fields are ALWAYS stamped to + /// exactly / + /// (regardless of ); only the actual + /// movement payload (raw bytes/MotionState) is gated on it. This + /// deliberately does NOT mirror every OTHER timestamp channel from a + /// live gate the way the legacy path's old MirrorGateTimestamps + /// helper did (redundant there, since gate and snapshot stay in + /// lockstep on the immediate-apply path, but wrong for the executor's + /// out-of-band residence replay, where other channels may have advanced + /// far beyond what THIS retained continuation is allowed to observe). + /// + /// The movement/server-control VALUES are explicit inputs, not derived + /// from internally - one shared apply body, + /// two different sources of truth for its two callers. The legacy + /// immediate-apply caller MUST pass the live gate's own post-call + /// MovementTimestamp/ServerControlledMoveTimestamp + /// (PhysicsTimestampGate.TryAcceptMovementEvent has three rejection + /// flavors - bad instance, stale MOVEMENT_TS, stale + /// SERVER_CONTROLLED_MOVE_TS - and only reading the gate AFTER the call + /// is a no-op in the first two and correct in the third; the wire's own + /// proposed + /// would silently corrupt the snapshot to a rejected packet's value in + /// the first two flavors). The continuation executor instead passes the + /// retained action's own Movement.Value.MovementSequence/ + /// - safe + /// there specifically because a Movement continuation is only ever + /// retained when AppliesMovementPayload || HasTimestampMutation, + /// which structurally guarantees MOVEMENT_TS itself already advanced to + /// that exact wire value at admission time (the same three-flavor gate + /// logic that makes stamping the wire value unsafe for an UNGATED legacy + /// call makes it exactly correct for an ALREADY-GATED retained one). + /// + internal static WorldSession.EntitySpawn ApplyAcceptedMotion( + WorldSession.EntitySpawn old, + ushort movementSequence, + ushort acceptedServerControlledMove, + WorldSession.EntityMotionUpdate update, + bool retainPayload) + { + PhysicsSpawnData? stampedPhysics = old.Physics; + if (stampedPhysics is { } stampedDesc) + stampedPhysics = stampedDesc with + { + Timestamps = stampedDesc.Timestamps with + { + Movement = movementSequence, + ServerControlledMove = acceptedServerControlledMove, + }, + }; + WorldSession.EntitySpawn stamped = old with + { + MovementSequence = movementSequence, + ServerControlSequence = acceptedServerControlledMove, + Physics = stampedPhysics, + }; + if (!retainPayload) + return stamped; + PhysicsSpawnData? physics = stamped.Physics; if (physics is { } desc) physics = desc with @@ -266,19 +453,37 @@ public sealed class InboundPhysicsStateController ReadOnlyMemory.Empty, update.MotionState, update.IsAutonomous), - Timestamps = desc.Timestamps with - { - Movement = gate.MovementTimestamp, - ServerControlledMove = gate.ServerControlledMoveTimestamp, - }, }; - accepted = stamped with + return stamped with { MotionState = update.MotionState, Physics = physics, }; - _snapshots[update.Guid] = accepted; + } + + /// Instance seam for - see the + /// remarks on . + internal bool ApplyAcceptedMotionSnapshot( + uint guid, + ushort movementSequence, + ushort acceptedServerControlledMove, + WorldSession.EntityMotionUpdate update, + bool retainPayload, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedMotion( + old, + movementSequence, + acceptedServerControlledMove, + update, + retainPayload); + _snapshots[guid] = accepted; return true; } @@ -293,6 +498,15 @@ public sealed class InboundPhysicsStateController return false; } + accepted = ApplyAcceptedVector(old, update); + _snapshots[update.Guid] = accepted; + return true; + } + + internal static WorldSession.EntitySpawn ApplyAcceptedVector( + WorldSession.EntitySpawn old, + VectorUpdate.Parsed update) + { PhysicsSpawnData? physics = old.Physics; if (physics is { } desc) physics = desc with @@ -302,8 +516,23 @@ public sealed class InboundPhysicsStateController Timestamps = desc.Timestamps with { Vector = update.VectorSequence }, }; - accepted = old with { Physics = physics }; - _snapshots[update.Guid] = accepted; + return old with { Physics = physics }; + } + + /// Instance seam for - see the + /// remarks on . + internal bool ApplyAcceptedVectorSnapshot( + uint guid, + VectorUpdate.Parsed update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedVector(old, update); + _snapshots[guid] = accepted; return true; } @@ -318,6 +547,15 @@ public sealed class InboundPhysicsStateController return false; } + accepted = ApplyAcceptedState(old, update); + _snapshots[update.Guid] = accepted; + return true; + } + + internal static WorldSession.EntitySpawn ApplyAcceptedState( + WorldSession.EntitySpawn old, + SetState.Parsed update) + { PhysicsSpawnData? physics = old.Physics; if (physics is { } desc) physics = desc with @@ -326,12 +564,27 @@ public sealed class InboundPhysicsStateController Timestamps = desc.Timestamps with { State = update.StateSequence }, }; - accepted = old with + return old with { PhysicsState = update.PhysicsState, Physics = physics, }; - _snapshots[update.Guid] = accepted; + } + + /// Instance seam for - see the + /// remarks on . + internal bool ApplyAcceptedStateSnapshot( + uint guid, + SetState.Parsed update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedState(old, update); + _snapshots[guid] = accepted; return true; } @@ -339,6 +592,20 @@ public sealed class InboundPhysicsStateController /// Returns true when the addressed live incarnation exists, even when the /// position payload is rejected. This lets callers publish a freshly /// consumed FORCE_POSITION_TS without applying a stale pose. + /// + /// Round 4 R4-15: this legacy immediate-apply path has no HasContact or + /// route-classification concept at all - it merges unconditionally on + /// the retained alone. The + /// continuation executor's ApplyPositionAction instead runs + /// RuntimeAuthoritativePositionRouteClassifier and derives + /// contact solely from the retained wire packet's own + /// IsGrounded bit. This is internal refactor debt tracked for + /// the eventual cutover unification (this file's TryApplyPosition + /// is today's only PRODUCTION Position wire caller; the classifier-based + /// path is test-only until a host wires the executor) - it is NOT a + /// retail divergence and does not belong in + /// docs/architecture/retail-divergence-register.md. See docs/ISSUES.md + /// for the tracked follow-up. /// public bool TryApplyPosition( WorldSession.EntityPositionUpdate update, @@ -370,12 +637,153 @@ public sealed class InboundPhysicsStateController gate, teleportAdvanced: disposition is PositionTimestampDisposition.Apply && advancesTeleport); - if (disposition is PositionTimestampDisposition.Rejected) + accepted = ApplyAcceptedPosition( + old, + update, + disposition, + timestamps, + isLocalPlayer, + forcePositionRotation, + currentLocalVelocity, + // Legacy immediate-apply reproduces EXACT prior behavior: the + // placement frame and parent clear were always unconditional + // here (see the Round 3 A1/B6 admission handoff). Only the + // continuation executor threads the classified route's own + // ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting flags. + installPlacementFrame: true, + clearParent: true); + _snapshots[update.Guid] = accepted; + return true; + } + + /// Instance seam for - see + /// the remarks on . The + /// executor passes its classified route's own + /// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting + /// flags rather than the legacy path's unconditional true/true. + internal bool ApplyAcceptedPositionSnapshot( + uint guid, + WorldSession.EntityPositionUpdate update, + PositionTimestampDisposition disposition, + AcceptedPhysicsTimestamps timestamps, + bool isLocalPlayer, + System.Numerics.Quaternion? forcePositionRotation, + System.Numerics.Vector3? currentLocalVelocity, + bool installPlacementFrame, + bool clearParent, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) { - accepted = MirrorGateTimestamps(old, gate); - _snapshots[update.Guid] = accepted; - return true; + accepted = default; + return false; } + accepted = ApplyAcceptedPosition( + old, + update, + disposition, + timestamps, + isLocalPlayer, + forcePositionRotation, + currentLocalVelocity, + installPlacementFrame, + clearParent); + _snapshots[guid] = accepted; + return true; + } + + /// + /// Round 3 B10: a retained Position action whose ADMISSION-time + /// disposition was Apply/ForcePosition (the gate genuinely advanced + /// POSITION_TS/TELEPORT_TS/FORCE_POSITION_TS at admission) but whose + /// EXECUTION-time classification rejects (RejectedAuthority/RejectedData + /// from the live route classifier - e.g. a malformed live input) must + /// still stamp every timestamp channel the gate actually moved; it must + /// not silently freeze the snapshot at pre-admission values. Distinct + /// from , which is the + /// ADMISSION-time-gate-rejected case where only FORCE_POSITION_TS can + /// have moved - here Position, Teleport, AND ForcePosition are all + /// replayed, still without installing any pose/parent/placement field. + /// + internal bool ApplyAcceptedPositionExecutionRejectedSnapshot( + uint guid, + ushort acceptedPositionSequence, + AcceptedPhysicsTimestamps timestamps, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + PhysicsSpawnData? physics = old.Physics; + if (physics is { } desc) + physics = desc with + { + Timestamps = desc.Timestamps with + { + Position = acceptedPositionSequence, + Teleport = timestamps.Teleport, + ForcePosition = timestamps.ForcePosition, + }, + }; + accepted = old with + { + PositionSequence = acceptedPositionSequence, + Physics = physics, + }; + _snapshots[guid] = accepted; + return true; + } + + /// + /// Shared Position snapshot mutation, reconstructed from the RETAINED + /// disposition + accepted-gate facts rather than a live gate read (the + /// executor drains a Position continuation long after the timestamp gate + /// itself moved on to later packets). Mirrors + /// SmartBox::HandleReceivedPosition (0x00453FD0) / + /// PositionPack::UnPack (0x00516740) exactly as the legacy + /// immediate-apply path did. + /// + /// A retained + /// continuation only ever exists because + /// -adjacent + /// bookkeeping mutated (see : + /// a Rejected outcome always leaves POSITION_TS and TELEPORT_TS net + /// unchanged, so the ONLY dimension that can differ is FORCE_POSITION_TS + /// from the local-player force-position fallthrough branch) — apply the + /// timestamp-only mutation and nothing else. + /// + /// For Apply/ForcePosition, the retained wire's own + /// IS the + /// stamped POSITION_TS value (PhysicsTimestampGate always sets the stored + /// channel to exactly the incoming value on acceptance); Teleport and + /// ForcePosition stamps come from the retained + /// captured at admission time. + /// + /// / + /// (Round 3 B6) let the two callers reproduce two different retail + /// gates: the legacy immediate-apply path always passes true/true + /// (retail's HandleReceivedPosition unconditionally runs + /// unset_parent/SetPlacementFrame there), while the continuation + /// executor passes its classified route's own + /// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting - + /// both false only for the FORCE_POSITION branch, which retail's + /// MoveOrTeleport returns from immediately, BEFORE either call. + /// + internal static WorldSession.EntitySpawn ApplyAcceptedPosition( + WorldSession.EntitySpawn old, + WorldSession.EntityPositionUpdate update, + PositionTimestampDisposition disposition, + AcceptedPhysicsTimestamps timestamps, + bool isLocalPlayer, + System.Numerics.Quaternion? forcePositionRotation, + System.Numerics.Vector3? currentLocalVelocity, + bool installPlacementFrame, + bool clearParent) + { + if (disposition is PositionTimestampDisposition.Rejected) + return ApplyAcceptedPositionTimestampOnly(old, timestamps); CreateObject.ServerPosition appliedPosition = update.Position; if (disposition is PositionTimestampDisposition.ForcePosition @@ -392,9 +800,14 @@ public sealed class InboundPhysicsStateController // PositionPack::UnPack (0x00516740) initializes an absent placement // id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact - // value to SetPlacementFrame on a normal accepted update. - uint? appliedPlacement = disposition is PositionTimestampDisposition.Apply - ? update.PlacementId ?? 0u + // value to SetPlacementFrame on a normal accepted update - but only + // when the caller's route actually runs that step + // (installPlacementFrame; retail skips it entirely while HasAnimations + // is true). + uint? appliedPlacement = installPlacementFrame + ? (disposition is PositionTimestampDisposition.Apply + ? update.PlacementId ?? 0u + : old.PlacementId) : old.PlacementId; System.Numerics.Vector3? appliedVelocity = disposition switch @@ -407,7 +820,7 @@ public sealed class InboundPhysicsStateController // A fresh local teleport explicitly installs zero velocity. A // normal local correction does not consume PositionPack velocity. PositionTimestampDisposition.Apply when isLocalPlayer => - advancesTeleport + timestamps.TeleportAdvanced ? System.Numerics.Vector3.Zero : currentLocalVelocity ?? old.Physics?.Velocity, @@ -418,6 +831,22 @@ public sealed class InboundPhysicsStateController _ => old.Physics?.Velocity, }; + // Round 4 R4-12: FORCE_POSITION_TS's retail Gate A + // (retail-notes.md function 3, SmartBox::HandleReceivedPosition + // 0x00453FD0, "GATE A: local-player force-position self-echo + // shortcut") returns BEFORE CPhysicsObj::unset_parent ever runs - + // clearParent stays false on that route (Round 3 B6), so a + // ForcePosition-merged snapshot can legitimately carry BOTH a + // non-null Position (the force-applied pose, below) AND a non-null + // ParentGuid/ParentLocation/Physics.Parent (the retained + // attachment) simultaneously. This combined shape is deliberate, + // not a bug: every OTHER accepted disposition either clears the + // parent (a genuine unparented Position) or never touches Position + // at all (a Parent/CreateParent-only merge) - ForcePosition is the + // one case that does both without touching parent state at all. + uint? parentGuid = clearParent ? null : old.ParentGuid; + uint? parentLocation = clearParent ? null : old.ParentLocation; + PhysicsAttachment? physicsParent = clearParent ? null : old.Physics?.Parent; PhysicsSpawnData? physics = old.Physics; if (physics is { } desc) physics = desc with @@ -425,26 +854,47 @@ public sealed class InboundPhysicsStateController Position = appliedPosition, AnimationFrame = appliedPlacement, Velocity = appliedVelocity, - Parent = null, + Parent = physicsParent, Timestamps = desc.Timestamps with { - Position = gate.PositionTimestamp, - Teleport = gate.TeleportTimestamp, - ForcePosition = gate.ForcePositionTimestamp, + Position = update.PositionSequence, + Teleport = timestamps.Teleport, + ForcePosition = timestamps.ForcePosition, }, }; - accepted = old with + return old with { Position = appliedPosition, - PositionSequence = gate.PositionTimestamp, - ParentGuid = null, - ParentLocation = null, + PositionSequence = update.PositionSequence, + ParentGuid = parentGuid, + ParentLocation = parentLocation, PlacementId = appliedPlacement, Physics = physics, }; - _snapshots[update.Guid] = accepted; - return true; + } + + /// + /// The Rejected-disposition-but-mutated branch of + /// : only FORCE_POSITION_TS can have + /// legitimately moved (see that method's remarks). Deliberately narrower + /// than the legacy path's old full-gate mirror, which was only safe + /// in-lockstep and is unsound for the executor's out-of-band replay. + /// + private static WorldSession.EntitySpawn ApplyAcceptedPositionTimestampOnly( + WorldSession.EntitySpawn old, + AcceptedPhysicsTimestamps timestamps) + { + PhysicsSpawnData? physics = old.Physics; + if (physics is { } desc) + physics = desc with + { + Timestamps = desc.Timestamps with + { + ForcePosition = timestamps.ForcePosition, + }, + }; + return old with { Physics = physics }; } /// @@ -675,31 +1125,6 @@ public sealed class InboundPhysicsStateController TeleportHookRequired: false, previousTeleport); - private static WorldSession.EntitySpawn MirrorGateTimestamps( - WorldSession.EntitySpawn spawn, - PhysicsTimestampGate gate) - { - if (spawn.Physics is not { } desc) - return spawn; - - return spawn with - { - Physics = desc with - { - Timestamps = new PhysicsTimestamps( - gate.PositionTimestamp, - gate.MovementTimestamp, - gate.StateTimestamp, - gate.VectorTimestamp, - gate.TeleportTimestamp, - gate.ServerControlledMoveTimestamp, - gate.ForcePositionTimestamp, - gate.ObjDescTimestamp, - gate.InstanceTimestamp), - }, - }; - } - private static WorldSession.EntitySpawn MergeUntimestampedCreate( WorldSession.EntitySpawn retained, WorldSession.EntitySpawn incoming) => @@ -727,6 +1152,32 @@ public sealed class InboundPhysicsStateController Physics = retained.Physics, }; + /// + /// Instance seam for the SameIncarnationCreate envelope's + /// WeenieDescription stage (Round 3 A2). This must NOT be a wholesale + /// snapshot replacement of the raw retained packet - it merges exactly + /// like every other same-generation Create + /// (), keeping the retained + /// Position/appearance/physics-timestamp fields that earlier stages in + /// THIS envelope (and any earlier FIFO entry) already committed to + /// _snapshots, and taking only the incoming packet's untimestamped + /// identity/description fields. + /// + internal bool ApplyAcceptedWeenieDescriptionSnapshot( + uint guid, + WorldSession.EntitySpawn incoming, + out WorldSession.EntitySpawn merged) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn retained)) + { + merged = default; + return false; + } + merged = MergeUntimestampedCreate(retained, incoming); + _snapshots[guid] = merged; + return true; + } + private static SameGenerationCreateObjectEvents BuildSameGenerationEvents( WorldSession.EntitySpawn incoming) { diff --git a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs index 5405ecc7..1ec4a209 100644 --- a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs +++ b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -20,8 +21,53 @@ public sealed class ParentAttachmentState private readonly Dictionary> _committedChildrenByParent = new(); private readonly Dictionary> _deferredCreatesByParent = []; + /// + /// Round 5 R5-1: retail-faithful queue-by-parent-GUID deferral for an + /// ACCEPTED parent relation (standalone Parent continuation or envelope + /// CreateParent stage) whose parent is unaddressable or names a + /// not-yet-arrived incarnation. Shares the SAME per-guid "blobs waiting + /// on guid X" shape as - retail's + /// QueueBlobForObject/CObjectMaint bucket does not + /// distinguish a raw Create blob from any other blob type queued + /// against the same guid. + /// + private readonly Dictionary> + _deferredAcceptedRelationsByParent = []; private ulong _nextDeferredCreateAdmissionId; + /// + /// Round 5 R5-2: cancellation-aware detach/restore window state, shared + /// by BOTH deferred buckets. and + /// register one window + /// entry per detach; while it is open, every cancellation primitive + /// (, , + /// , , + /// ) ADDITIONALLY records its own retain + /// predicate into every currently-open window of the matching bucket + /// kind, so a later Restore call can apply the SAME filtering to the + /// detached remainder that would have applied had the batch never left + /// the live dictionary. wipes both window + /// dictionaries outright, which is what makes a stale token + /// (post-Clear/Dispose) restore nothing - the token's Id is simply + /// gone, an ABA-safe no-op via the same "TryRemove fails" pattern + /// already relies on. + /// + private sealed class CreateWindowState + { + internal required uint ParentGuid { get; init; } + internal List> Filters { get; } = []; + } + + private sealed class RelationWindowState + { + internal required uint ParentGuid { get; init; } + internal List> Filters { get; } = []; + } + + private readonly Dictionary _createWindows = []; + private readonly Dictionary _relationWindows = []; + private ulong _nextWindowId; + public int UnresolvedRelationCount => _unresolvedByChild.Values.Sum(queue => queue.Count); public int StagedRelationCount => _stagedByChild.Count; @@ -29,6 +75,8 @@ public sealed class ParentAttachmentState public int CommittedRelationCount => _lastAcceptedByChild.Count; internal int DeferredCreateCount => _deferredCreatesByParent.Values.Sum(queue => queue.Count); + internal int DeferredAcceptedRelationCount => + _deferredAcceptedRelationsByParent.Values.Sum(queue => queue.Count); /// /// Retains the complete unaccepted CreateObject packet when its nonzero @@ -102,6 +150,81 @@ public sealed class ParentAttachmentState return true; } + /// + /// Round 3 B7: retail's PartArray::add_child-owning CreateObject + /// handler detaches the ENTIRE queued netblob list for one parent + /// atomically before dispatching any of it (pseudo-C ~93617) - there is + /// no separate peek-then-remove step; the detach itself IS the consume. + /// Returns an empty array when nothing was queued. Structurally rules + /// out the stale-AdmissionId race the previous peek/consume replay loop + /// had to special-case: a Create arriving for this parent AFTER this + /// call enqueues into a brand-new queue instance, never the one already + /// removed here. Round 5 R5-2: opens a cancellation-aware window + /// () for the detached batch - see the window- + /// machinery remarks at this class's field declarations. + /// + internal ImmutableArray DetachDeferredCreates( + uint parentGuid, + out DeferredReplayWindowToken window) + { + if (!_deferredCreatesByParent.Remove( + parentGuid, + out Queue? queue)) + { + window = default; + return ImmutableArray.Empty; + } + ulong id = ++_nextWindowId; + _createWindows[id] = new CreateWindowState { ParentGuid = parentGuid }; + window = new DeferredReplayWindowToken(id, parentGuid, DeferredReplayBucketKind.Creates); + return [.. queue]; + } + + /// + /// Round 4 R4-1 / Round 5 R5-2: restores the unprocessed remainder of a + /// previously-detached replay batch, in original FIFO order and with + /// original values, at the + /// FRONT of the window's parent guid's queue - ahead of anything enqueued + /// for the same parent guid AFTER the detach. Every cancellation + /// primitive that fired WHILE this exact window was open recorded its + /// own retain predicate; those predicates are applied here before + /// re-insertion, so a child deleted (or otherwise cancelled) mid-replay + /// is never resurrected. ALWAYS call this once replay of the detached + /// batch concludes - successful or not - passing the empty remainder on + /// full success; this releases the window (a stale/already-released/ + /// Clear-invalidated token is an ABA-safe no-op, since its Id is simply + /// no longer tracked). + /// + internal void RestoreDeferredCreates( + in DeferredReplayWindowToken window, + ReadOnlySpan entries) + { + if (window.Kind != DeferredReplayBucketKind.Creates + || !_createWindows.Remove(window.Id, out CreateWindowState? state)) + { + return; + } + if (entries.Length == 0) + return; + IEnumerable filtered = entries.ToArray(); + foreach (Func filter in state.Filters) + filtered = filtered.Where(filter); + DeferredParentCreate[] survivors = filtered.ToArray(); + if (survivors.Length == 0) + return; + var restored = new Queue(survivors.Length); + foreach (DeferredParentCreate entry in survivors) + restored.Enqueue(entry); + if (_deferredCreatesByParent.TryGetValue( + window.ParentGuid, + out Queue? existing)) + { + foreach (DeferredParentCreate entry in existing) + restored.Enqueue(entry); + } + _deferredCreatesByParent[window.ParentGuid] = restored; + } + internal bool ContainsDeferredCreate( uint childGuid, ushort instanceSequence) @@ -119,18 +242,151 @@ public sealed class ParentAttachmentState return false; } + /// + /// Round 5 R5-1: retail-faithful replacement for the Round 4 discard - + /// a missing/not-yet-arrived parent QUEUES the accepted relation under + /// the PARENT's guid (standalone parent handler 0x004535D0 -> + /// QueueBlobForObject, pseudo-C 92326; GUID-keyed placeholder + /// bucket in CObjectMaint, 271082-271088) and replays it when + /// that guid is created, exactly like a raw missing-parent Create. + /// Shares the SAME monotonic AdmissionId source as + /// (never reset) - both buckets are + /// "blobs waiting on guid X," the same general retail mechanism. + /// + internal void EnqueueDeferredAcceptedRelation( + uint childGuid, + RuntimeEntityKey childKey, + ParentEvent.Parsed? standalone, + CreateParentUpdate? envelope, + AcceptedPhysicsTimestamps acceptedTimestamps) + { + uint parentGuid = standalone?.ParentGuid ?? envelope?.ParentGuid ?? 0u; + if (parentGuid == 0u || childGuid == 0u) + { + throw new ArgumentException( + "A deferred accepted parent relation requires nonzero parent and child GUIDs."); + } + if (_nextDeferredCreateAdmissionId == ulong.MaxValue) + { + throw new InvalidOperationException( + "The deferred parent CreateObject admission sequence is exhausted."); + } + ulong admissionId = _nextDeferredCreateAdmissionId + 1UL; + EnqueueDeferredAcceptedRelation(new DeferredAcceptedParentRelation( + admissionId, childGuid, childKey, standalone, envelope, acceptedTimestamps)); + _nextDeferredCreateAdmissionId = admissionId; + } + + /// + /// Re-enqueues an EXISTING relation verbatim, preserving its original + /// - used at + /// replay time when the relation still names a parent incarnation that + /// has not yet arrived (wait for the next matching incarnation). + /// + internal void EnqueueDeferredAcceptedRelation( + in DeferredAcceptedParentRelation relation) + { + uint parentGuid = relation.Standalone?.ParentGuid + ?? relation.Envelope?.ParentGuid + ?? 0u; + if (!_deferredAcceptedRelationsByParent.TryGetValue( + parentGuid, + out Queue? queue)) + { + queue = new Queue(); + _deferredAcceptedRelationsByParent.Add(parentGuid, queue); + } + queue.Enqueue(relation); + } + + /// Round 5 R5-2 window-aware detach - see 's remarks. + internal ImmutableArray DetachDeferredAcceptedRelations( + uint parentGuid, + out DeferredReplayWindowToken window) + { + if (!_deferredAcceptedRelationsByParent.Remove( + parentGuid, + out Queue? queue)) + { + window = default; + return ImmutableArray.Empty; + } + ulong id = ++_nextWindowId; + _relationWindows[id] = new RelationWindowState { ParentGuid = parentGuid }; + window = new DeferredReplayWindowToken(id, parentGuid, DeferredReplayBucketKind.AcceptedRelations); + return [.. queue]; + } + + /// Round 5 R5-2 window-aware restore - see 's remarks. + internal void RestoreDeferredAcceptedRelations( + in DeferredReplayWindowToken window, + ReadOnlySpan entries) + { + if (window.Kind != DeferredReplayBucketKind.AcceptedRelations + || !_relationWindows.Remove(window.Id, out RelationWindowState? state)) + { + return; + } + if (entries.Length == 0) + return; + IEnumerable filtered = entries.ToArray(); + foreach (Func filter in state.Filters) + filtered = filtered.Where(filter); + DeferredAcceptedParentRelation[] survivors = filtered.ToArray(); + if (survivors.Length == 0) + return; + var restored = new Queue(survivors.Length); + foreach (DeferredAcceptedParentRelation entry in survivors) + restored.Enqueue(entry); + if (_deferredAcceptedRelationsByParent.TryGetValue( + window.ParentGuid, + out Queue? existing)) + { + foreach (DeferredAcceptedParentRelation entry in existing) + restored.Enqueue(entry); + } + _deferredAcceptedRelationsByParent[window.ParentGuid] = restored; + } + + internal bool ContainsDeferredAcceptedRelation( + uint childGuid, + RuntimeEntityKey childKey) + { + foreach (Queue queue + in _deferredAcceptedRelationsByParent.Values) + { + if (queue.Any(candidate => + candidate.ChildGuid == childGuid + && candidate.ChildKey == childKey)) + { + return true; + } + } + return false; + } + /// /// Cancels only the raw, still-unaccepted child generation addressed by a /// terminal packet. Instance zero is a normal retail timestamp and is not - /// treated as an empty sentinel. + /// treated as an empty sentinel. Round 5 R5-1: also cancels a deferred + /// ACCEPTED relation for the same child incarnation - "child-addressed + /// candidates die with the child" applies identically to both buckets. /// internal void CancelDeferredChildGeneration( uint childGuid, - ushort terminalInstanceSequence) => FilterDeferredCreates( + ushort terminalInstanceSequence) + { + FilterDeferredCreates( candidate => candidate.Spawn.Guid != childGuid || PhysicsTimestampGate.IsNewer( terminalInstanceSequence, candidate.Spawn.InstanceSequence)); + FilterDeferredAcceptedRelations( + candidate => candidate.ChildGuid != childGuid + || PhysicsTimestampGate.IsNewer( + terminalInstanceSequence, + candidate.ChildKey.Incarnation)); + } public void AcceptCreateObjectRelation(ParentAttachmentRelation relation) { @@ -379,6 +635,7 @@ public sealed class ParentAttachmentState public void RemoveObject(uint guid) { RemoveDeferredChildCreates(guid); + RemoveDeferredAcceptedRelationsForChild(guid); _stagedByChild.Remove(guid); _recoveryByChild.Remove(guid); RemoveCommittedChild(guid); @@ -413,6 +670,12 @@ public sealed class ParentAttachmentState || PhysicsTimestampGate.IsNewer( replacementGeneration, candidate.Spawn.InstanceSequence)); + FilterDeferredAcceptedRelations(candidate => + candidate.ChildGuid != guid + || candidate.ChildKey.Incarnation == replacementGeneration + || PhysicsTimestampGate.IsNewer( + replacementGeneration, + candidate.ChildKey.Incarnation)); FilterChildCandidates( guid, relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); @@ -468,6 +731,7 @@ public sealed class ParentAttachmentState public void RemoveChild(uint childGuid) { RemoveDeferredChildCreates(childGuid); + RemoveDeferredAcceptedRelationsForChild(childGuid); _stagedByChild.Remove(childGuid); _recoveryByChild.Remove(childGuid); RemoveCommittedChild(childGuid); @@ -477,6 +741,12 @@ public sealed class ParentAttachmentState public void Clear() { _deferredCreatesByParent.Clear(); + _deferredAcceptedRelationsByParent.Clear(); + // Round 5 R5-2: wipes every open window outright - a later + // Restore call for a token minted before this Clear() finds + // nothing to remove by Id and correctly no-ops (ABA-safe). + _createWindows.Clear(); + _relationWindows.Clear(); _unresolvedByChild.Clear(); _stagedByChild.Clear(); _recoveryByChild.Clear(); @@ -490,6 +760,15 @@ public sealed class ParentAttachmentState => FilterDeferredCreates( candidate => candidate.Spawn.Guid != childGuid); + private void RemoveDeferredAcceptedRelationsForChild(uint childGuid) + => FilterDeferredAcceptedRelations( + candidate => candidate.ChildGuid != childGuid); + + /// + /// Round 5 R5-2: filters the LIVE bucket exactly as before, then + /// records the SAME retain predicate into every currently-open create + /// window so a later Restore applies it to the detached remainder too. + /// private void FilterDeferredCreates( Func retain) { @@ -504,6 +783,27 @@ public sealed class ParentAttachmentState else _deferredCreatesByParent[parentGuid] = retained; } + foreach (CreateWindowState state in _createWindows.Values) + state.Filters.Add(retain); + } + + /// Round 5 R5-2 relation-bucket counterpart of . + private void FilterDeferredAcceptedRelations( + Func retain) + { + uint[] parents = _deferredAcceptedRelationsByParent.Keys.ToArray(); + for (int index = 0; index < parents.Length; index++) + { + uint parentGuid = parents[index]; + Queue retained = new( + _deferredAcceptedRelationsByParent[parentGuid].Where(retain)); + if (retained.Count == 0) + _deferredAcceptedRelationsByParent.Remove(parentGuid); + else + _deferredAcceptedRelationsByParent[parentGuid] = retained; + } + foreach (RelationWindowState state in _relationWindows.Values) + state.Filters.Add(retain); } private void RemoveCommittedChild(uint childGuid) @@ -609,6 +909,52 @@ internal readonly record struct DeferredParentCreate( && Spawn.Guid != 0u; } +/// +/// Round 5 R5-1: one ACCEPTED parent relation (the gate was already +/// consumed - the child's own POSITION_TS channel advanced at admission) +/// queued under its parent's guid because that parent was unaddressable or +/// named a not-yet-arrived incarnation. Carries exactly ONE of +/// (a standalone Parent continuation, which HAS a +/// parent incarnation to compare) or (an envelope +/// CreateParent stage, which does not). +/// +internal readonly record struct DeferredAcceptedParentRelation( + ulong AdmissionId, + uint ChildGuid, + RuntimeEntityKey ChildKey, + ParentEvent.Parsed? Standalone, + CreateParentUpdate? Envelope, + AcceptedPhysicsTimestamps AcceptedTimestamps) +{ + internal bool IsValid => AdmissionId != 0UL + && ChildGuid != 0u + && ChildKey.LocalEntityId != 0u + && (Standalone.HasValue ^ Envelope.HasValue); + + /// Null for the envelope flavor - carries no parent INSTANCE_TS. + internal ushort? ParentInstanceSequence => Standalone?.ParentInstanceSequence; +} + +/// Round 5 R5-2: which deferred bucket a belongs to. +internal enum DeferredReplayBucketKind : byte +{ + Creates, + AcceptedRelations, +} + +/// +/// Round 5 R5-2: opaque handle for one open detach/restore window. See the +/// window-machinery remarks at 's field +/// declarations for the full cancellation-awareness contract. +/// +internal readonly record struct DeferredReplayWindowToken( + ulong Id, + uint ParentGuid, + DeferredReplayBucketKind Kind) +{ + internal bool IsValid => Id != 0UL; +} + public readonly record struct ParentAttachmentRelation( uint ParentGuid, uint ChildGuid, diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index f037dbf5..a9ac7b05 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -547,6 +547,101 @@ public sealed class RuntimeEntityDirectory public bool IsFreshTeleportStart(uint guid, ushort teleportSequence) => _inbound.IsFreshTeleportStart(guid, teleportSequence); + // Round 3 A1: gate-less instance seams for the initial-Create + // continuation executor. Each merges against _inbound's OWN + // _snapshots[guid] (never a caller-supplied base) and writes the result + // back, keeping this store and RuntimeEntityRecord.Snapshot in lockstep. + internal bool ApplyAcceptedObjDescSnapshot( + uint guid, + ObjDescEvent.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedObjDescSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedPickupSnapshot( + uint guid, + PickupEvent.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedPickupSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedCreateParentSnapshot( + uint guid, + CreateParentUpdate update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedCreateParentSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedParentSnapshot( + uint guid, + ParentEvent.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedParentSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedMotionSnapshot( + uint guid, + ushort movementSequence, + ushort acceptedServerControlledMove, + WorldSession.EntityMotionUpdate update, + bool retainPayload, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedMotionSnapshot( + guid, + movementSequence, + acceptedServerControlledMove, + update, + retainPayload, + out accepted); + + internal bool ApplyAcceptedStateSnapshot( + uint guid, + SetState.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedStateSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedVectorSnapshot( + uint guid, + VectorUpdate.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedVectorSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedPositionSnapshot( + uint guid, + WorldSession.EntityPositionUpdate update, + PositionTimestampDisposition disposition, + AcceptedPhysicsTimestamps timestamps, + bool isLocalPlayer, + System.Numerics.Quaternion? forcePositionRotation, + System.Numerics.Vector3? currentLocalVelocity, + bool installPlacementFrame, + bool clearParent, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedPositionSnapshot( + guid, + update, + disposition, + timestamps, + isLocalPlayer, + forcePositionRotation, + currentLocalVelocity, + installPlacementFrame, + clearParent, + out accepted); + + internal bool ApplyAcceptedPositionExecutionRejectedSnapshot( + uint guid, + ushort acceptedPositionSequence, + AcceptedPhysicsTimestamps timestamps, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedPositionExecutionRejectedSnapshot( + guid, + acceptedPositionSequence, + timestamps, + out accepted); + + internal bool ApplyAcceptedWeenieDescriptionSnapshot( + uint guid, + WorldSession.EntitySpawn incoming, + out WorldSession.EntitySpawn merged) => + _inbound.ApplyAcceptedWeenieDescriptionSnapshot(guid, incoming, out merged); + private bool IsKnown(RuntimeEntityRecord record) { if (IsCurrent(record)) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index a238d27a..a98e8c4b 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -31,6 +31,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( int EquipmentOwnerCount, int PendingMoveCount, int InitialCreateResidenceLeaseCount, + int InitialCreateExecutorProgressCount, int StreamSubscriberCount, int PlacementStreamSubscriberCount, long StreamDispatchFailureCount, @@ -38,7 +39,12 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( int PendingDispatchCount, bool IsDispatching, bool IsSessionClearInProgress, - bool IsDisposed) + bool IsDisposed, + /// Round 5 R5-1: pending queue-by-parent-GUID accepted relations (see ). + int DeferredAcceptedRelationCount = 0, + /// Round 5 R5-3: mirrors StreamDispatchFailureCount/HasLastStreamDispatchFailure for the executor's contained-replay failure surface. Diagnostic only - like its stream precedent, NOT gated by . + long ReplayFailureCount = 0, + bool HasLastReplayFailure = false) { public bool IsConverged => IsDisposed @@ -48,6 +54,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && AcceptedSnapshotCount == 0 && UnresolvedParentRelationCount == 0 && DeferredParentCreateCount == 0 + && DeferredAcceptedRelationCount == 0 && StagedParentRelationCount == 0 && RecoveryParentRelationCount == 0 && CommittedParentRelationCount == 0 @@ -57,6 +64,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && EquipmentOwnerCount == 0 && PendingMoveCount == 0 && InitialCreateResidenceLeaseCount == 0 + && InitialCreateExecutorProgressCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -127,6 +135,23 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences = new RuntimeInitialCreateResidenceState( Entities, Physics.SetPosition); + InitialCreateExecution = new RuntimeInitialCreateContinuationExecutor( + Entities, + InitialCreateResidences, + Physics, + Events, + (spawn, isLocalPlayer) => + RegisterEntityWithInitialResidence(spawn, isLocalPlayer), + (canonical, version, spawn, replaceGeneration) => + ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // Round 3 B3: every residence retirement path - not only the + // executor's own DiscardProgress calls - must converge the + // executor's progress AND its separately-tracked pending + // continuation placement token. This class owns both sides of the + // relationship, so it binds the delegate here rather than the + // residence state referencing the executor type directly. + InitialCreateResidences.BindRetirementNotification( + key => InitialCreateExecution.DiscardProgress(key)); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -155,6 +180,23 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences = new RuntimeInitialCreateResidenceState( Entities, Physics.SetPosition); + InitialCreateExecution = new RuntimeInitialCreateContinuationExecutor( + Entities, + InitialCreateResidences, + Physics, + Events, + (spawn, isLocalPlayer) => + RegisterEntityWithInitialResidence(spawn, isLocalPlayer), + (canonical, version, spawn, replaceGeneration) => + ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // Round 3 B3: every residence retirement path - not only the + // executor's own DiscardProgress calls - must converge the + // executor's progress AND its separately-tracked pending + // continuation placement token. This class owns both sides of the + // relationship, so it binds the delegate here rather than the + // residence state referencing the executor type directly. + InitialCreateResidences.BindRetirementNotification( + key => InitialCreateExecution.DiscardProgress(key)); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -183,6 +225,23 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences = new RuntimeInitialCreateResidenceState( Entities, Physics.SetPosition); + InitialCreateExecution = new RuntimeInitialCreateContinuationExecutor( + Entities, + InitialCreateResidences, + Physics, + Events, + (spawn, isLocalPlayer) => + RegisterEntityWithInitialResidence(spawn, isLocalPlayer), + (canonical, version, spawn, replaceGeneration) => + ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // Round 3 B3: every residence retirement path - not only the + // executor's own DiscardProgress calls - must converge the + // executor's progress AND its separately-tracked pending + // continuation placement token. This class owns both sides of the + // relationship, so it binds the delegate here rather than the + // residence state referencing the executor type directly. + InitialCreateResidences.BindRetirementNotification( + key => InitialCreateExecution.DiscardProgress(key)); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -197,6 +256,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable public RuntimePlacementProjectionChannel Placements { get; } internal RuntimeInitialCreateResidenceState InitialCreateResidences { get; } + internal RuntimeInitialCreateContinuationExecutor InitialCreateExecution + { get; } public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership() { @@ -220,6 +281,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Objects.PendingMoveCount, initialResidence.ActiveLeaseCount + initialResidence.PendingAdoptionCount, + InitialCreateExecution.ProgressCount, Events.SubscriberCount, Events.PlacementSubscriberCount, Events.DispatchFailureCount, @@ -227,7 +289,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Events.PendingDispatchCount, Events.IsDispatching, _sessionClearInProgress, - _disposed); + _disposed, + parents.DeferredAcceptedRelationCount, + InitialCreateExecution.ReplayFailureCount, + InitialCreateExecution.LastReplayFailure is not null); } public void BindEventContext( @@ -238,6 +303,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Events.BindContext(generation, frameNumber); Placements.BindGeneration(generation); InitialCreateResidences.BindGeneration(generation); + InitialCreateExecution.BindGeneration(generation); } /// @@ -1515,6 +1581,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable _sessionClearInProgress = true; RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); InitialCreateResidences.Clear(); + InitialCreateExecution.DiscardAll(); Physics.CollisionReports.LeaveWorldBatch(active); Physics.ResetSessionPhysics(); Entities.BeginSessionClear(); @@ -2028,12 +2095,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable private RuntimePlacementCancellationReceipt ForgetInitialCreateResidence( RuntimeEntityRecord canonical) { - return InitialCreateResidences.Forget( + bool forgotten = InitialCreateResidences.Forget( canonical, out _, - out RuntimePlacementCancellationReceipt cancellation) - ? cancellation - : default; + out RuntimePlacementCancellationReceipt cancellation); + // Round 3 B3: InitialCreateResidences.Forget's own retirement + // notification already routes to InitialCreateExecution.DiscardProgress + // for a successful Forget. This explicit call is defensive-in-depth + // for the (record never held a residence) case where Forget returns + // false without ever reaching the notification - DiscardProgress is + // idempotent, so a redundant call after a successful Forget is a + // guaranteed no-op, never a double-discard. + if (canonical.Key is { } key) + InitialCreateExecution.DiscardProgress(key); + return forgotten ? cancellation : default; } private static RuntimePlacementCancellationReceipt PreferCancellation( diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs new file mode 100644 index 00000000..9905b538 --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs @@ -0,0 +1,2107 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Entities; + +internal enum RuntimeInitialCreateExecutionStatus : byte +{ + /// Initial tail + entire FIFO revision applied + residence consumed. + Completed, + /// Initial authored placement not yet acknowledged; retry later. + PendingPlacement, + /// + /// Yielded mid-drain: a Position continuation began an authored placement + /// that is not yet acknowledged; retry later. Two distinct flavors share + /// this one status (Round 4 R4-14): (1) the ORDINARY flavor, where + /// + /// returns the exact token to drive prepare/submit/acknowledge on; and + /// (2) transient operation-slot CONTENTION (Round 3 B1) - another + /// operation already occupies this entity's SetPosition slot at the + /// moment the continuation's own merge committed. In flavor (2), + /// TryGetPendingContinuationPlacement returns false (no + /// token was ever begun) even though the overall status is still + /// AwaitingContinuationPlacement; the caller's only correct action is to + /// retry Execute again later with no placement work of its own - + /// the retry re-attempts ONLY the placement begin against the + /// already-committed merge, never re-running the merge or re-publishing. + /// + AwaitingContinuationPlacement, + RejectedToken, + /// Residence retired/superseded - abandoned, ledgers converged. + RejectedAuthority, +} + +/// +/// Executor-time inputs sampled at the retail decision point. These cannot be +/// retained at admission time because they describe LIVE state (the local +/// player, the current physics simulation) rather than the accepted wire +/// packet itself. Round 3 A3: contact is NOT one of these - it comes solely +/// from the retained wire packet's own IsGrounded bit (PositionPack +/// bit 0x4, server-asserted contact at admission time), never from a live +/// body query or a caller-supplied fallback. +/// +internal readonly record struct RuntimeInitialCreateExecutionInputs( + bool UsePositionFromServer, + float PlayerDistance); + +internal enum RuntimeInitialCreateExecutedActionKind : byte +{ + InitialAdoption, + TeleportHookRequest, + DeferredChildReplay, + /// Round 5 R5-1: one queued accepted parent relation replayed in this parent's own initial tail. + ParentRelationReplay, + PreTailDescriptionAdaptation, + ObjDesc, + CreateParent, + Parent, + Pickup, + Position, + Movement, + State, + Vector, + WeenieDescription, + ResidentCellCleanup, +} + +/// +/// Round 4 R4-6: outcome of one deferred child's replay registration +/// (). +/// Replaces the previous bool DeferredChildRegistered field, which +/// collapsed a genuine re-defer (the grandparent is ALSO still missing) +/// into the same "true" value as an outright successful registration. +/// +internal enum RuntimeDeferredChildReplayOutcome : byte +{ + /// was non-null. + Registered, + /// was true - the replayed child itself still has a missing (grand)parent. + ReDeferred, + /// Neither Canonical nor DeferredForParent - registration was rejected outright, or the registration callback threw (Round 4 R4-1). + Rejected, +} + +/// +/// Round 4 R4-6: distinct outcome for a Parent/CreateParent relation +/// applied at execution time. Replaces the Round 3 B9 dead-letter +/// re-Enqueue with retail-faithful discard (Round 4 R4-5). +/// +internal enum RuntimeParentRelationOutcome : byte +{ + /// The parent was addressable and current (or, at replay, named the exact live incarnation); the attach commit ran. + Applied, + /// + /// Round 5 R5-1: the LIVE parent incarnation is newer than the one this + /// relation named - retail-faithful discard, mirroring + /// 's own "current parent + /// newer than the packet" branch. The already-accepted position- + /// timestamp merge already ran (at the relation's original drain, not + /// repeated here); no leave-world, no placement forget. + /// + DiscardedStaleParent, + /// + /// Round 5 R5-1: the parent is unaddressable, or (standalone Parent + /// only) names a parent incarnation that has not yet arrived - queued + /// under the parent's guid exactly like retail's QueueBlobForObject + /// (pseudo-C 92326), replayed when that guid is created. + /// + DeferredAwaitingParent, + /// + /// Round 5 R5-3: the queued relation's child is no longer valid at + /// replay time (not current, or a different incarnation than when + /// queued) - a contained failure, not an exception; recorded and + /// skipped, never resurrected. + /// + Rejected, +} + +/// +/// Retail's exact three-way ResidentCellCleanup disposition (retail-notes.md +/// function 1, SmartBox::HandleCreateObject 0x00454c80, lines ~788-801). +/// +internal enum RuntimeResidentCellCleanupDisposition : byte +{ + /// + /// objcell_id != 0 && cell != 0: already resident - + /// un-mark (RemoveObjectToBeDestroyed). + /// + ResidentUnmarked, + /// + /// objcell_id != 0 && cell == 0 while an existing + /// lost-cell/deferred SetPosition operation already owns this exact + /// entity: the destruction mark belongs to that existing lifetime, not + /// to this tail action. + /// + DeferredUnderLostCellOwnership, + /// + /// No cell claimed at all (objcell_id == 0). Retail's own third + /// case (HandleCreateObject, retail-notes.md function 1, lines + /// ~93942-93943) additionally requires NO weenie description before + /// marking for destruction. That second half is structurally + /// UNREACHABLE through this exact envelope path: every + /// SameIncarnationCreate continuation this codebase constructs + /// carries a WeenieDescription action immediately before + /// ResidentCellCleanup, never optionally + /// (, + /// RuntimeInitialCreateResidenceState.cs:277-284, enforces + /// Actions[^2].Kind is WeenieDescription for every admitted + /// envelope). This value records the conservative claimed-but-celless + /// fact for that case without asserting it matches retail's documented + /// no-weenie destruction mark, and without building a second destruction + /// mechanism ahead of the object-table wiring that would let the + /// executor distinguish the two. + /// + CelllessNoWeenieMarkUnreachable, +} + +/// +/// One immutable trace entry. is the owning +/// continuation's FIFO sequence (0 for initial-tail-only facts that precede +/// the FIFO entirely). is the same-incarnation envelope +/// action index, or -1 outside an envelope. +/// and are only meaningful for Position/hook-request +/// entries; only for +/// . +/// +internal readonly record struct RuntimeInitialCreateExecutedAction( + RuntimeInitialCreateExecutedActionKind Kind, + ulong Sequence, + int Stage, + RuntimeAuthoritativePositionDisposition? PositionDisposition, + RuntimeTeleportHookPhase HookPhase, + RuntimeDeferredChildReplayOutcome? DeferredChildOutcome = null, + RuntimeResidentCellCleanupDisposition? ResidentCellCleanupDisposition = null, + RuntimePositionConstrainPhase ConstrainPhase = RuntimePositionConstrainPhase.None, + bool StopInterpolating = false, + bool ZeroVelocity = false, + bool PreserveHeading = false, + bool SendPositionImmediately = false, + bool UnparentBeforeRouting = false, + RuntimeParentRelationOutcome? ParentRelationOutcome = null); + +/// +/// Host-independent immutable execution result. Hosts/tests consume this; the +/// executor never calls presentation. +/// +internal readonly record struct RuntimeInitialCreateExecutionReceipt( + RuntimeEntityKey Entity, + uint FullCellId, + RuntimeTeleportHookPhase TeleportHookPhase, + ImmutableArray Trace, + int ReplayedDeferredChildCount); + +/// +/// Applies one entity's completed initial-Create residence: adopts the +/// initial placement exactly once, emits the AfterEnterWorld teleport-hook +/// request, replays raw missing-parent child Creates in FIFO order, drains +/// every retained continuation strictly by sequence (classifying Position +/// continuations at execution time against LIVE inputs), and releases the +/// residence once the drained prefix matches the lease's current length. +/// Execute is synchronous and retry-idempotent: a caller re-invokes it +/// after or +/// +/// once the placement token in the returned trace has been prepared, +/// submitted, and acknowledged by whatever drives +/// (a test harness today; a host at +/// cutover). This type never references App/UI/Silk.NET/OpenGL/OpenAL/ +/// Headless and is reached only by tests in this slice - no production +/// caller exists yet. +/// +internal sealed class RuntimeInitialCreateContinuationExecutor +{ + private enum InitialTailPhase : byte + { + NotStarted, + Adopted, + HookRecorded, + DeferredReplayed, + /// Round 5 R5-1: the accepted-relation queue for this guid has been drained. + RelationsReplayed, + } + + private readonly record struct PendingPublish( + RuntimeEntityChange Change, + Func Matches, + RuntimePlacementCancellationReceipt Cancellation); + + private sealed class Progress + { + internal required ulong LeaseId { get; init; } + internal ulong AppliedThroughSequence { get; set; } + internal InitialTailPhase TailPhase { get; set; } + internal int EnvelopeStageIndex { get; set; } = -1; + internal RuntimeEntityPlacementToken PendingContinuationPlacement { get; set; } + internal ulong PendingContinuationSequence { get; set; } + internal RuntimeAuthoritativePositionRoute PendingContinuationRoute { get; set; } + /// + /// Round 3 B1: true once a Position action's merge+publish has + /// committed but TryBeginExclusiveAuthoredPlacement failed on + /// transient operation-slot contention (another operation currently + /// owns this entity's SetPosition slot) rather than genuine + /// staleness. While true, a re-entry into ApplyPositionAction + /// for the SAME continuation/stage skips the merge/publish entirely + /// and retries only the placement begin - closing the "duplicate + /// publish on every retry" hole a naive full re-apply would open. + /// + internal bool PositionMergeCommittedForRetry { get; set; } + internal ulong PositionMergeCommittedVersion { get; set; } + internal int ReplayedDeferredChildCount { get; set; } + internal List EnvelopeBuffer { get; } = []; + internal ImmutableArray.Builder Trace { get; } = + ImmutableArray.CreateBuilder(); + } + + private readonly RuntimeEntityDirectory _entities; + private readonly RuntimeInitialCreateResidenceState _residences; + private readonly RuntimePhysicsState _physics; + private readonly RuntimeEntityObjectEventStream _events; + private readonly Func + _registerDeferredChild; + /// + /// Round 3 B12: mirrors + /// for the + /// residence path's WeenieDescription tail action. The executor holds no + /// direct reference to RuntimeEntityObjectLifetime (it is + /// constructed BY that owner) or its ClientObjectTable, so the + /// lifetime binds this delegate at construction the same way it binds + /// . + /// + private readonly Func + _applyAcceptedSpawn; + private readonly Dictionary _progress = []; + private readonly HashSet _executing = []; + private Func? _generation; + + internal RuntimeInitialCreateContinuationExecutor( + RuntimeEntityDirectory entities, + RuntimeInitialCreateResidenceState residences, + RuntimePhysicsState physics, + RuntimeEntityObjectEventStream events, + Func + registerDeferredChild, + Func + applyAcceptedSpawn) + { + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _residences = residences + ?? throw new ArgumentNullException(nameof(residences)); + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _events = events ?? throw new ArgumentNullException(nameof(events)); + _registerDeferredChild = registerDeferredChild + ?? throw new ArgumentNullException(nameof(registerDeferredChild)); + _applyAcceptedSpawn = applyAcceptedSpawn + ?? throw new ArgumentNullException(nameof(applyAcceptedSpawn)); + } + + internal void BindGeneration(Func generation) + { + ArgumentNullException.ThrowIfNull(generation); + if (_generation is not null) + { + throw new InvalidOperationException( + "The initial-create continuation executor's generation source is already bound."); + } + _generation = generation; + } + + internal int ProgressCount => _progress.Count; + + /// + /// Round 5 R5-3: mirrors the + /// DispatchFailureCount/LastDispatchFailure precedent for the deferred- + /// replay containment introduced by Round 4 R4-1 and extended by this + /// round's deferred-relation replay. A contained catch never silently + /// swallows - it increments this counter and records the exception, + /// then keeps draining the remaining entries. + /// + internal long ReplayFailureCount { get; private set; } + internal Exception? LastReplayFailure { get; private set; } + + private void RecordReplayFailure(Exception error) + { + ReplayFailureCount++; + LastReplayFailure = error; + } + + /// + /// Exposes the exact placement token a + /// + /// yield is waiting on, so a caller (a test harness today; a host at + /// cutover) can drive 's ordinary + /// prepare/submit/acknowledge cycle on it, exactly like it already does + /// for the initial lease's own placement token. + /// + internal bool TryGetPendingContinuationPlacement( + RuntimeEntityKey key, + out RuntimeEntityPlacementToken placement) + { + if (_progress.TryGetValue(key, out Progress? progress) + && progress.PendingContinuationPlacement.IsValid) + { + placement = progress.PendingContinuationPlacement; + return true; + } + placement = default; + return false; + } + + /// + /// Exposes the exact classified route the pending continuation placement + /// is running, so a caller can drive + /// with the matching + /// / + /// - the same information + /// already exposes for the initial placement. + /// + internal bool TryGetPendingContinuationRoute( + RuntimeEntityKey key, + out RuntimeAuthoritativePositionRoute route) + { + if (_progress.TryGetValue(key, out Progress? progress) + && progress.PendingContinuationPlacement.IsValid) + { + route = progress.PendingContinuationRoute; + return true; + } + route = default; + return false; + } + + /// + /// Deterministic cleanup hook wired into the SAME choke points that + /// forget a residence lease (). + /// A retired residence can never leave orphaned executor progress + /// behind. Also forgets any in-flight CONTINUATION placement token + /// (distinct from the residence's own initial-lease placement, which + /// ForgetInitialCreateResidence already forgets separately, and + /// distinct from the unconditional Physics.SetPosition.Forget + /// every existing ForgetInitialCreateResidence caller already + /// runs alongside it - which independently cancels whatever operation + /// currently exists for this key, continuation placement included). + /// This is defensive-in-depth: DiscardProgress owns cleanup of the + /// state IT introduces (PendingContinuationPlacement) rather than + /// relying on every current AND future caller pairing it with an + /// ordinary Forget of its own. + /// + internal void DiscardProgress(RuntimeEntityKey key) + { + if (!_progress.Remove(key, out Progress? progress)) + return; + if (progress.PendingContinuationPlacement.IsValid) + { + RuntimePlacementCancellationReceipt cancellation = + _physics.SetPosition.ForgetExactPlacement( + progress.PendingContinuationPlacement); + _physics.SetPosition.PublishCancellation(cancellation); + } + } + + /// + /// Deterministic bulk cleanup wired into + /// 's call site + /// (). Also + /// forgets every in-flight continuation placement token, defensively - + /// Physics.ResetSessionPhysics() runs immediately after this in + /// the same session-clear sequence and would otherwise be the only + /// thing to reap them. + /// + internal void DiscardAll() + { + foreach (Progress progress in _progress.Values) + { + if (!progress.PendingContinuationPlacement.IsValid) + continue; + RuntimePlacementCancellationReceipt cancellation = + _physics.SetPosition.ForgetExactPlacement( + progress.PendingContinuationPlacement); + _physics.SetPosition.PublishCancellation(cancellation); + } + _progress.Clear(); + } + + internal RuntimeInitialCreateExecutionStatus Execute( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + in RuntimeInitialCreateExecutionInputs inputs, + out RuntimeInitialCreateExecutionReceipt receipt) + { + ArgumentNullException.ThrowIfNull(canonical); + receipt = default; + if (!token.IsValid || canonical.Key is not { } key) + return RuntimeInitialCreateExecutionStatus.RejectedToken; + + // A reentrant Execute for the SAME entity while one is already on the + // stack (e.g. a synchronous event observer re-entering) fails closed + // rather than interleaving two drains of the same FIFO. + if (!_executing.Add(key)) + return RuntimeInitialCreateExecutionStatus.RejectedAuthority; + + try + { + return ExecuteCore(canonical, token, inputs, key, out receipt); + } + finally + { + _executing.Remove(key); + } + } + + private RuntimeInitialCreateExecutionStatus ExecuteCore( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + in RuntimeInitialCreateExecutionInputs inputs, + RuntimeEntityKey key, + out RuntimeInitialCreateExecutionReceipt receipt) + { + receipt = default; + + // An existing Progress for a DIFFERENT (older or ABA-reused) lease + // id is discarded here, and THIS exact call fails closed - an old + // incarnation's progress can never leak into a reused GUID/key. A + // retry with no prior progress starts fresh and succeeds normally. + if (_progress.TryGetValue(key, out Progress? existing) + && existing.LeaseId != token.LeaseId) + { + DiscardProgress(key); + return RuntimeInitialCreateExecutionStatus.RejectedAuthority; + } + + // Round 3 B11: a FRESH Progress for the CURRENT lease id is never + // materialized here - only lazily below, once Complete() actually + // reports Completed. A PendingPlacement/RejectedToken/RejectedAuthority + // outcome on THIS call must leave the ownership ledger (ProgressCount) + // untouched when nothing was ever tracked before - it should reflect + // drain work actually in flight, not a placeholder for a residence + // that has not even resolved yet. + Progress? progress = existing; + + // Resume a placement that a PREVIOUS Execute call began and yielded + // on, before doing anything else. This can belong either to a + // standalone Position continuation or to a Position stage inside a + // SameIncarnationCreate envelope; ApplyContinuation/ApplyEnvelope + // both check PendingContinuationPlacement first for exactly this + // reason. + while (true) + { + // The ONLY legitimate window where one of the four baseline + // fields can move BETWEEN Execute calls without the executor's + // own synchronous code running is a pending continuation + // placement's host-driven prepare/submit/acknowledge cycle + // (RuntimeSetPositionState's own commit machinery advances + // FullCellId/PlacementCommitVersion there). Re-sync the + // baseline ONLY when that exact window was left open by a + // PREVIOUS call - never unconditionally, or every call with + // nothing in flight would bless an external race on these + // fields before Complete() ever gets a chance to see it. Safe + // even when the placement was displaced/cancelled instead of + // committed: ResumePendingPlacement below independently + // re-derives that outcome from + // IsPlacementCurrent/TryPeekAcknowledgedPlacement, not from + // these four fields. + if (progress is not null && progress.PendingContinuationPlacement.IsValid) + { + // Round 4 R4-4: only FullCellId/PlacementCommitVersion can + // legitimately move in this exact window (RuntimeSetPositionState's + // own commit machinery, not the executor) - PositionAuthorityVersion/ + // CreateIntegrationVersion moving here would be a genuine + // external race Complete() must still catch. + _residences.AdvanceExecutorBaseline( + canonical, + token, + RuntimeExecutorBaselineFields.FullCellId + | RuntimeExecutorBaselineFields.PlacementCommitVersion); + } + RuntimeInitialCreateResidenceCompletionStatus completion = + _residences.Complete(canonical, token, out RuntimeInitialCreateResidenceReceipt residenceReceipt); + switch (completion) + { + case RuntimeInitialCreateResidenceCompletionStatus.PendingPlacement: + // Nothing has been drained yet for this exact lease - + // leave _progress exactly as found (untouched if it + // never existed). + return RuntimeInitialCreateExecutionStatus.PendingPlacement; + case RuntimeInitialCreateResidenceCompletionStatus.RejectedToken: + DiscardProgress(key); + return RuntimeInitialCreateExecutionStatus.RejectedToken; + case RuntimeInitialCreateResidenceCompletionStatus.RejectedAuthority: + DiscardProgress(key); + return RuntimeInitialCreateExecutionStatus.RejectedAuthority; + } + + // Completed: a residence now exists to drain. Materialize + // Progress exactly once, lazily, only at this point. + if (progress is null) + { + progress = new Progress { LeaseId = token.LeaseId }; + _progress[key] = progress; + } + + if (progress.TailPhase != InitialTailPhase.DeferredReplayed) + { + RuntimeInitialCreateExecutionStatus tailStatus = + RunInitialTail(canonical, token, residenceReceipt, progress); + if (tailStatus != RuntimeInitialCreateExecutionStatus.Completed) + return Abandon(canonical, key); + } + + while (progress.AppliedThroughSequence + < (ulong)residenceReceipt.Continuations.Length) + { + if (!_entities.IsCurrent(canonical) || canonical.Key != token.Entity) + return Abandon(canonical, key); + + int index = (int)progress.AppliedThroughSequence; + RuntimeInitialCreateResidenceContinuation continuation = + residenceReceipt.Continuations[index]; + if (continuation.InstanceSequence != canonical.Incarnation) + return Abandon(canonical, key); + + RuntimeInitialCreateExecutionStatus applyStatus = + ApplyContinuation(canonical, token, key, continuation, inputs, progress); + // Round 3 B2: every apply method below now rebaselines + // itself immediately after its own canonical mutation and + // BEFORE its own publish (mutate -> rebaseline -> publish), + // closing the reentrant-retirement window a synchronous + // Publish observer could otherwise see (the baseline would + // still show the PRE-mutation values while the observer + // reenters residence/executor state). No blanket + // re-synchronize belongs here anymore - each apply already + // guarantees its own baseline is current before ANY + // observer can run. + if (applyStatus + == RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement) + { + return applyStatus; + } + if (applyStatus != RuntimeInitialCreateExecutionStatus.Completed) + return applyStatus; + + progress.AppliedThroughSequence = continuation.Sequence; + progress.EnvelopeStageIndex = -1; + } + + RuntimeInitialCreateResidenceExecutorReleaseStatus release = + _residences.ConsumeExecuted( + canonical, + residenceReceipt.Adoption, + progress.AppliedThroughSequence); + switch (release) + { + case RuntimeInitialCreateResidenceExecutorReleaseStatus.Released: + receipt = new RuntimeInitialCreateExecutionReceipt( + key, + residenceReceipt.FullCellId, + residenceReceipt.TeleportHookPhase, + progress.Trace.ToImmutable(), + progress.ReplayedDeferredChildCount); + _progress.Remove(key); + return RuntimeInitialCreateExecutionStatus.Completed; + case RuntimeInitialCreateResidenceExecutorReleaseStatus.Revised: + // A new continuation arrived mid-drain (Enqueue bumps the + // completed entry's Adoption.Revision in place). Re-fetch + // via Complete and drain only the newly-appended tail - + // AppliedThroughSequence already reflects everything this + // progress has committed, so the outer while(true) loop's + // inner drain loop naturally continues from there. + continue; + default: + return Abandon(canonical, key); + } + } + } + + /// + /// Round 3 B1: the ONE choke point every abandonment path routes + /// through. Retiring the RESIDENCE itself (not just this executor's own + /// progress) is essential here - a caller that only discarded progress + /// and returned RejectedAuthority would leave the residence's own + /// completed entry sitting there fully current; the NEXT Execute call + /// for the same key would re-fetch it via Complete(), start a FRESH + /// Progress at sequence zero, and REPLAY every continuation already + /// committed to the canonical snapshot in this attempt. + /// 's own + /// retirement notification (bound at + /// construction) already + /// routes back to for a successful Forget; + /// the explicit call here is the same idempotent defense-in-depth every + /// other DiscardProgress caller uses, covering the case where Forget + /// finds no matching residence at all (nothing left to retire, but this + /// key's own progress must still go). + /// + private RuntimeInitialCreateExecutionStatus Abandon( + RuntimeEntityRecord canonical, + RuntimeEntityKey key) + { + if (_residences.Forget( + canonical, + out _, + out RuntimePlacementCancellationReceipt cancellation)) + { + _physics.SetPosition.PublishCancellation(cancellation); + } + DiscardProgress(key); + return RuntimeInitialCreateExecutionStatus.RejectedAuthority; + } + + private RuntimeInitialCreateExecutionStatus RunInitialTail( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + in RuntimeInitialCreateResidenceReceipt residenceReceipt, + Progress progress) + { + if (progress.TailPhase == InitialTailPhase.NotStarted) + { + // Resolves the runtime-surface.md 3.1 deadlock: BeginAcceptedPlacementCore + // (every placement-begin entry point) rejects while HasRetainedCompletion + // is true for this key. Consuming the initial placement's + // acknowledged completion here - exactly once, guarded by + // PlacementAdopted - is what lets a later Position continuation + // begin its OWN authored placement for the same key. + if (!_residences.AdoptCompletedPlacement(canonical, token)) + return RuntimeInitialCreateExecutionStatus.RejectedAuthority; + progress.Trace.Add(new RuntimeInitialCreateExecutedAction( + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + 0UL, + -1, + null, + RuntimeTeleportHookPhase.None)); + progress.TailPhase = InitialTailPhase.Adopted; + } + + if (progress.TailPhase == InitialTailPhase.Adopted) + { + // Retail: SmartBox new-object player branch, init_player / + // PlayerPositionUpdated (function 1 in retail-notes.md, + // SmartBox::HandleCreateObject 0x00454c80). The hook REQUEST is + // the Runtime-side fact; a host runs the actual after-enter + // teleport suffix at cutover. + if (residenceReceipt.TeleportHookPhase + == RuntimeTeleportHookPhase.AfterEnterWorld) + { + progress.Trace.Add(new RuntimeInitialCreateExecutedAction( + RuntimeInitialCreateExecutedActionKind.TeleportHookRequest, + 0UL, + -1, + null, + RuntimeTeleportHookPhase.AfterEnterWorld)); + } + progress.TailPhase = InitialTailPhase.HookRecorded; + } + + if (progress.TailPhase == InitialTailPhase.HookRecorded) + { + if (!ReplayDeferredChildren(canonical, progress)) + return RuntimeInitialCreateExecutionStatus.RejectedAuthority; + progress.TailPhase = InitialTailPhase.DeferredReplayed; + } + + if (progress.TailPhase == InitialTailPhase.DeferredReplayed) + { + // Round 5 R5-1: drains the accepted-relation queue keyed to + // THIS guid AFTER the raw-Create replay above - wire-arrival + // order means any relation waiting on this exact guid was + // queued no earlier than the raw children were (retail's + // ProcessObjectNetBlobs replays both classes of blob from the + // SAME per-guid bucket; our queues are split by shape but + // drained in the same relative order). + if (!ReplayDeferredAcceptedRelations(canonical, progress)) + return RuntimeInitialCreateExecutionStatus.RejectedAuthority; + progress.TailPhase = InitialTailPhase.RelationsReplayed; + } + + return RuntimeInitialCreateExecutionStatus.Completed; + } + + /// + /// Retail: SmartBox::ProcessObjectNetBlobs 0x00454b20, called at the tail + /// of HandleCreateObject's new-object path for the object that was just + /// created - a parent's own successful Create replays every blob queued + /// waiting on ITS guid, synchronously, in the same call stack, in FIFO + /// order. Wire-arrival order means this replay runs BEFORE the FIFO + /// drain below: children were queued before any continuation targeting + /// this entity itself could exist. + /// + /// Round 3 B7: retail detaches the ENTIRE queued netblob list for one + /// parent atomically before dispatching any of it (pseudo-C ~93617) - + /// "detach" IS retail's "consume"; there is no separate peek-then-remove + /// step. This replaces the previous peek/consume loop, which needed a + /// stale-AdmissionId escape hatch for a race that an atomic detach makes + /// structurally impossible: a NEW Create arriving for this same parent + /// during replay enqueues into a brand-new queue instance, since + /// DetachDeferredCreates already removed the old one from the + /// dictionary before this loop starts. + /// + /// Round 4 R4-1: two containment gaps closed. (1) one child's + /// registration THROWING no longer escapes Execute or strands + /// the remaining siblings - each registration runs inside a try/catch, + /// recording a + /// outcome and continuing with the next entry on an exception. (2) a + /// mid-loop abandonment (this entity no longer current - e.g. a + /// reentrant delete/reset fired synchronously from an earlier sibling's + /// own registration callback) restores the UNPROCESSED remainder into + /// - in original FIFO order, with + /// original AdmissionIds - rather than permanently destroying it. + /// Retail's own queued blobs live on CObjectMaint (per-GUID), not + /// on the object instance being replayed, so they survive the object + /// and replay again against a recreated GUID; our GUID-keyed + /// persistence already pins this, RestoreDeferredCreates just makes an + /// abandoned-mid-replay attempt honor it too. + /// + private bool ReplayDeferredChildren(RuntimeEntityRecord canonical, Progress progress) + { + if (!_entities.IsCurrent(canonical)) + return false; + + ImmutableArray detached = + _entities.ParentAttachments.DetachDeferredCreates( + canonical.ServerGuid, out DeferredReplayWindowToken window); + for (int index = 0; index < detached.Length; index++) + { + if (!_entities.IsCurrent(canonical)) + { + _entities.ParentAttachments.RestoreDeferredCreates( + window, + detached.AsSpan()[index..]); + return false; + } + + DeferredParentCreate deferred = detached[index]; + RuntimeDeferredChildReplayOutcome outcome; + try + { + RuntimeEntityRegistrationResult result = + _registerDeferredChild(deferred.Spawn, deferred.IsLocalPlayer); + outcome = result.Canonical is not null + ? RuntimeDeferredChildReplayOutcome.Registered + : result.DeferredForParent + ? RuntimeDeferredChildReplayOutcome.ReDeferred + : RuntimeDeferredChildReplayOutcome.Rejected; + } + catch (Exception error) + { + // Round 4 R4-1: contain the exception here - one bad child + // must not strand the remaining siblings or escape Execute + // as a typed status. Round 5 R5-3: record it on the + // observable failure surface rather than swallowing it. + RecordReplayFailure(error); + outcome = RuntimeDeferredChildReplayOutcome.Rejected; + } + progress.Trace.Add(new RuntimeInitialCreateExecutedAction( + RuntimeInitialCreateExecutedActionKind.DeferredChildReplay, + 0UL, + -1, + null, + RuntimeTeleportHookPhase.None, + outcome)); + progress.ReplayedDeferredChildCount++; + } + // Nothing left to restore on a full pass - releases the window. + _entities.ParentAttachments.RestoreDeferredCreates( + window, ReadOnlySpan.Empty); + return true; + } + + /// + /// Round 5 R5-1: drains the accepted-relation queue keyed to this exact + /// guid, replaying every relation a standalone Parent continuation or + /// envelope CreateParent stage deferred because this parent was + /// unaddressable or named a not-yet-arrived incarnation. Mirrors + /// 's detach-first / cancellation- + /// aware-window / contained-failure shape exactly - see that method's + /// remarks for the retail citations and the R4-1/R5-2 rationale, which + /// apply identically here. + /// + private bool ReplayDeferredAcceptedRelations(RuntimeEntityRecord canonical, Progress progress) + { + if (!_entities.IsCurrent(canonical)) + return false; + + ImmutableArray detached = + _entities.ParentAttachments.DetachDeferredAcceptedRelations( + canonical.ServerGuid, out DeferredReplayWindowToken window); + for (int index = 0; index < detached.Length; index++) + { + if (!_entities.IsCurrent(canonical)) + { + _entities.ParentAttachments.RestoreDeferredAcceptedRelations( + window, + detached.AsSpan()[index..]); + return false; + } + + DeferredAcceptedParentRelation entry = detached[index]; + RuntimeParentRelationOutcome outcome; + try + { + outcome = ApplyReplayedParentRelation(canonical, entry); + } + catch (Exception error) + { + RecordReplayFailure(error); + outcome = RuntimeParentRelationOutcome.Rejected; + } + progress.Trace.Add(new RuntimeInitialCreateExecutedAction( + RuntimeInitialCreateExecutedActionKind.ParentRelationReplay, + 0UL, + -1, + null, + RuntimeTeleportHookPhase.None, + null, + null, + RuntimePositionConstrainPhase.None, + false, + false, + false, + false, + false, + outcome)); + if (outcome == RuntimeParentRelationOutcome.DeferredAwaitingParent) + { + // Relation still names an incarnation that has not arrived + // yet - wait for the NEXT one. Re-enqueues into a BRAND NEW + // queue instance (the whole bucket was already detached + // above), so this same detach loop never re-observes it. + _entities.ParentAttachments.EnqueueDeferredAcceptedRelation(entry); + } + } + _entities.ParentAttachments.RestoreDeferredAcceptedRelations( + window, ReadOnlySpan.Empty); + return true; + } + + /// + /// Round 5 R5-1: incarnation dispatch vs THIS parent (), + /// mirroring 's own rules - + /// equal incarnation (or the envelope flavor, which has none to compare) + /// commits the attach; THIS parent newer than the relation discards it + /// (stale); the relation newer than THIS parent re-enqueues (wait for + /// the next incarnation - handled by the caller). The merge already + /// committed at the relation's ORIGINAL drain (its own position- + /// timestamp-only stamp); replay commits ONLY the attach tail, never + /// re-runs it. + /// + private RuntimeParentRelationOutcome ApplyReplayedParentRelation( + RuntimeEntityRecord parent, + in DeferredAcceptedParentRelation entry) + { + if (!_entities.TryGetActive(entry.ChildGuid, out RuntimeEntityRecord child) + || child.Key != entry.ChildKey) + { + return RuntimeParentRelationOutcome.Rejected; + } + + if (entry.ParentInstanceSequence is { } relationParentInstance + && parent.Incarnation != relationParentInstance) + { + return PhysicsTimestampGate.IsNewer(relationParentInstance, parent.Incarnation) + ? RuntimeParentRelationOutcome.DiscardedStaleParent + : RuntimeParentRelationOutcome.DeferredAwaitingParent; + } + + // Round 5 R5-3 note: the child's OWN residence token (if its + // initial-tail is somehow still open at this exact moment) is not + // held here - only the parent's is in scope. AdvanceExecutorBaseline + // is deliberately SKIPPED (rebaseline: false) rather than guessed at; + // if the child's own residence is still active, its own next + // Complete() call will correctly observe this PositionAuthorityVersion + // bump as an external race and fail closed - the safe direction - + // rather than this call silently blessing a baseline it does not + // own. + CommitParentAttachment(child, default, rebaseline: false, buffer: null); + return RuntimeParentRelationOutcome.Applied; + } + + private RuntimeInitialCreateExecutionStatus ApplyContinuation( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeEntityKey key, + in RuntimeInitialCreateResidenceContinuation continuation, + in RuntimeInitialCreateExecutionInputs inputs, + Progress progress) + { + if (continuation.Kind + == RuntimeInitialCreateContinuationKind.SameIncarnationCreate) + { + return ApplyEnvelope(canonical, token, key, continuation, inputs, progress); + } + + if (progress.PendingContinuationPlacement.IsValid) + { + RuntimeInitialCreateExecutionStatus resumeStatus = + ResumePendingPlacement(canonical, key, progress, out RuntimeAuthoritativePositionRoute route); + if (resumeStatus != RuntimeInitialCreateExecutionStatus.Completed) + return resumeStatus; + progress.Trace.Add(BuildPositionTrace(continuation.Sequence, -1, route)); + return RuntimeInitialCreateExecutionStatus.Completed; + } + + RuntimeInitialCreateTailAction action = continuation.Actions[0]; + switch (continuation.Kind) + { + case RuntimeInitialCreateContinuationKind.ObjDesc: + if (!ApplyObjDescAction(canonical, token, action, null)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.ObjDesc, + continuation.Sequence)); + return RuntimeInitialCreateExecutionStatus.Completed; + case RuntimeInitialCreateContinuationKind.Parent: + return ApplyParentContinuation(canonical, token, key, continuation, action, progress); + case RuntimeInitialCreateContinuationKind.Pickup: + if (!ApplyPickupAction(canonical, token, action, null)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.Pickup, + continuation.Sequence)); + return RuntimeInitialCreateExecutionStatus.Completed; + case RuntimeInitialCreateContinuationKind.Movement: + if (!ApplyMovementAction(canonical, token, action, null)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.Movement, + continuation.Sequence)); + return RuntimeInitialCreateExecutionStatus.Completed; + case RuntimeInitialCreateContinuationKind.State: + if (!ApplyStateAction(canonical, token, action, null)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.State, + continuation.Sequence)); + return RuntimeInitialCreateExecutionStatus.Completed; + case RuntimeInitialCreateContinuationKind.Vector: + if (!ApplyVectorAction(canonical, token, action, null)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.Vector, + continuation.Sequence)); + return RuntimeInitialCreateExecutionStatus.Completed; + case RuntimeInitialCreateContinuationKind.Position: + return ApplyPositionAction( + canonical, + token, + key, + continuation.Sequence, + -1, + action, + inputs, + progress, + null); + default: + throw new InvalidOperationException( + $"Unsupported initial-Create continuation kind {continuation.Kind}."); + } + } + + /// + /// Round 3 B9 revalidated the standalone Parent continuation's parent + /// incarnation at EXECUTION time. Round 4 R4-5 replaced admission's + /// dead-letter re-Enqueue with a DISCARD; Round 5 R5-1 OVERTURNS that + /// discard with hard retail evidence: a missing/stale parent QUEUES the + /// raw blob under the PARENT's guid (standalone parent handler + /// 0x004535D0 -> QueueBlobForObject, pseudo-C 92326; GUID-keyed + /// placeholder bucket in CObjectMaint, 271082-271088) and replays + /// it via ProcessObjectNetBlobs when that guid is created - retail + /// NEVER discards on this path; its only check is pointer addressability + /// (92312). The already-accepted position-timestamp merge still runs + /// exactly once here (gate/snapshot lockstep preserved); the dispatch + /// that follows mirrors 's + /// OWN established staleness rules verbatim: unaddressable parent or a + /// relation naming a not-yet-arrived incarnation both ENQUEUE (wait); + /// only a relation whose named incarnation the LIVE parent has already + /// superseded is discarded. + /// drains the queue this enqueues into, in the target parent's own + /// initial tail, after its raw-Create replay. + /// + private RuntimeInitialCreateExecutionStatus ApplyParentContinuation( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeEntityKey key, + in RuntimeInitialCreateResidenceContinuation continuation, + RuntimeInitialCreateTailAction action, + Progress progress) + { + ParentEvent.Parsed parentUpdate = action.Parent!.Value; + if (!ApplyParentPositionTimestampOnly(canonical, parentUpdate)) + return Abandon(canonical, key); + + RuntimeParentRelationOutcome outcome; + if (!_entities.TryGetActive(parentUpdate.ParentGuid, out RuntimeEntityRecord parent)) + { + _entities.ParentAttachments.EnqueueDeferredAcceptedRelation( + canonical.ServerGuid, key, parentUpdate, null, action.AcceptedTimestamps); + outcome = RuntimeParentRelationOutcome.DeferredAwaitingParent; + } + else if (parent.Incarnation != parentUpdate.ParentInstanceSequence) + { + if (PhysicsTimestampGate.IsNewer(parentUpdate.ParentInstanceSequence, parent.Incarnation)) + { + // Live parent is NEWER than the relation's named incarnation + // - stale, discard (Resolve's own discard branch). + outcome = RuntimeParentRelationOutcome.DiscardedStaleParent; + } + else + { + // Relation names a FUTURE incarnation - wait for it. + _entities.ParentAttachments.EnqueueDeferredAcceptedRelation( + canonical.ServerGuid, key, parentUpdate, null, action.AcceptedTimestamps); + outcome = RuntimeParentRelationOutcome.DeferredAwaitingParent; + } + } + else + { + CommitParentAttachment(canonical, token, rebaseline: true, null); + outcome = RuntimeParentRelationOutcome.Applied; + } + + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.Parent, + continuation.Sequence, + parentRelationOutcome: outcome)); + return RuntimeInitialCreateExecutionStatus.Completed; + } + + /// + /// The position-timestamp-only stamp that ALWAYS runs, exactly once, + /// when a standalone Parent continuation is first drained - regardless + /// of whether the dispatch that follows applies, defers, or discards + /// the relation. + /// is exactly retail's ApplyPositionTimestampOnly. None of the + /// four executor-tracked baseline fields move here (Round 4 R4-4), so + /// no AdvanceExecutorBaseline call belongs here either. + /// + private bool ApplyParentPositionTimestampOnly( + RuntimeEntityRecord canonical, + ParentEvent.Parsed update) + { + if (!_entities.ApplyAcceptedParentSnapshot( + canonical.ServerGuid, + update, + out WorldSession.EntitySpawn stamped)) + { + return false; + } + _entities.RefreshSnapshot(canonical, stamped); + return true; + } + + /// + /// The envelope's CreateParent stage revalidates parent ADDRESSABILITY + /// only - carries no + /// ParentInstanceSequence at all (retail-notes.md's + /// TryApplyCreateParent remarks: "unlike standalone ParentEvent + /// it carries no parent INSTANCE_TS"), so there is no incarnation to + /// compare - only whether the parent is addressable at all. Round 5 + /// R5-1: an unaddressable parent now QUEUES (same retail-faithful + /// deferral as the standalone Parent continuation), not discards - the + /// merge already ran once, unconditionally, before this dispatch. + /// + private (bool Success, RuntimeParentRelationOutcome Outcome) ApplyCreateParentContinuation( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeEntityKey key, + RuntimeInitialCreateTailAction action, + List? buffer) + { + CreateParentUpdate createParentUpdate = action.CreateParent!.Value; + if (!ApplyCreateParentPositionTimestampOnly(canonical, createParentUpdate)) + return (false, default); + + if (!_entities.TryGetActive(createParentUpdate.ParentGuid, out _)) + { + _entities.ParentAttachments.EnqueueDeferredAcceptedRelation( + canonical.ServerGuid, key, null, createParentUpdate, action.AcceptedTimestamps); + return (true, RuntimeParentRelationOutcome.DeferredAwaitingParent); + } + CommitParentAttachment(canonical, token, rebaseline: true, buffer); + return (true, RuntimeParentRelationOutcome.Applied); + } + + /// Instance-seam-only stamp - see 's remarks. + private bool ApplyCreateParentPositionTimestampOnly( + RuntimeEntityRecord canonical, + CreateParentUpdate update) + { + if (!_entities.ApplyAcceptedCreateParentSnapshot( + canonical.ServerGuid, + update, + out WorldSession.EntitySpawn stamped)) + { + return false; + } + _entities.RefreshSnapshot(canonical, stamped); + return true; + } + + private RuntimeInitialCreateExecutionStatus ApplyEnvelope( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeEntityKey key, + in RuntimeInitialCreateResidenceContinuation continuation, + in RuntimeInitialCreateExecutionInputs inputs, + Progress progress) + { + int startStage = progress.EnvelopeStageIndex < 0 ? 0 : progress.EnvelopeStageIndex; + + if (progress.PendingContinuationPlacement.IsValid) + { + RuntimeInitialCreateExecutionStatus resumeStatus = + ResumePendingPlacement(canonical, key, progress, out RuntimeAuthoritativePositionRoute route); + if (resumeStatus != RuntimeInitialCreateExecutionStatus.Completed) + return resumeStatus; + progress.Trace.Add(BuildPositionTrace(continuation.Sequence, startStage, route)); + startStage++; + progress.EnvelopeStageIndex = startStage; + } + + for (int i = startStage; i < continuation.Actions.Length; i++) + { + if (!_entities.IsCurrent(canonical)) + return Abandon(canonical, key); + + RuntimeInitialCreateTailAction action = continuation.Actions[i]; + switch (action.Kind) + { + case RuntimeInitialCreateTailActionKind.PreTailDescriptionAdaptation: + // AP-119 compatibility: retail does NOT re-run + // set_description for an equal-generation Create tail. + // The retained PhysicsSpawnData is a presentation-side + // compat artifact only; no canonical mutation here. + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.PreTailDescriptionAdaptation, + continuation.Sequence, + i)); + break; + case RuntimeInitialCreateTailActionKind.ObjDesc: + if (!ApplyObjDescAction(canonical, token, action, progress.EnvelopeBuffer)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.ObjDesc, + continuation.Sequence, + i)); + break; + case RuntimeInitialCreateTailActionKind.CreateParent: + { + (bool success, RuntimeParentRelationOutcome outcome) = + ApplyCreateParentContinuation(canonical, token, key, action, progress.EnvelopeBuffer); + if (!success) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.CreateParent, + continuation.Sequence, + i, + parentRelationOutcome: outcome)); + break; + } + case RuntimeInitialCreateTailActionKind.Pickup: + if (!ApplyPickupAction(canonical, token, action, progress.EnvelopeBuffer)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.Pickup, + continuation.Sequence, + i)); + break; + case RuntimeInitialCreateTailActionKind.Position: + { + progress.EnvelopeStageIndex = i; + RuntimeInitialCreateExecutionStatus status = ApplyPositionAction( + canonical, + token, + key, + continuation.Sequence, + i, + action, + inputs, + progress, + progress.EnvelopeBuffer); + if (status + == RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement) + { + // A mid-envelope yield publishes NOTHING - the + // buffer accumulated so far stays on Progress and is + // flushed only once the whole envelope completes. + return status; + } + if (status != RuntimeInitialCreateExecutionStatus.Completed) + return status; + break; + } + case RuntimeInitialCreateTailActionKind.Movement: + if (!ApplyMovementAction(canonical, token, action, progress.EnvelopeBuffer)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.Movement, + continuation.Sequence, + i)); + break; + case RuntimeInitialCreateTailActionKind.State: + if (!ApplyStateAction(canonical, token, action, progress.EnvelopeBuffer)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.State, + continuation.Sequence, + i)); + break; + case RuntimeInitialCreateTailActionKind.Vector: + if (!ApplyVectorAction(canonical, token, action, progress.EnvelopeBuffer)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.Vector, + continuation.Sequence, + i)); + break; + case RuntimeInitialCreateTailActionKind.WeenieDescription: + if (!ApplyWeenieDescriptionAction(canonical, token, action, progress.EnvelopeBuffer)) + return Abandon(canonical, key); + progress.Trace.Add(Simple( + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + continuation.Sequence, + i)); + break; + case RuntimeInitialCreateTailActionKind.ResidentCellCleanup: + { + RuntimeResidentCellCleanupDisposition? cleanupDisposition = + ApplyResidentCellCleanup(canonical); + if (cleanupDisposition is null) + { + // Round 3 B1: the fail-closed invariant violation + // (claimed+celless+not-deferred) is a typed + // abandonment, never a throw escaping Execute. + return Abandon(canonical, key); + } + progress.Trace.Add(new RuntimeInitialCreateExecutedAction( + RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup, + continuation.Sequence, + i, + null, + RuntimeTeleportHookPhase.None, + null, + cleanupDisposition)); + break; + } + default: + throw new InvalidOperationException( + $"Unsupported same-incarnation tail action {action.Kind}."); + } + + // Round 3 B1: persist EnvelopeStageIndex after EVERY committed + // stage, not only Position. Without this, a retry after an + // unexpected mid-envelope failure (or any future non-Position + // yield point) would resume from a stale index and REPLAY + // stages already committed to the canonical snapshot - + // Position's own yield/resume already tracks this correctly; + // this makes every other stage kind do the same. + progress.EnvelopeStageIndex = i + 1; + } + + // Retail's tail is one synchronous critical section; no observer + // boundary between stages. Publish every buffered per-stage event + // consecutively, in stage order, only now that every stage committed. + foreach (PendingPublish pending in progress.EnvelopeBuffer) + PublishNow(canonical, pending.Change, pending.Matches, pending.Cancellation); + progress.EnvelopeBuffer.Clear(); + progress.EnvelopeStageIndex = -1; + return RuntimeInitialCreateExecutionStatus.Completed; + } + + /// + /// Round 3 B4: matches + /// 's FULL + /// record/projection agreement rather than a subset of it - a + /// continuation's own authored placement deserves the same staleness + /// rigor as the initial lease's placement. Beyond the projection's own + /// reported facts, this also re-checks the LIVE canonical record's + /// PositionAuthorityVersion (has something ELSE moved the record since + /// this exact placement began?) and FullCellId/PlacementCommitVersion + /// (does the projection's committed cell/version still match reality?). + /// + private RuntimeInitialCreateExecutionStatus ResumePendingPlacement( + RuntimeEntityRecord canonical, + RuntimeEntityKey key, + Progress progress, + out RuntimeAuthoritativePositionRoute route) + { + route = progress.PendingContinuationRoute; + RuntimeEntityPlacementToken placementToken = progress.PendingContinuationPlacement; + if (_physics.SetPosition.IsPlacementCurrent(placementToken)) + return RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement; + + if (!_physics.SetPosition.TryPeekAcknowledgedPlacement( + placementToken, + out RuntimePlacementProjectionToken projection) + || projection.Entity != placementToken.Entity + || projection.SessionLifetimeVersion != placementToken.SessionLifetimeVersion + || projection.PositionAuthorityVersion != placementToken.PositionAuthorityVersion + || canonical.PositionAuthorityVersion != placementToken.PositionAuthorityVersion + || projection.ExactCellId == 0u + || projection.ExactCellId != canonical.FullCellId + || projection.PlacementCommitVersion != canonical.PlacementCommitVersion) + { + // Round 4 R4-2: forget -> clear -> Abandon. Neither still in + // flight nor acknowledged with matching facts - cancelled or + // superseded by a newer authoritative operation. ForgetExactPlacement + // removes the retained _acknowledgedPlacementCompletions entry + // (via ForgetPlacementCompletionCore) even in this mismatch + // case - without it, HasRetainedCompletion for this key would + // stay true forever and block EVERY later placement begin (the + // runtime-surface.md 3.1 deadlock this executor exists to + // resolve). A newer owner now has the entity; abandon this + // execution. + RuntimePlacementCancellationReceipt forgotten = + _physics.SetPosition.ForgetExactPlacement(placementToken); + _physics.SetPosition.PublishCancellation(forgotten); + progress.PendingContinuationPlacement = default; + return Abandon(canonical, key); + } + + if (!_physics.SetPosition.ConsumeAcknowledgedPlacement(placementToken, projection)) + { + // Round 4 R4-2: same forget -> clear -> Abandon ordering - a + // concurrent consumer raced this exact acknowledgement away + // between TryPeek and here; still forget defensively so no + // stale watch/ack entry survives under this token. + RuntimePlacementCancellationReceipt forgotten = + _physics.SetPosition.ForgetExactPlacement(placementToken); + _physics.SetPosition.PublishCancellation(forgotten); + progress.PendingContinuationPlacement = default; + return Abandon(canonical, key); + } + + progress.PendingContinuationPlacement = default; + progress.PendingContinuationSequence = 0UL; + return RuntimeInitialCreateExecutionStatus.Completed; + } + + private RuntimeInitialCreateExecutionStatus ApplyPositionAction( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeEntityKey key, + ulong sequence, + int stage, + RuntimeInitialCreateTailAction action, + in RuntimeInitialCreateExecutionInputs inputs, + Progress progress, + List? buffer) + { + if (!_residences.TryGetTransaction(canonical, out RuntimeInitialCreateResidenceLease lease) + || canonical.Key != key) + { + return Abandon(canonical, key); + } + + WorldSession.EntityPositionUpdate update = action.Position!.Value; + RuntimePositionEntityKind entityKind = EntityKindOf(lease.Route.OperationKind); + bool isLocalPlayer = entityKind is RuntimePositionEntityKind.LocalPlayer; + + RuntimeAuthoritativePositionRoute route; + if (progress.PositionMergeCommittedForRetry) + { + // Round 3 B1: a PREVIOUS attempt already merged and published + // this exact position continuation; TryBeginExclusiveAuthoredPlacement + // failed on transient operation-slot contention rather than + // staleness, and this re-entry retries ONLY the placement + // begin. Re-running the merge here would double-publish. + route = progress.PendingContinuationRoute; + } + else + { + var authority = new RuntimeAuthoritativePositionAuthority( + CurrentGeneration(), + key, + canonical.PositionAuthorityVersion, + update.PositionSequence, + action.PreviousTeleportSequence, + action.AcceptedTimestamps.Teleport, + action.PositionDisposition); + + // Round 3 A3: contact comes SOLELY from the retained wire + // packet's own IsGrounded bit (PositionPack bit 0x4, + // server-asserted contact at admission time) - never a live + // body query, never an Inputs fallback. + bool hasContact = update.IsGrounded; + // Round 3 B5: HasAnimations is the SAME data-driven proxy for + // every position source, SameIncarnationCreate included - no + // PositionSource short-circuit. Round 4 R4-13: fall back to the + // nested PhysicsSpawnData's own MotionTableId when the + // top-level snapshot field is null (WeenieDescription/ObjDesc + // merges only ever populate one of the two, depending on + // which stage last touched appearance vs description). + bool hasAnimations = (canonical.Snapshot.MotionTableId + ?? canonical.Snapshot.Physics?.MotionTableId) is { } motionTableId + && motionTableId != 0u; + + var request = new RuntimeAcceptedPositionRouteRequest( + authority, + entityKind, + action.PositionSource, + update.Position, + update.PlacementId, + update.Velocity, + canonical.FullCellId, + hasContact, + inputs.PlayerDistance, + inputs.UsePositionFromServer, + hasAnimations, + new RuntimePositionPlacementFacts( + canonical.FinalPhysicsState, + canonical.Snapshot.SetupTableId is not null)); + + route = RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(request); + + if (!route.Accepted) + { + // Round 3 B10: distinguish two retained-action shapes. + // When admission ITSELF already rejected (only + // FORCE_POSITION_TS could have moved), the ordinary Rejected + // merge is the correct stamp-only path. When admission + // ACCEPTED (Apply/ForcePosition - POSITION_TS/TELEPORT_TS/ + // FORCE_POSITION_TS genuinely advanced) but EXECUTION-time + // classification now rejects, the snapshot must still + // reflect every channel the gate actually moved, not just + // ForcePosition. + bool stampedOk = action.PositionDisposition + is PositionTimestampDisposition.Rejected + ? _entities.ApplyAcceptedPositionSnapshot( + canonical.ServerGuid, + update, + PositionTimestampDisposition.Rejected, + action.AcceptedTimestamps, + isLocalPlayer, + null, + null, + installPlacementFrame: false, + clearParent: false, + out WorldSession.EntitySpawn stampedOnly) + : _entities.ApplyAcceptedPositionExecutionRejectedSnapshot( + canonical.ServerGuid, + update.PositionSequence, + action.AcceptedTimestamps, + out stampedOnly); + if (!stampedOk) + return Abandon(canonical, key); + _entities.RefreshSnapshot(canonical, stampedOnly); + progress.Trace.Add(BuildPositionTrace(sequence, stage, route)); + return RuntimeInitialCreateExecutionStatus.Completed; + } + + // CANONICAL CELL SEMANTICS (deliberate difference from the + // legacy direct-commit InboundPhysicsStateController.TryApplyPosition + // caller): refreshPosition stays false. A wire position never + // directly makes the record resident; only a Runtime SetPosition + // commit (below) or a simulation full-cell commit may change + // FullCellId. This matches retail (HandleReceivedPosition never + // sets a resident cell) and the classifier's own documented rule + // that a target frame with a nonzero cell does not make a + // cellless canonical body resident. The snapshot's Position + // field itself IS refreshed; only the derived FullCellId write + // is withheld. + // + // Round 3 B6: installPlacementFrame/clearParent come from the + // classified route's OWN ApplyPlacementFrameBeforeRouting/ + // UnparentBeforeRouting flags, not the legacy path's + // unconditional true/true. + PhysicsBody? body = canonical.PhysicsBody; + bool mergedOk = _entities.ApplyAcceptedPositionSnapshot( + canonical.ServerGuid, + update, + action.PositionDisposition, + action.AcceptedTimestamps, + isLocalPlayer, + body?.Orientation, + body?.Velocity, + installPlacementFrame: route.ApplyPlacementFrameBeforeRouting, + clearParent: route.UnparentBeforeRouting, + out WorldSession.EntitySpawn merged); + if (!mergedOk) + return Abandon(canonical, key); + _entities.RefreshSnapshot(canonical, merged, refreshPosition: false); + _entities.AdvancePositionAuthority(canonical); + _entities.ParentAttachments.EndChildProjection(canonical.ServerGuid); + // Round 3 B2: mutate -> rebaseline -> publish. Rebaselining + // BEFORE Publish closes the reentrant-retirement window a + // synchronous observer could otherwise see (the baseline would + // still show pre-mutation values while the observer reenters + // residence/executor state). Round 4 R4-4: only + // PositionAuthorityVersion moved (AdvancePositionAuthority also + // bumps VelocityAuthorityVersion, which is not one of the four + // executor-tracked baseline fields). + _residences.AdvanceExecutorBaseline( + canonical, token, RuntimeExecutorBaselineFields.PositionAuthorityVersion); + ulong positionVersion = canonical.PositionAuthorityVersion; + ulong spatialVersion = canonical.SpatialAuthorityVersion; + Publish( + canonical, + RuntimeEntityChange.Updated, + () => canonical.PositionAuthorityVersion == positionVersion + && canonical.SpatialAuthorityVersion == spatialVersion, + default, + buffer); + + if (!route.PerformsSetPosition) + { + // Interpolate / NoPositionOperation / AwaitFreshPosition: + // typed trace result only. Binding to the live + // interpolation owner is cutover work. + progress.Trace.Add(BuildPositionTrace(sequence, stage, route)); + return RuntimeInitialCreateExecutionStatus.Completed; + } + + progress.PositionMergeCommittedForRetry = true; + progress.PendingContinuationRoute = route; + progress.PositionMergeCommittedVersion = canonical.PositionAuthorityVersion; + } + + RuntimeEntityPlacementToken placement = _physics.SetPosition + .TryBeginExclusiveAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + route.OperationKind); + if (!placement.IsValid) + { + // Round 3 B1: distinguish genuine staleness (abandon) from + // transient operation-slot contention (retry - the SAME merge + // stays committed; only the begin attempt repeats). + if (!_entities.IsCurrent(canonical) + || canonical.PositionAuthorityVersion != progress.PositionMergeCommittedVersion) + { + progress.PositionMergeCommittedForRetry = false; + return Abandon(canonical, key); + } + return RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement; + } + if (!_physics.SetPosition.WatchPlacementCompletion(placement)) + { + _ = _physics.SetPosition.ForgetExactPlacement(placement); + progress.PositionMergeCommittedForRetry = false; + return Abandon(canonical, key); + } + + progress.PositionMergeCommittedForRetry = false; + progress.PendingContinuationPlacement = placement; + progress.PendingContinuationSequence = sequence; + progress.PendingContinuationRoute = route; + return RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement; + } + + private bool ApplyObjDescAction( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeInitialCreateTailAction action, + List? buffer) + { + if (!_entities.ApplyAcceptedObjDescSnapshot( + canonical.ServerGuid, + action.ObjDesc!.Value, + out WorldSession.EntitySpawn merged)) + { + return false; + } + _entities.RefreshSnapshot(canonical, merged); + _entities.AdvanceObjDescAuthority(canonical); + // Round 4 R4-4: ObjDescAuthorityVersion is not one of the four + // executor-tracked baseline fields (PositionAuthorityVersion/ + // CreateIntegrationVersion/FullCellId/PlacementCommitVersion) - no + // AdvanceExecutorBaseline call belongs here at all; calling it + // unconditionally would silently bless an external race on those + // four fields that this apply never touched. + ulong version = canonical.ObjDescAuthorityVersion; + Publish( + canonical, + RuntimeEntityChange.Updated, + () => canonical.ObjDescAuthorityVersion == version, + default, + buffer); + return true; + } + + /// + /// Round 5 R5-1: the SHARED attach-commit tail for BOTH the standalone + /// Parent continuation and the envelope CreateParent stage - the two + /// were byte-identical bodies before this round. The merge step that + /// precedes them (ApplyAcceptedParentSnapshot/ + /// ApplyAcceptedCreateParentSnapshot, factored out into + /// / + /// ) runs EXACTLY + /// once at the relation's original drain and is deliberately + /// position-timestamp-only - it never sets ParentGuid/ParentLocation on + /// the snapshot. Per the test + /// StandaloneParentContinuationAppliesPositionTimestampOnlyMergeWithNoReDefer's + /// established, pre-Round-5 precedent (verified against it directly: + /// an earlier revision of this method wrongly called + /// here and broke + /// that test), the actual attach commit is out of scope for + /// this residence-continuation drain - it is the App-layer + /// EquippedChildRenderController's job, invoked through + /// only after + /// it validates the parent's render-side PartArray/holding-location can + /// actually host the child (see LiveEntityRuntime.CommitStagedParent's + /// remarks). This method therefore commits ONLY the residence tail + /// (AdvancePositionAuthority/LeaveWorld/Forget/rebaseline/publish) and is + /// payload-agnostic, so it serves the live-continuation apply AND + /// 's replayed apply + /// identically - "apply through the SAME parent-apply body used by a + /// live Parent continuation" (round5-fixes.md R5-1) means exactly this + /// tail, not a new attach step neither live nor replay ever performed. + /// Unlike the legacy CommitPositionChannelUpdate helper, this does NOT + /// call ForgetInitialCreateResidence - the executor IS the residence + /// owner mid-drain; forgetting it here would cancel our own in-progress + /// lease. Residence teardown is exclusively the adoption/release + /// machinery's job (RunInitialTail / ConsumeExecuted). + /// is false ONLY at replay time, when the + /// child's own residence token is not held here - see + /// 's remarks for why that is + /// safe (fails closed, never silently blesses a baseline it does not + /// own). + /// + private void CommitParentAttachment( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + bool rebaseline, + List? buffer) + { + _entities.AdvancePositionAuthority(canonical); + _physics.CollisionReports.LeaveWorld(canonical); + RuntimePlacementCancellationReceipt cancellation = + _physics.SetPosition.Forget(canonical); + // Round 3 B2: mutate -> rebaseline -> publish. Round 4 R4-4: + // AdvancePositionAuthority only moves PositionAuthorityVersion of + // the four tracked fields. + if (rebaseline) + { + _residences.AdvanceExecutorBaseline( + canonical, token, RuntimeExecutorBaselineFields.PositionAuthorityVersion); + } + ulong positionVersion = canonical.PositionAuthorityVersion; + ulong spatialVersion = canonical.SpatialAuthorityVersion; + Publish( + canonical, + RuntimeEntityChange.Updated, + () => canonical.PositionAuthorityVersion == positionVersion + && canonical.SpatialAuthorityVersion == spatialVersion, + cancellation, + buffer); + } + + private bool ApplyPickupAction( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeInitialCreateTailAction action, + List? buffer) + { + if (!_entities.ApplyAcceptedPickupSnapshot( + canonical.ServerGuid, + action.Pickup!.Value, + out WorldSession.EntitySpawn merged)) + { + return false; + } + _entities.RefreshSnapshot(canonical, merged); + // Retail: the object entered world (this residence's initial tail), + // then was picked up - a FIFO entry always executes AFTER the + // initial placement committed. This is a leave-world edge, but it + // must not tear down the residence mid-drain: only ordinary + // SetPosition.Forget runs here, never ForgetInitialCreateResidence. + _entities.AdvancePositionAuthority(canonical); + _physics.CollisionReports.LeaveWorld(canonical); + RuntimePlacementCancellationReceipt cancellation = + _physics.SetPosition.Forget(canonical); + _entities.SuspendObjectClock(canonical); + _entities.SetFullCell(canonical, 0u, 0u); + _entities.ParentAttachments.EndChildProjection(canonical.ServerGuid); + // Round 3 B2: mutate -> rebaseline -> publish. Round 4 R4-4: + // AdvancePositionAuthority + SetFullCell(0,0) move + // PositionAuthorityVersion and FullCellId, the only two of the + // four tracked fields this apply touches. + _residences.AdvanceExecutorBaseline( + canonical, + token, + RuntimeExecutorBaselineFields.PositionAuthorityVersion + | RuntimeExecutorBaselineFields.FullCellId); + ulong positionVersion = canonical.PositionAuthorityVersion; + ulong spatialVersion = canonical.SpatialAuthorityVersion; + Publish( + canonical, + RuntimeEntityChange.Withdrawn, + () => canonical.PositionAuthorityVersion == positionVersion + && canonical.SpatialAuthorityVersion == spatialVersion, + cancellation, + buffer); + return true; + } + + private bool ApplyMovementAction( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeInitialCreateTailAction action, + List? buffer) + { + WorldSession.EntityMotionUpdate update = action.Movement!.Value; + // Safe to pass the retained wire's own MovementSequence directly + // here (unlike the legacy caller, which must read the live gate - + // see ApplyAcceptedMotion's remarks): a Movement continuation is + // only ever retained when AppliesMovementPayload || HasTimestampMutation, + // which structurally guarantees MOVEMENT_TS itself already advanced + // to this exact value at admission time. + if (!_entities.ApplyAcceptedMotionSnapshot( + canonical.ServerGuid, + update.MovementSequence, + action.AcceptedTimestamps.ServerControlledMove, + update, + retainPayload: false, + out WorldSession.EntitySpawn stamped)) + { + return false; + } + _entities.RefreshSnapshot(canonical, stamped); + if (!action.AppliesMovementPayload) + { + // Timestamp-only entry: stamp landed above; no publish beyond + // that, matching legacy's own timestamp-only branch. Round 4 + // R4-4: the stamp only moves MovementSequence/ServerControlSequence + // (nested Physics.Timestamps), never any of the four + // executor-tracked baseline fields - no AdvanceExecutorBaseline + // call here. + return true; + } + + if (action.RetainMovementPayload) + { + if (!_entities.ApplyAcceptedMotionSnapshot( + canonical.ServerGuid, + update.MovementSequence, + action.AcceptedTimestamps.ServerControlledMove, + update, + retainPayload: true, + out WorldSession.EntitySpawn merged)) + { + return false; + } + _entities.RefreshSnapshot(canonical, merged); + _entities.AdvanceMovementAuthority(canonical); + } + _entities.AdvanceMovementCommit(canonical); + // Round 4 R4-4: MovementAuthorityVersion/MovementCommitVersion are + // not among the four executor-tracked baseline fields - no + // AdvanceExecutorBaseline call belongs here. + ulong movementCommitVersion = canonical.MovementCommitVersion; + Publish( + canonical, + RuntimeEntityChange.Updated, + () => canonical.MovementCommitVersion == movementCommitVersion, + default, + buffer); + return true; + } + + /// + /// Round 4 R4-11: the BecameHidden branch's currency-failure path + /// returns false (routes the caller to the shared + /// Abandon/RejectedAuthority), not true as an earlier + /// revision of this method did - the legacy equivalent reports failure + /// there too, and the record genuinely mutated under us mid-apply. + /// Not independently unit-tested with a live reentrancy seam: this + /// harness has no constructible way to make + /// RuntimeCollisionReportingState.LeaveWorld invoke an observer + /// callback for a residence-fresh entity - EndExpiredObjectCollisions + /// returns immediately whenever _owners has no established + /// collision record for this key (see + /// RuntimeCollisionReportingState.cs's own early-return guard), + /// which is always true for an entity that has never yet run a real + /// collision batch. Building a synthetic seam to force that callback + /// would be exactly the kind of workaround this project's CLAUDE.md + /// forbids; the fix is verified by direct code review of the + /// now-symmetric bool contract instead (every OTHER Apply*Action + /// method already returns false, never true, on its own currency + /// failure). + /// + private bool ApplyStateAction( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeInitialCreateTailAction action, + List? buffer) + { + SetState.Parsed update = action.State!.Value; + if (!_entities.ApplyAcceptedStateSnapshot( + canonical.ServerGuid, + update, + out WorldSession.EntitySpawn merged)) + { + return false; + } + _entities.RefreshSnapshot(canonical, merged); + RetailPhysicsStateTransition preview = RetailPhysicsStateTransitions.Apply( + canonical.FinalPhysicsState, + (PhysicsStateFlags)update.PhysicsState); + ulong priorPhysicsMutation = canonical.PhysicsStateMutationVersion; + if (preview.HiddenTransition is RetailHiddenTransition.BecameHidden) + { + _physics.CollisionReports.LeaveWorld(canonical); + if (!_entities.IsCurrent(canonical) + || canonical.PhysicsStateMutationVersion != priorPhysicsMutation) + { + // Round 4 R4-11: the record mutated out from under us mid-apply + // (LeaveWorld's own synchronous collision-report callbacks can + // reenter and either invalidate currency or bump + // PhysicsStateMutationVersion again) - that IS an external + // race, not a successfully-applied continuation. Return false + // so the caller routes through the shared Abandon + // (RejectedAuthority), matching the legacy equivalent's + // failure report instead of silently claiming success. + return false; + } + } + RetailPhysicsStateTransition transition = + _entities.ApplyRawPhysicsState(canonical, update.PhysicsState); + if (canonical.Key is { } key) + { + _physics.Engine.ShadowObjects.UpdatePhysicsState( + key.LocalEntityId, + (uint)canonical.FinalPhysicsState); + } + // Round 4 R4-4: StateAuthorityVersion/PhysicsStateMutationVersion + // are not among the four executor-tracked baseline fields - no + // AdvanceExecutorBaseline call belongs here. + ulong stateVersion = canonical.StateAuthorityVersion; + ulong physicsMutationVersion = canonical.PhysicsStateMutationVersion; + Publish( + canonical, + transition.HiddenTransition is RetailHiddenTransition.BecameHidden + ? RuntimeEntityChange.Hidden + : RuntimeEntityChange.Updated, + () => canonical.StateAuthorityVersion == stateVersion + && canonical.PhysicsStateMutationVersion == physicsMutationVersion, + default, + buffer); + return true; + } + + private bool ApplyVectorAction( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeInitialCreateTailAction action, + List? buffer) + { + if (!_entities.ApplyAcceptedVectorSnapshot( + canonical.ServerGuid, + action.Vector!.Value, + out WorldSession.EntitySpawn merged)) + { + return false; + } + _entities.RefreshSnapshot(canonical, merged); + _entities.AdvanceVectorAuthority(canonical); + // Round 4 R4-4: VectorAuthorityVersion is not among the four + // executor-tracked baseline fields - no AdvanceExecutorBaseline + // call belongs here. + ulong version = canonical.VectorAuthorityVersion; + Publish( + canonical, + RuntimeEntityChange.Updated, + () => canonical.VectorAuthorityVersion == version, + default, + buffer); + return true; + } + + private bool ApplyWeenieDescriptionAction( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeInitialCreateTailAction action, + List? buffer) + { + // Round 3 A2: this must NOT be a wholesale RefreshSnapshot of the + // raw retained packet - it merges exactly like every other + // same-generation Create (MergeUntimestampedCreate via the instance + // seam), keeping the retained Position/appearance/physics-timestamp + // fields earlier stages already committed to _snapshots. + if (!_entities.ApplyAcceptedWeenieDescriptionSnapshot( + canonical.ServerGuid, + action.WeenieDescription!.Value, + out WorldSession.EntitySpawn merged)) + { + return false; + } + _entities.RefreshSnapshot(canonical, merged, refreshPosition: false); + // RuntimeEntityObjectLifetime.RegisterEntityCore's ExistingGeneration + // branch only calls Entities.AdvanceCreateAuthority when + // !beginInitialResidence - a residence-pending entity's admission + // deliberately skipped it. This deferred WeenieDescription tail + // action is where that authority mutation actually lands. + _entities.AdvanceCreateAuthority(canonical); + ulong createVersion = canonical.CreateIntegrationVersion; + // Round 3 B12: RuntimeLiveEntitySessionController.OnSpawned (the + // non-residence direct-host Create path) drives + // ApplyAcceptedSpawn(canonical, integrationVersion, canonical.Snapshot, + // replaceGeneration: NewGeneration) for EVERY accepted Create - the + // prior "zero callers" claim for this object-table wiring was false. + // This tail action is the residence path's exact counterpart: it + // only ever runs for an ExistingGeneration same-incarnation Create + // (a residence is admitted only when preview is ExistingGeneration), + // so replaceGeneration is always false here. + // + // Round 4 R4-3: order is AdvanceCreateAuthority -> AdvanceExecutorBaseline + // -> _applyAcceptedSpawn -> (false -> typed Abandon) -> buffered + // publish. Rebaselining BEFORE the object-table apply (rather than + // after, as an earlier revision did) means a reentrant callback + // FROM WITHIN _applyAcceptedSpawn's own synchronous + // ObjectAdded/ObjectUpdated dispatch already observes a current + // baseline. _applyAcceptedSpawn's own result is now actually + // OBSERVED rather than discarded: RuntimeEntityObjectLifetime. + // ApplyAcceptedSpawn re-checks currency before, during, AND after + // its own object-table apply (its callback is synchronous and may + // re-enter entity lifetime) - a nested replacement racing in from + // that same callback invalidates the exact canonical incarnation + // this drain is still executing against, mirroring + // RuntimeLiveEntitySessionController.cs:87's own gate on that same + // call's result. The remaining tail cannot run against a record a + // nested replacement has already superseded. + _residences.AdvanceExecutorBaseline( + canonical, + token, + RuntimeExecutorBaselineFields.PositionAuthorityVersion + | RuntimeExecutorBaselineFields.CreateIntegrationVersion); + if (!_applyAcceptedSpawn(canonical, createVersion, merged, /* replaceGeneration: */ false)) + return false; + Publish( + canonical, + RuntimeEntityChange.Updated, + () => canonical.CreateIntegrationVersion == createVersion, + default, + buffer); + return true; + } + + /// + /// Retail: SmartBox::HandleCreateObject's same-incarnation tail, final + /// step (retail-notes.md function 1, 0x00454c80, lines ~788-801) - + /// objcell_id != 0 && cell == 0 marks for destruction, + /// objcell_id != 0 && cell != 0 un-marks, and no cell + /// claimed with no weenie also marks for destruction. Asserts the + /// invariant rather than building a new destruction mechanism: any + /// claimed-but-celless outcome must already be under lost-cell/deferred + /// SetPosition ownership. + /// + private RuntimeResidentCellCleanupDisposition? ApplyResidentCellCleanup( + RuntimeEntityRecord canonical) + { + uint claimedCell = canonical.Snapshot.Physics?.Position?.LandblockId + ?? canonical.Snapshot.Position?.LandblockId + ?? 0u; + if (claimedCell == 0u) + { + // No cell claimed. See RuntimeResidentCellCleanupDisposition. + // CelllessNoWeenieMarkUnreachable's remarks: retail's matching + // no-weenie destruction-mark condition is structurally + // unreachable through this exact envelope path. + return RuntimeResidentCellCleanupDisposition + .CelllessNoWeenieMarkUnreachable; + } + if (canonical.FullCellId != 0u) + return RuntimeResidentCellCleanupDisposition.ResidentUnmarked; + if (!_physics.SetPosition.IsDeferred(canonical)) + { + // Round 3 B1: the fail-closed invariant violation + // (claimed+celless+not-deferred) is a typed abandonment - null + // signals the caller to Abandon rather than letting an + // exception escape Execute. + return null; + } + // Claimed but celless, and an existing lost-cell/deferred + // SetPosition operation already owns this exact entity: the + // destruction mark belongs to that existing lifetime (retail: + // AddObjectToBeDestroyed was already reached via that path), not to + // this tail action - assert the invariant, do not invent a second + // destruction mechanism. + return RuntimeResidentCellCleanupDisposition.DeferredUnderLostCellOwnership; + } + + private void Publish( + RuntimeEntityRecord canonical, + RuntimeEntityChange change, + Func matches, + RuntimePlacementCancellationReceipt cancellation, + List? buffer) + { + if (buffer is not null) + { + // Buffered (same-incarnation envelope) publishes flush only + // after EVERY stage has committed. The per-field "matches" + // check the IMMEDIATE (standalone-continuation) path uses + // exists to catch a reentrant race between one mutation and + // its own publish - but a LATER stage in the SAME envelope + // legitimately advances the SAME field again as normal, + // expected progression (e.g. WeenieDescription's + // AdvanceCreateAuthority bumps Position/State/Vector/ObjDesc + // authority all at once), which would make an EARLIER stage's + // captured matches() go stale by flush time even though + // nothing external raced it. IsCurrent (checked unconditionally + // by PublishNow below) is the only currency guard a buffered + // entry needs: envelope processing dispatches no event until + // the flush, so there is no opportunity for reentrancy mid- + // envelope except at a Position-stage yield, and THAT window is + // independently guarded by ApplyEnvelope's own IsCurrent check + // at the top of the resumed loop and by ResumePendingPlacement. + buffer.Add(new PendingPublish(change, static () => true, cancellation)); + return; + } + PublishNow(canonical, change, matches, cancellation); + } + + private void PublishNow( + RuntimeEntityRecord canonical, + RuntimeEntityChange change, + Func matches, + RuntimePlacementCancellationReceipt cancellation) + { + _physics.SetPosition.PublishCancellation(cancellation); + if (_entities.IsCurrent(canonical) && matches()) + _events.PublishEntity(change, canonical); + } + + private static RuntimeInitialCreateExecutedAction BuildPositionTrace( + ulong sequence, + int stage, + in RuntimeAuthoritativePositionRoute route) => new( + RuntimeInitialCreateExecutedActionKind.Position, + sequence, + stage, + route.Disposition, + route.TeleportHookPhase, + null, + null, + // Round 3 B6: record the route's own flags in the trace. + route.ConstrainPhase, + route.StopInterpolating, + route.ZeroVelocity, + route.PreserveHeading, + route.SendPositionImmediately, + route.UnparentBeforeRouting); + + private static RuntimeInitialCreateExecutedAction Simple( + RuntimeInitialCreateExecutedActionKind kind, + ulong sequence, + int stage = -1, + RuntimeParentRelationOutcome? parentRelationOutcome = null) => new( + kind, + sequence, + stage, + null, + RuntimeTeleportHookPhase.None, + ParentRelationOutcome: parentRelationOutcome); + + private static RuntimePositionEntityKind EntityKindOf( + RuntimeSetPositionOperationKind operationKind) => operationKind switch + { + RuntimeSetPositionOperationKind.InitialLogin + or RuntimeSetPositionOperationKind.LocalAuthoritative => + RuntimePositionEntityKind.LocalPlayer, + RuntimeSetPositionOperationKind.ProjectileAuthoritative => + RuntimePositionEntityKind.Projectile, + _ => RuntimePositionEntityKind.Remote, + }; + + private RuntimeGenerationToken CurrentGeneration() => _generation?.Invoke() ?? default; +} diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs index c8e1388c..636f1725 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs @@ -362,6 +362,20 @@ internal enum RuntimeInitialCreateResidenceCompletionStatus : byte RejectedAuthority, } +/// +/// Result of , +/// the executor-only release that supersedes the host's +/// once +/// the initial placement has been adopted. +/// +internal enum RuntimeInitialCreateResidenceExecutorReleaseStatus : byte +{ + Released, + Revised, + RejectedToken, + RejectedAuthority, +} + /// /// Exact post-residence receipt. A local graphical or no-window host may run /// the retail after-enter teleport suffix only when this receipt carries @@ -385,6 +399,29 @@ internal readonly record struct RuntimeInitialCreateResidenceOwnershipSnapshot( && PendingAdoptionCount == 0; } +/// +/// Round 4 R4-4: field-masked precision for +/// . +/// The blanket four-field re-sync the executor previously called after +/// EVERY apply silently absorbed an external race on whichever field(s) a +/// given apply did NOT itself move - e.g. an ObjDesc/Movement/State/Vector +/// apply never touches PositionAuthorityVersion/CreateIntegrationVersion/ +/// FullCellId/PlacementCommitVersion, so blanket-resyncing all four there +/// would mask a genuine concurrent bump to one of them instead of letting +/// the next +/// check catch it. Each caller now passes exactly the field(s) its OWN +/// mutation moved. +/// +[Flags] +internal enum RuntimeExecutorBaselineFields : byte +{ + None = 0, + PositionAuthorityVersion = 1 << 0, + CreateIntegrationVersion = 1 << 1, + FullCellId = 1 << 2, + PlacementCommitVersion = 1 << 3, +} + /// /// Owns only initial CreateObject residence leases. DAT lookup, body creation, /// and presentation stay outside this owner; their immutable preparation is @@ -403,6 +440,48 @@ internal sealed class RuntimeInitialCreateResidenceState internal required RuntimeEntityRecord Record { get; init; } internal required RuntimeInitialCreateResidenceLease Lease { get; set; } internal required RuntimeInitialCreateResidenceReceipt Receipt { get; set; } + + /// + /// True once the continuation executor has consumed the initial + /// placement's acknowledged completion through + /// . A retained + /// _acknowledgedPlacementCompletions entry on + /// blocks EVERY later placement + /// begin for the same key (see + /// 's + /// HasRetainedCompletion guard) — a Position continuation could + /// never start its own authored placement while the initial one still + /// sits unconsumed. Adoption resolves that deadlock by consuming the + /// proof exactly once, while this flag keeps the completed entry + /// itself "current" for placement-tracking purposes even though the + /// placement token is no longer separately tracked. + /// + internal bool PlacementAdopted { get; set; } + + /// + /// Executor-tracked baseline for the four version/cell fields + /// compares against the LIVE record. + /// Seeded from 's own (frozen, identity-matching) + /// Token/FullCellId/PlacementCommitVersion at the + /// moment first produces this entry, then kept + /// in sync by every time the + /// continuation executor legitimately advances one of them while + /// applying a retained continuation. .Token + /// itself must NEVER be rebaselined — a caller (the executor) always + /// re-presents the SAME original token instance on every retry, and + /// 's own token-identity match + /// (completed.Receipt.Token == token) depends on that struct + /// staying byte-identical. Splitting "identity" (the frozen token) + /// from "expected current value" (these fields) is what lets the + /// executor's own sequential mutations keep the entry current + /// without the residence mistaking its own controlled progress for + /// an external race - see the 2026-08-01 admission handoff's own + /// warning about exactly this risk. + /// + internal ulong ExpectedPositionAuthorityVersion { get; set; } + internal ulong ExpectedCreateIntegrationVersion { get; set; } + internal uint ExpectedFullCellId { get; set; } + internal ulong ExpectedPlacementCommitVersion { get; set; } } private readonly RuntimeEntityDirectory _entities; @@ -410,6 +489,7 @@ internal sealed class RuntimeInitialCreateResidenceState private readonly Dictionary _entries = []; private readonly Dictionary _completed = []; private Func? _generation; + private Action? _retirementNotification; private ulong _nextLeaseId; internal RuntimeInitialCreateResidenceState( @@ -432,6 +512,31 @@ internal sealed class RuntimeInitialCreateResidenceState _generation = generation; } + /// + /// Round 3 B3: the ONE choke point every residence retirement path - + /// , , + /// , and - notifies through, + /// regardless of which caller (a host query, a staleness check inside + /// this class, or the continuation executor itself) triggered the + /// retirement. Without this, a residence retired by a path OTHER than + /// the executor's own DiscardProgress call (e.g. a host's + /// silently discovering staleness) would + /// leave the executor's progress AND its separately-tracked pending + /// continuation placement token orphaned - this class owns no reference + /// to the executor type, so the lifetime binds a plain delegate here + /// instead. + /// + internal void BindRetirementNotification(Action notify) + { + ArgumentNullException.ThrowIfNull(notify); + if (_retirementNotification is not null) + { + throw new InvalidOperationException( + "The initial Create residence retirement notification is already bound."); + } + _retirementNotification = notify; + } + internal bool CanAcceptCreate(WorldSession.EntitySpawn incoming) { bool parented = (incoming.ParentGuid @@ -788,10 +893,59 @@ internal sealed class RuntimeInitialCreateResidenceState Record = record, Lease = lease, Receipt = receipt, + ExpectedPositionAuthorityVersion = token.PositionAuthorityVersion, + ExpectedCreateIntegrationVersion = token.CreateIntegrationVersion, + ExpectedFullCellId = receipt.FullCellId, + ExpectedPlacementCommitVersion = receipt.PlacementCommitVersion, }); return RuntimeInitialCreateResidenceCompletionStatus.Completed; } + /// + /// Executor-only: re-synchronizes the completed entry's staleness + /// baseline (see + /// remarks) to the record's CURRENT live values, but ONLY for the + /// field(s) named in (Round 4 R4-4). Called + /// after the continuation executor legitimately advances one or more of + /// PositionAuthorityVersion/CreateIntegrationVersion/FullCellId/ + /// PlacementCommitVersion while applying a retained continuation, so a + /// LATER / check + /// does not mistake the executor's own controlled progress for an + /// external race. Passing a field NOT actually moved by the caller's own + /// mutation would defeat the whole point - it would silently bless an + /// external race on that field instead of letting the next currency + /// check catch it - so every call site names exactly its own field(s); + /// an apply that moves none of the four tracked fields (ObjDesc, + /// Movement, State, Vector) must not call this method at all. A no-op + /// (returns false) if the token no longer matches a live completed + /// entry - the executor's own currency checks catch that condition + /// independently and this call is purely advisory bookkeeping, never a + /// source of truth by itself. + /// + internal bool AdvanceExecutorBaseline( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken token, + RuntimeExecutorBaselineFields fields) + { + ArgumentNullException.ThrowIfNull(record); + if (!token.IsValid + || !_completed.TryGetValue(token.Entity, out CompletedEntry? entry) + || !ReferenceEquals(entry.Record, record) + || entry.Receipt.Token != token) + { + return false; + } + if ((fields & RuntimeExecutorBaselineFields.PositionAuthorityVersion) != 0) + entry.ExpectedPositionAuthorityVersion = record.PositionAuthorityVersion; + if ((fields & RuntimeExecutorBaselineFields.CreateIntegrationVersion) != 0) + entry.ExpectedCreateIntegrationVersion = record.CreateIntegrationVersion; + if ((fields & RuntimeExecutorBaselineFields.FullCellId) != 0) + entry.ExpectedFullCellId = record.FullCellId; + if ((fields & RuntimeExecutorBaselineFields.PlacementCommitVersion) != 0) + entry.ExpectedPlacementCommitVersion = record.PlacementCommitVersion; + return true; + } + internal bool AcknowledgeAdoption( RuntimeEntityRecord record, in RuntimeInitialCreateResidenceAdoptionToken token) @@ -816,7 +970,15 @@ internal sealed class RuntimeInitialCreateResidenceState // discard accepted packets. if (!current.Lease.Continuations.IsEmpty) return false; + // The executor's own release path is ConsumeExecuted, not this host + // method. If the executor already adopted the placement proof + // (RuntimeInitialCreateContinuationExecutor.AdoptCompletedPlacement), + // it is gone from RuntimeSetPositionState's tracking table entirely — + // do not re-consume it a second time, just tolerate the already- + // satisfied state and fall through to the same removal every other + // caller of this host method observes. if (current.Lease.Route.PerformsSetPosition + && !current.PlacementAdopted && !_setPosition.ConsumeAcknowledgedPlacement( current.Lease.Placement, current.Receipt.Projection)) @@ -841,6 +1003,7 @@ internal sealed class RuntimeInitialCreateResidenceState lease = entry.Lease; cancellation = _setPosition.ForgetExactPlacement( lease.Placement); + _retirementNotification?.Invoke(key); return true; } if (record.Key is { } completedKey @@ -853,6 +1016,7 @@ internal sealed class RuntimeInitialCreateResidenceState lease = completed.Lease; cancellation = _setPosition.ForgetExactPlacement( lease.Placement); + _retirementNotification?.Invoke(completedKey); return true; } lease = default; @@ -888,6 +1052,13 @@ internal sealed class RuntimeInitialCreateResidenceState { _setPosition.PublishCancellation(cancellations[index]); } + if (_retirementNotification is { } notify) + { + foreach (Entry entry in active) + notify(entry.Lease.Token.Entity); + foreach (CompletedEntry entry in completed) + notify(entry.Receipt.Token.Entity); + } } internal RuntimeInitialCreateResidenceOwnershipSnapshot CaptureOwnership() => @@ -917,6 +1088,24 @@ internal sealed class RuntimeInitialCreateResidenceState return _generation?.Invoke() ?? default; } + /// + /// The staleness check every completed-entry caller shares. Compares the + /// live record against + /// et al — an executor-tracked, continuously re-synchronized baseline — + /// rather than against 's + /// own FROZEN admission-time fields directly. This is what lets the + /// continuation executor's own legitimate mutations + /// (AdvancePositionAuthority, AdvanceCreateAuthority, SetFullCell, + /// AdvancePlacementCommit — all driven by applying a retained + /// continuation) keep this entry current across the many + /// re-entries a multi-call drain requires, while + /// still correctly detecting a genuine EXTERNAL race (anything that + /// changes one of these fields WITHOUT going through + /// ) exactly as it always did. The + /// token itself remains the untouched identity/match key - + /// 's completed.Receipt.Token == token check + /// depends on that. + /// private bool IsCompletedCurrent(CompletedEntry entry) { RuntimeInitialCreateResidenceReceipt receipt = entry.Receipt; @@ -925,35 +1114,147 @@ internal sealed class RuntimeInitialCreateResidenceState && _entities.SessionLifetimeVersion == receipt.Token.SessionLifetimeVersion && entry.Record.PositionAuthorityVersion - == receipt.Token.PositionAuthorityVersion + == entry.ExpectedPositionAuthorityVersion && entry.Record.CreateIntegrationVersion - == receipt.Token.CreateIntegrationVersion - && entry.Record.FullCellId == receipt.FullCellId + == entry.ExpectedCreateIntegrationVersion + && entry.Record.FullCellId == entry.ExpectedFullCellId && entry.Record.PlacementCommitVersion - == receipt.PlacementCommitVersion + == entry.ExpectedPlacementCommitVersion && entry.Lease.Route.Authority.Generation == CurrentGeneration() && receipt.Token.SessionLifetimeVersion == receipt.Adoption.SessionLifetimeVersion && receipt.Token.LeaseId == receipt.Adoption.LeaseId + // A completed entry whose placement proof the executor already + // adopted remains current on the placement dimension without + // re-querying RuntimeSetPositionState: AdoptCompletedPlacement + // consumed (removed) the exact tracked token, so + // IsPlacementCompletionTracked would now report false even though + // nothing here has gone stale. && (!entry.Lease.Route.PerformsSetPosition + || entry.PlacementAdopted || _setPosition.IsPlacementCompletionTracked( entry.Lease.Placement)); } + /// + /// Executor-only: consumes the initial placement's acknowledged + /// completion exactly once so a later retained Position continuation can + /// begin its own authored placement for the same + /// (see the remarks on + /// for why this is + /// necessary). Idempotent: a retry after is + /// already true is a no-op success, never a double-consume. + /// + internal bool AdoptCompletedPlacement( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken token) + { + ArgumentNullException.ThrowIfNull(record); + if (!token.IsValid + || !_completed.TryGetValue(token.Entity, out CompletedEntry? entry) + || !ReferenceEquals(entry.Record, record) + || entry.Receipt.Token != token) + { + return false; + } + if (entry.PlacementAdopted) + return IsCompletedCurrent(entry); + if (!IsCompletedCurrent(entry)) + { + Retire(entry); + return false; + } + if (!entry.Lease.Route.PerformsSetPosition) + { + // A Parented/PickedUp lease never captured a real placement + // token; there is nothing to consume, but the tail must still be + // able to progress past this step exactly once. + entry.PlacementAdopted = true; + return true; + } + if (!_setPosition.ConsumeAcknowledgedPlacement( + entry.Lease.Placement, + entry.Receipt.Projection)) + { + return false; + } + entry.PlacementAdopted = true; + return true; + } + + /// + /// Executor-only release: consumes the residence entirely once the exact + /// adoption token still matches AND the caller has applied every + /// continuation through the CURRENT lease's full length. Placement + /// consumption already happened via , + /// so this does not call + /// a second time for an adopted entry — unlike the host-facing + /// , which only ever runs for entries the + /// executor has not touched. + /// + internal RuntimeInitialCreateResidenceExecutorReleaseStatus ConsumeExecuted( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceAdoptionToken token, + ulong executedThroughSequence) + { + ArgumentNullException.ThrowIfNull(record); + if (!token.IsValid + || !_completed.TryGetValue(token.Entity, out CompletedEntry? entry) + || !ReferenceEquals(entry.Record, record) + || entry.Receipt.Adoption.Entity != token.Entity + || entry.Receipt.Adoption.LeaseId != token.LeaseId) + { + return RuntimeInitialCreateResidenceExecutorReleaseStatus + .RejectedToken; + } + if (!IsCompletedCurrent(entry)) + { + Retire(entry); + return RuntimeInitialCreateResidenceExecutorReleaseStatus + .RejectedAuthority; + } + if (entry.Receipt.Adoption.Revision != token.Revision) + { + // A newer continuation arrived mid-drain (Enqueue bumps Revision + // in place on the SAME completed entry). The executor must + // re-fetch via Complete and drain the tail, never replay the + // already-applied prefix. + return RuntimeInitialCreateResidenceExecutorReleaseStatus.Revised; + } + if (!entry.PlacementAdopted && entry.Lease.Route.PerformsSetPosition) + { + throw new InvalidOperationException( + "Executor release requires the initial placement to have been adopted first."); + } + if ((ulong)entry.Lease.Continuations.Length != executedThroughSequence) + { + return RuntimeInitialCreateResidenceExecutorReleaseStatus + .RejectedAuthority; + } + return _completed.Remove(token.Entity) + ? RuntimeInitialCreateResidenceExecutorReleaseStatus.Released + : RuntimeInitialCreateResidenceExecutorReleaseStatus + .RejectedAuthority; + } + private void Retire(Entry entry) { - _entries.Remove(entry.Lease.Token.Entity); + RuntimeEntityKey key = entry.Lease.Token.Entity; + _entries.Remove(key); RuntimePlacementCancellationReceipt cancellation = _setPosition.ForgetExactPlacement(entry.Lease.Placement); _setPosition.PublishCancellation(cancellation); + _retirementNotification?.Invoke(key); } private void Retire(CompletedEntry entry) { - _completed.Remove(entry.Receipt.Token.Entity); + RuntimeEntityKey key = entry.Receipt.Token.Entity; + _completed.Remove(key); RuntimePlacementCancellationReceipt cancellation = _setPosition.ForgetExactPlacement(entry.Lease.Placement); _setPosition.PublishCancellation(cancellation); + _retirementNotification?.Invoke(key); } } diff --git a/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs b/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs index 0b0b0259..4edd2dbf 100644 --- a/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs @@ -339,6 +339,76 @@ public sealed class InboundPhysicsStateControllerTests Assert.Equal((ushort)2, retained.Physics!.Value.Timestamps.Movement); } + [Fact] + public void StaleMovementTimestampLeavesLegacySnapshotByteIdentical() + { + var controller = new InboundPhysicsStateController(); + WorldSession.EntitySpawn spawn = WithTimestamps( + Spawn(0x70000008u, 3, 1, 1, Position(0x0101FFFFu, 10f), 0x408u), + movement: 5, + serverControl: 5); + controller.AcceptCreate(spawn); + Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn before)); + + // MOVEMENT_TS 3 is OLDER than the stored 5: + // PhysicsTimestampGate.TryAcceptMovementEvent's + // AdvanceStrict(Movement, ...) fails before ever consulting + // SERVER_CONTROLLED_MOVE_TS - the gate is entirely untouched, and + // the canonical snapshot must not move either (the regression this + // guards: ApplyAcceptedMotion stamping the WIRE's own stale + // MovementSequence instead of the gate's unchanged post-call value). + bool applied = controller.TryApplyMotion( + new WorldSession.EntityMotionUpdate( + spawn.Guid, + new CreateObject.ServerMotionState(0x3d, 0x99), + InstanceSequence: 3, + MovementSequence: 3, + ServerControlSequence: 9, + IsAutonomous: false), + retainPayload: true, + out WorldSession.EntitySpawn accepted, + out _); + + Assert.False(applied); + Assert.Equal(default, accepted); + Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn after)); + Assert.Equal(before, after); + } + + [Fact] + public void InstanceMismatchedMovementLeavesLegacySnapshotByteIdentical() + { + var controller = new InboundPhysicsStateController(); + WorldSession.EntitySpawn spawn = WithTimestamps( + Spawn(0x70000009u, 3, 1, 1, Position(0x0101FFFFu, 10f), 0x408u), + movement: 1, + serverControl: 1); + controller.AcceptCreate(spawn); + Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn before)); + + // A different INSTANCE_TS: TryAcceptInstance fails immediately, + // before MOVEMENT_TS or SERVER_CONTROLLED_MOVE_TS are ever + // consulted. Nothing in the gate or the canonical snapshot may move + // - a stale duplicate or foreign-incarnation packet must not + // silently drag a live entity's movement timestamps forward. + bool applied = controller.TryApplyMotion( + new WorldSession.EntityMotionUpdate( + spawn.Guid, + new CreateObject.ServerMotionState(0x3d, 0x99), + InstanceSequence: 4, + MovementSequence: 9, + ServerControlSequence: 9, + IsAutonomous: false), + retainPayload: true, + out WorldSession.EntitySpawn accepted, + out _); + + Assert.False(applied); + Assert.Equal(default, accepted); + Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn after)); + Assert.Equal(before, after); + } + [Fact] public void FreshForceWithOlderTeleportMirrorsForceButRejectsPose() { diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs new file mode 100644 index 00000000..ab1b8a9b --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs @@ -0,0 +1,4464 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Entities; + +public sealed class RuntimeInitialCreateContinuationExecutorTests +{ + private const uint Landblock = 0xA9B50000u; + private const uint Cell = Landblock | 0x0001u; + private static readonly RuntimeInitialCreateExecutionInputs NoContact = + new(UsePositionFromServer: false, PlayerDistance: 0f); + + // --------------------------------------------------------------- + // A. Basic + // --------------------------------------------------------------- + + [Fact] + public void CompletedTopLevelCreateAdoptsOnceEmitsHookAndConvergesEveryLedger() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(0x70020001u, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(RuntimeTeleportHookPhase.AfterEnterWorld, receipt.TeleportHookPhase); + Assert.Equal(0, receipt.ReplayedDeferredChildCount); + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.TeleportHookRequest, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal( + 0, + lifetime.Physics.SetPosition.CaptureOwnership().AcknowledgedPlacementCompletionCount); + Assert.False(lifetime.TryGetInitialCreateResidence(canonical, out _)); + + // Retrying with the now-stale token is a distinct, safe no-op. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _)); + } + + [Fact] + public void MixedSimpleContinuationsDrainInExactSequenceOrderAndMutateSnapshot() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 2UL); + const uint parentGuid = 0x70021100u; + const uint guid = 0x70021000u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); // parented -> AwaitFreshPosition, no placement + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x04000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + var vector = new VectorUpdate.Parsed( + guid, new Vector3(1f, 2f, 3f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + var state = new SetState.Parsed( + guid, + (uint)PhysicsStateFlags.Gravity, + InstanceSequence: 1, + StateSequence: 2); + Assert.True(lifetime.TryApplyState(state, null, out _, out _)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.ObjDesc, + RuntimeInitialCreateExecutedActionKind.Vector, + RuntimeInitialCreateExecutedActionKind.State, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal([1UL, 2UL, 3UL], + receipt.Trace + .Where(static a => a.Sequence != 0UL) + .Select(static a => a.Sequence)); + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + Assert.Equal(0x04000002u, canonical.Snapshot.BasePaletteId); + Assert.Equal(new Vector3(1f, 2f, 3f), canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 4 R4-4 (mandated regression test): a FIFO of ObjDesc then Vector + // - NEITHER touches any of the four executor-tracked baseline fields + // (PositionAuthorityVersion/CreateIntegrationVersion/FullCellId/ + // PlacementCommitVersion), so NEITHER may call AdvanceExecutorBaseline + // at all. An observer bumps PositionAuthorityVersion externally during + // ObjDesc's own publish; because ObjDesc's apply never rebaselines that + // field, the STALE Expected value survives into the next check. The + // drain must detect this external race at ConsumeExecuted (the "next + // step") and abandon - NOT silently absorb it and report Released, which + // a blanket four-field rebaseline (the pre-R4-4 shape) would have done. + [Fact] + public void FieldMaskedBaselinePrecisionDetectsAnExternalPositionRaceDuringAnUnrelatedObjDescPublish() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 96UL); + const uint parentGuid = 0x70038000u; + const uint guid = 0x70038001u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x07000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + var vector = new VectorUpdate.Parsed( + guid, new Vector3(1f, 2f, 3f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + bool bumped = false; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (bumped || delta.Change is not RuntimeEntityChange.Updated) + return; + bumped = true; + // External, non-executor mutation of PositionAuthorityVersion + // during the ObjDesc stage's OWN publish. + lifetime.Entities.AdvancePositionAuthority(canonical); + })); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.True(bumped); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 A1 (mandated regression test): the executor's applies must + // write InboundPhysicsStateController's OWN _snapshots[guid] - the + // legacy merge base - in lockstep with RuntimeEntityRecord.Snapshot. If + // they only wrote the record's own Snapshot (as before A1), the FIRST + // legacy wire apply reached after the residence drain would re-merge + // against a STALE _snapshots[guid] base and silently revert every fact + // the drain just committed. + [Fact] + public void DrainedAppearanceAndPoseSurviveTheNextLegacyWireApply() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 30UL); + const uint parentGuid = 0x7002D100u; + const uint guid = 0x7002D000u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + // Drain an ObjDesc continuation that changes appearance (a fresh + // BasePaletteId) as part of the residence FIFO. + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x06000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.ObjDesc, + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(0x06000002u, canonical.Snapshot.BasePaletteId); + + // Now run an ORDINARY legacy wire apply (Vector) on a channel the + // drain never touched. Its merge base is InboundPhysicsStateController's + // OWN _snapshots[guid] - if that store still held the pre-drain + // appearance (A1's bug), this apply's `old with { ... }` merge would + // carry the STALE BasePaletteId back into canonical.Snapshot via + // RefreshSnapshot, silently reverting the drained fact. + var vector = new VectorUpdate.Parsed( + guid, new Vector3(4f, 5f, 6f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + Assert.Equal(0x06000002u, canonical.Snapshot.BasePaletteId); + Assert.Equal(new Vector3(4f, 5f, 6f), canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 B12: the WeenieDescription tail action must drive the object + // table the same way RuntimeLiveEntitySessionController.OnSpawned does + // for the non-residence direct-host Create path (ApplyAcceptedSpawn) - + // the prior "zero callers" claim for this wiring was false. A + // residence-pending admission deliberately never wires the object table + // at the initial Create (RegisterEntityCore's beginInitialResidence + // branch); the FIRST time this guid's entry can appear is exactly here. + [Fact] + public void WeenieDescriptionStageWiresTheObjectTableExactlyOnce() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 34UL); + const uint guid = 0x7002E400u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + int objectCountBeforeDrain = lifetime.Objects.ObjectCount; + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, positionSequence: 2); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(objectCountBeforeDrain + 1, lifetime.Objects.ObjectCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 4 R4-3: ApplyWeenieDescriptionAction's object-table apply window. + // ClientObjectTable.Ingest publishes ObjectAdded/ObjectUpdated + // SYNCHRONOUSLY - a subscriber may re-enter a wire apply for the SAME + // entity from inside that dispatch. Because AdvanceExecutorBaseline now + // runs BEFORE _applyAcceptedSpawn (not after), the residence's baseline + // is already current at the moment the reentrant call runs; the + // residence is not retired and the envelope still completes. + [Fact] + public void ObjectTableSubscriberReenteringAWireApplyDuringIngestDoesNotRetireTheResidenceAndTheEnvelopeCompletes() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 94UL); + const uint guid = 0x70037000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, positionSequence: 2); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + bool reentered = false; + lifetime.Objects.ObjectAdded += addedObject => + { + if (reentered) + return; + reentered = true; + // A reentrant wire apply for a channel this envelope does NOT + // itself carry a dedicated stage for at this admission + // (Vector IS one of this envelope's own stages here, but the + // residence is still "pending" mid-drain, so this legitimately + // defers into the SAME FIFO rather than applying immediately - + // proving the residence survives the reentrant call). + var vector = new VectorUpdate.Parsed( + guid, new Vector3(9f, 8f, 7f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 3); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + }; + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.True(reentered); + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 4 R4-3: a NESTED REPLACEMENT (a newer incarnation Create for the + // SAME guid) arriving from within Ingest's own synchronous dispatch + // invalidates the exact canonical incarnation the drain is still + // executing against. ApplyAcceptedSpawn re-checks currency AFTER its own + // object-table apply (mirroring RuntimeLiveEntitySessionController.cs:87's + // gate on that same call's result) and returns false; the executor must + // treat this as a typed abandonment - the remaining tail (ResidentCellCleanup) + // never runs. + [Fact] + public void NestedReplacementDuringObjectTableIngestAbandonsTheWeenieDescriptionStageWithNoFurtherStages() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 95UL); + const uint guid = 0x70037100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, positionSequence: 2); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + bool reentered = false; + lifetime.Objects.ObjectAdded += addedObject => + { + if (reentered) + return; + reentered = true; + _ = lifetime.RegisterEntity(Spawn(guid, 2, includePosition: false)); + }; + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.True(reentered); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 A2 (mandated regression test): a standalone ObjDesc + // continuation admits a fresh palette (bumping the gate's ObjDesc + // channel and proving A1's snapshot lockstep feeds entry 2's merge + // base), THEN a same-incarnation Create arrives whose OWN raw + // WeenieDescription packet carries a DIFFERENT MotionTableId. Appearance + // fields (BasePaletteId etc.) are legitimately re-applied by the + // envelope's OWN dedicated ObjDesc stage (retail-faithful: a same- + // generation Create's tail always re-runs its own ObjDesc first) - that + // is NOT what A2 fixes. MotionTableId has NO dedicated envelope stage; + // WeenieDescription's merge is the ONLY place it can move, which + // isolates MergeUntimestampedCreate's "retained wins" rule cleanly: the + // prior wholesale RefreshSnapshot bug would have silently overwritten it + // with the incoming raw packet's value instead. + [Fact] + public void SameIncarnationWeenieDescriptionMergePreservesRetainedMotionTableId() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 31UL); + const uint parentGuid = 0x7002D300u; + const uint guid = 0x7002D200u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + + WorldSession.EntitySpawn initial = Spawn(guid, 1, includePosition: false, parentGuid: parentGuid); + PhysicsSpawnData initialPhysics = initial.Physics!.Value; + initial = initial with + { + MotionTableId = 0x12345678u, + Physics = initialPhysics with { MotionTableId = 0x12345678u }, + }; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(initial, isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + // FIFO entry 1: standalone ObjDesc with a NEW palette, bumping the + // gate's ObjDesc channel to 2. + var newAppearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x07000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(newAppearance, null, out _)); + + // FIFO entry 2: a same-incarnation Create whose OWN raw + // WeenieDescription packet carries a DIFFERENT MotionTableId and a + // NEWER-still ObjDesc channel stamp (3). + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, parentGuid: parentGuid, positionSequence: 2); + PhysicsSpawnData samePhysics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + MotionTableId = 0x99999999u, + Physics = samePhysics with + { + MotionTableId = 0x99999999u, + Timestamps = samePhysics.Timestamps with { ObjDesc = 3, State = 2, Vector = 2 }, + }, + }; + RuntimeEntityRegistrationResult same = lifetime + .RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, same.Inbound.Disposition); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + receipt.Trace.Select(static a => a.Kind)); + // The envelope's OWN ObjDesc stage correctly re-applies THIS + // Create's own appearance (sameCreate never set its own palette, so + // it legitimately reverts to null - retail-faithful, not what A2 + // fixes). Entry 1's transient fresh palette was always going to be + // superseded by entry 2's OWN ObjDesc stage; that stage - not + // WeenieDescription - owns this field. + Assert.Null(canonical.Snapshot.BasePaletteId); + // MotionTableId has no dedicated envelope stage - WeenieDescription's + // merge must keep the RETAINED value, never adopt incoming's raw + // packet wholesale. + Assert.Equal(0x12345678u, canonical.Snapshot.Physics!.Value.MotionTableId); + Assert.Equal(0x12345678u, canonical.Snapshot.MotionTableId); + // ObjDesc's timestamp DOES have a dedicated stage that legitimately + // advances it further as part of entry 2's own admission (unlike + // MotionTableId). + Assert.Equal(3, canonical.Snapshot.Physics!.Value.Timestamps.ObjDesc); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // --------------------------------------------------------------- + // B. Standalone continuations (Movement/Pickup/Parent) + enqueue- + // during-drain (ConsumeExecuted's Revised arm). + // --------------------------------------------------------------- + + [Fact] + public void StandaloneMovementContinuationAppliesPayloadAndPublishesUpdated() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 40UL); + const uint guid = 0x7002F000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var motion = new WorldSession.EntityMotionUpdate( + guid, + new CreateObject.ServerMotionState(0x3d, 0x11), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 1, + IsAutonomous: false); + // Movement (2) advances and ServerControl (1) is equal-not-stale, so + // the gate accepts the payload outright. + Assert.True(lifetime.TryApplyMotion(motion, retainPayload: true, null, out _, out _)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.Movement, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(new CreateObject.ServerMotionState(0x3d, 0x11), + canonical.Snapshot.MotionState); + Assert.Equal(2, canonical.Snapshot.Physics!.Value.Timestamps.Movement); + Assert.Equal([RuntimeEntityChange.Updated], observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void StandaloneMovementContinuationTimestampOnlyStampsWithoutPayloadOrPublish() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 41UL); + const uint guid = 0x7002F100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + Assert.Null(canonical.Snapshot.MotionState); + + var motion = new WorldSession.EntityMotionUpdate( + guid, + new CreateObject.ServerMotionState(0x3d, 0x11), + InstanceSequence: 1, + MovementSequence: 2, + // Retail consumes MOVEMENT_TS before discovering a stale + // SERVER_CONTROLLED_MOVE_TS (0, older than the gate's seeded 1) - + // Movement itself still advances (hasTimestampMutation) even + // though the overall event, and thus the payload, is rejected. + ServerControlSequence: 0, + IsAutonomous: false); + Assert.False(lifetime.TryApplyMotion(motion, retainPayload: true, null, out _, out _)); + Assert.True(lifetime.InitialCreateResidences.TryGetTransaction( + canonical, out RuntimeInitialCreateResidenceLease retained)); + Assert.Single(retained.Continuations); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.Movement, + receipt.Trace.Select(static a => a.Kind)); + // Timestamp landed; no payload, no publish (matches the legacy + // timestamp-only branch). + Assert.Equal(2, canonical.Snapshot.Physics!.Value.Timestamps.Movement); + Assert.Null(canonical.Snapshot.MotionState); + Assert.Empty(observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void StandalonePickupContinuationLeavesWorldThroughTheDrainAndResidenceStillReleases() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 42UL); + const uint guid = 0x7002F200u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + Assert.Equal(Cell, canonical.FullCellId); + + // A committed parent projection so Pickup's own EndChildProjection + // has something real to tear down. + var relation = new ParentAttachmentRelation( + 0x7002F300u, guid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 1); + lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(lifetime.Entities.ParentAttachments.CommitProjection(relation)); + Assert.True(lifetime.Entities.ParentAttachments.HasCommittedParent(guid)); + + Assert.True(lifetime.TryApplyPickup( + new PickupEvent.Parsed(guid, InstanceSequence: 1, PositionSequence: 2), + null, + out _)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + ulong clockEpochBefore = canonical.ObjectClockEpoch; + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.Pickup, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal([RuntimeEntityChange.Withdrawn], observed); + Assert.Equal(0u, canonical.FullCellId); + // SuspendObjectClock bumps the epoch - the clock is no longer live. + Assert.NotEqual(clockEpochBefore, canonical.ObjectClockEpoch); + Assert.False(lifetime.Entities.ParentAttachments.HasCommittedParent(guid)); + Assert.False(lifetime.Entities.ParentAttachments.TryGetRecoveryProjection(guid, out _)); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + // Residence still releases cleanly even though this continuation + // left the world mid-drain. + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void StandaloneParentContinuationAppliesPositionTimestampOnlyMergeWithNoReDefer() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 43UL); + const uint parentGuid = 0x7002F400u; + const uint childGuid = 0x7002F500u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var parentUpdate = new ParentEvent.Parsed( + parentGuid, + childGuid, + ParentLocation: 1u, + PlacementId: 0u, + ParentInstanceSequence: 1, + ChildPositionSequence: 2); + // The parent is alive at admission AND stays alive all the way + // through the drain - contrast with + // ParentContinuationRevalidatesLiveParentAtExecutionAndReDefersOnMismatch, + // which deletes the parent between admission and execution. + Assert.True(lifetime.TryApplyParent(parentUpdate, null, out _)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.Parent, + ], + receipt.Trace.Select(static a => a.Kind)); + // ApplyAcceptedParent is position-timestamp-only: the shared + // POSITION_TS channel advances but no pose/ParentGuid field moves + // (the actual attach commit is TryCommitParent's job, out of scope + // here). + Assert.Equal(2, canonical.Snapshot.Physics!.Value.Timestamps.Position); + Assert.Null(canonical.Snapshot.ParentGuid); + Assert.Equal([RuntimeEntityChange.Updated], observed); + // The successful path hands NOTHING to ParentAttachments' unresolved + // bucket - the direct contrast with the re-defer test's + // UnresolvedRelationCount == 1. + Assert.Equal(0, lifetime.Entities.ParentAttachments.UnresolvedRelationCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 arc-10 (enqueue-during-drain): a continuation enqueued from an + // observer mid-drain bumps the completed entry's Adoption.Revision in + // place; ConsumeExecuted must return Revised (not Released) so the SAME + // Execute() call's outer while(true) loop re-fetches via Complete and + // drains only the newly-appended tail. The already-applied prefix must + // never replay. + [Fact] + public void EnqueueDuringDrainExercisesConsumeExecutedRevisedArmAndDrainsTailOnlyOnce() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 44UL); + const uint guid = 0x7002F600u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x08000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + + var observed = new List(); + bool enqueuedFromObserver = false; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + observed.Add(delta.Change); + if (enqueuedFromObserver) + return; + enqueuedFromObserver = true; + // Reentrant enqueue, mid-drain, from inside the ObjDesc + // continuation's own publish: the residence is still in + // RuntimeInitialCreateResidenceState's _completed dictionary + // (not yet ConsumeExecuted'd), so this routes through + // CanEnqueue's completed-entry branch and bumps Revision in + // place. + var vector = new VectorUpdate.Parsed( + guid, new Vector3(7f, 8f, 9f), Vector3.Zero, + InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + })); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // Each action appears exactly once - the Revised loop-around must + // not replay the already-applied ObjDesc prefix. + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.ObjDesc, + RuntimeInitialCreateExecutedActionKind.Vector, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + Assert.Equal(0x08000002u, canonical.Snapshot.BasePaletteId); + Assert.Equal(new Vector3(7f, 8f, 9f), canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // --------------------------------------------------------------- + // C. Envelope atomicity + // --------------------------------------------------------------- + + [Fact] + public void SameIncarnationEnvelopePublishesNothingUntilEveryStageCommitsThenPublishesInStageOrder() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 3UL); + const uint guid = 0x70022000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, positionSequence: 2) with + { + Name = "same-incarnation", + }; + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { ObjDesc = 2, State = 2, Vector = 2 }, + }, + }; + RuntimeEntityRegistrationResult same = lifetime + .RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, same.Inbound.Disposition); + + var observed = new List(); + int countAtFirstObservation = -1; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + observed.Add(delta.Change); + countAtFirstObservation = observed.Count; + })); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // No event fired until the whole envelope committed: the very first + // observation must already carry every publish, not a partial subset + // trickling in one at a time. + Assert.True(observed.Count >= 1); + Assert.Equal(observed.Count, countAtFirstObservation); + + RuntimeInitialCreateExecutedActionKind[] envelopeStages = receipt.Trace + .Where(static a => a.Sequence == 1UL) + .Select(static a => a.Kind) + .ToArray(); + // A same-Create with neither a parent nor a position decomposes into + // a Pickup branch (retail priority: Parent > Position > Pickup - see + // InboundPhysicsStateController.BuildSameGenerationEvents), plus the + // AP-119 PreTailDescriptionAdaptation stage this envelope always + // carries when SameGenerationEvents is present. + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.PreTailDescriptionAdaptation, + RuntimeInitialCreateExecutedActionKind.ObjDesc, + RuntimeInitialCreateExecutedActionKind.Pickup, + RuntimeInitialCreateExecutedActionKind.State, + RuntimeInitialCreateExecutedActionKind.Vector, + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup, + ], + envelopeStages); + // This entity never claimed a cell (PickedUp residence, then the + // envelope's own Pickup stage nulls the position again) - Gap 4(c), + // the no-cell-claimed destruction-mark disposition. + RuntimeInitialCreateExecutedAction cleanup = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup); + Assert.Equal( + RuntimeResidentCellCleanupDisposition.CelllessNoWeenieMarkUnreachable, + cleanup.ResidentCellCleanupDisposition); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void ResidentCellCleanupUnmarksWhenCellClaimedAndAlreadyResident() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 18UL); + const uint guid = 0x7002A000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + Assert.Equal(Cell, canonical.FullCellId); + + // A same-Create carrying its own position: PositionSource == + // SameIncarnationCreate makes effectiveContact always true for the + // non-local classify branch, and the entity is already resident + // (FullCellId == Cell, unchanged since neither Position's own + // refreshPosition:false merge nor WeenieDescription's touch it), so + // this classifies to Interpolate - no new placement, no yield. + WorldSession.EntitySpawn sameCreate = Spawn(guid, 1, positionSequence: 2, positionX: 15f); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { ObjDesc = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 10f); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction cleanup = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup); + Assert.Equal( + RuntimeResidentCellCleanupDisposition.ResidentUnmarked, + cleanup.ResidentCellCleanupDisposition); + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void ResidentCellCleanupFailsClosedWhenClaimedCelllessOutcomeIsNotUnderLostCellOwnership() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 19UL); + const uint guid = 0x7002A100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); + Assert.Equal(0u, canonical.FullCellId); + + // The same-Create's own position exists on the wire (claims a + // cell), but with UsePositionFromServer=false this LocalPlayer + // classify branch resolves to NoPositionOperation - no SetPosition + // ever begins, so nothing tracks a lost-cell/deferred operation + // for this entity either. The entity was never resident (PickedUp + // initial, no placement ever ran) - claimed + celless + NOT under + // lost-cell ownership is the exact invariant violation this test + // requires. Round 3 B1: this is now a typed abandonment (Abandon + // retires the residence and discards progress), never a throw + // escaping Execute - the ledger must still fully converge. + WorldSession.EntitySpawn sameCreate = Spawn(guid, 1, positionSequence: 2, positionX: 15f); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { ObjDesc = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: true); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 0f); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out RuntimeInitialCreateExecutionReceipt receipt); + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.False(lifetime.TryGetInitialCreateResidence(canonical, out _)); + + // A retry with the now-retired token is a distinct, safe no-op - + // proving Abandon actually retired the residence rather than + // leaving it sitting fully current for a replay. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out RuntimeInitialCreateExecutionReceipt retryReceipt)); + Assert.Equal(default, retryReceipt); + } + + [Fact] + public void EnvelopePositionStageRequiringSetPositionYieldsResumesAndPublishesOnceAfterCompletion() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 20UL); + const uint guid = 0x7002B000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); + Assert.Equal(0u, canonical.FullCellId); + AttachDormantBody(lifetime, canonical); + + // Cellless (CommittedCellId == canonical.FullCellId == 0) forces + // SetPosition regardless of contact/distance for the non-local + // classify branch - this same-Create's OWN Position stage will + // require a real placement and yield mid-envelope. + WorldSession.EntitySpawn sameCreate = Spawn(guid, 1, positionSequence: 2, positionX: 15f); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { ObjDesc = 2, State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt pending); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Equal(default, pending); + // ObjDesc already committed (buffered) before Position began its + // own placement lifecycle - prove NOTHING has published yet, even + // though a stage before the yield point already ran. + Assert.Empty(observed); + + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPosition, route.Disposition); + CompletePendingContinuationPlacement(lifetime, key, route); + Assert.Empty(observed); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // Every buffered per-stage event from this ONE envelope publishes + // exactly once, only now that the envelope fully committed. + Assert.Equal( + [ + RuntimeEntityChange.Updated, + RuntimeEntityChange.Updated, + RuntimeEntityChange.Updated, + RuntimeEntityChange.Updated, + RuntimeEntityChange.Updated, + ], + observed); + RuntimeInitialCreateExecutedActionKind[] envelopeStages = receipt.Trace + .Where(static a => a.Sequence == 1UL) + .Select(static a => a.Kind) + .ToArray(); + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.PreTailDescriptionAdaptation, + RuntimeInitialCreateExecutedActionKind.ObjDesc, + RuntimeInitialCreateExecutedActionKind.Position, + RuntimeInitialCreateExecutedActionKind.State, + RuntimeInitialCreateExecutedActionKind.Vector, + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup, + ], + envelopeStages); + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPosition, + positionAction.PositionDisposition); + // The continuation's own SetPosition committed a real cell - + // ResidentCellCleanup now sees a claimed, resident entity. + Assert.Equal(Cell, canonical.FullCellId); + RuntimeInitialCreateExecutedAction cleanup = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup); + Assert.Equal( + RuntimeResidentCellCleanupDisposition.ResidentUnmarked, + cleanup.ResidentCellCleanupDisposition); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + } + + // Gap 2 (failure injection between every same-Create envelope stage): + // the Position stage is the ONLY boundary inside a SameIncarnationCreate + // envelope that this suite can interrupt via a REAL yield. Every other + // stage kind - PreTailDescriptionAdaptation, ObjDesc, CreateParent/ + // Parent/Pickup, Movement, State, Vector, WeenieDescription, + // ResidentCellCleanup - is a pure, synchronous, in-memory + // RuntimeEntityRecord/InboundPhysicsStateController mutation with NO + // async placement lifecycle and NO event dispatched until the whole + // envelope's buffered publish flush at the very end (see ApplyEnvelope): + // there is no callback, no I/O, and no point where control ever returns + // to a caller (or where an event observer could reenter) mid-stage. + // Injecting a synthetic "failure" between two purely synchronous stages + // would require adding a diagnostic seam to production code purely to + // make a test possible, which this task's own instructions forbid. The + // Position stage is structurally different: it is the one action kind + // whose disposition can require a real RuntimeSetPositionState + // placement round-trip (Begin/Watch/yield/host-prepare-submit- + // acknowledge/resume) - the SAME mechanism a standalone (non-envelope) + // Position continuation uses. The two tests below exhaust that + // reachable boundary: a successful resume (see + // EnvelopePositionStageRequiringSetPositionYieldsResumesAndPublishesOnceAfterCompletion + // above) and an abandonment at that exact boundary (immediately below). + + [Fact] + public void EnvelopeAbandonedDuringPositionStageYieldPublishesNothingAndConvergesEveryLedger() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 21UL); + const uint guid = 0x7002B100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); + AttachDormantBody(lifetime, canonical); + + // Same recipe as the successful-resume test: cellless forces + // SetPosition, guaranteeing a mid-envelope yield after ObjDesc (and + // Position's own snapshot-merge commit) already ran. + WorldSession.EntitySpawn sameCreate = Spawn(guid, 1, positionSequence: 2, positionX: 15f); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { ObjDesc = 2, State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Empty(observed); + + // Abandon between Execute retries at the yield: delete the entity + // rather than ever completing the continuation's placement. + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, canonical.Incarnation), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + + // The delete's own Deleted is the only observed event - NONE of the + // envelope's buffered stages (ObjDesc, and Position's own snapshot + // merge, both of which had ALREADY committed before the yield) ever + // publish. TryAcceptDelete's own unconditional Physics.SetPosition.Forget + // already cancels the still-pending continuation placement here + // (same key, any token); DiscardProgress ALSO forgets it + // defensively (see its remarks) for callers that retire a + // residence without going through the full delete path. + Assert.Equal([RuntimeEntityChange.Deleted], observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + Assert.Equal( + 0, + lifetime.Physics.SetPosition.CaptureOwnership().AcknowledgedPlacementCompletionCount); + + // A stale Execute() with the original token, after the entity is + // gone, must not resurrect anything either. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + } + + [Fact] + public void EnvelopeStageRetryDoesNotDuplicateAlreadyCommittedStages() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 4UL); + const uint guid = 0x70022100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, positionSequence: 2); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + // The first Execute call fully drains the envelope in one synchronous + // pass (no yield point exists in this envelope). A SECOND call with + // the same (now-stale, released) token must not resurrect or + // reapply anything. + RuntimeInitialCreateExecutionReceipt first = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + ulong vectorAuthorityAfterFirst = canonical.VectorAuthorityVersion; + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute(canonical, lease.Token, NoContact, out _)); + Assert.Equal(vectorAuthorityAfterFirst, canonical.VectorAuthorityVersion); + Assert.NotEmpty(first.Trace); + } + + // --------------------------------------------------------------- + // D. Position routes + // --------------------------------------------------------------- + + [Fact] + public void LocalOrdinaryPositionInterpolatesWithoutWorldPlacement() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 5UL); + const uint guid = 0x70023000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 15f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: true, PlayerDistance: 0f); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.Interpolate, + positionAction.PositionDisposition); + Assert.Equal(15f, canonical.Snapshot.Position!.Value.PositionX); + // Interpolate never runs a physics placement: the cell stays exactly + // what the initial SetPosition already committed. + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + // Round 4 R4-8: local ordinary route trace flags - CONSTRAIN-BEFORE + // (retail-notes.md function 3 line ~93041: ConstrainTo runs + // unconditionally BEFORE InterpolateTo on this route), unparent + // always runs on a received Position, and no teleport hook. + Assert.Equal(RuntimePositionConstrainPhase.BeforePositionOperation, positionAction.ConstrainPhase); + Assert.True(positionAction.UnparentBeforeRouting); + Assert.Equal(RuntimeTeleportHookPhase.None, positionAction.HookPhase); + } + + [Fact] + public void LocalTeleportContinuationDrivesItsOwnAuthoredPlacementLifecycle() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 6UL); + const uint guid = 0x70023100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, true, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt pending); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Equal(default, pending); + // The snapshot's own position field is refreshed even before the + // physics placement resolves; the derived cell residency is not. + Assert.Equal(40f, canonical.Snapshot.Position!.Value.PositionX); + Assert.Equal(Cell, canonical.FullCellId); + + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); + CompletePendingContinuationPlacement(lifetime, key, route); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + positionAction.PositionDisposition); + // Round 4 R4-8: local teleport route trace flags - ZeroVelocity, + // CONSTRAIN-AFTER (retail: TeleportPlayer runs first, ConstrainTo + // second), the AfterPositionOperation teleport hook, and unparent + // (a received Position always unsets parent). + Assert.True(positionAction.ZeroVelocity); + Assert.Equal(RuntimePositionConstrainPhase.AfterPositionOperation, positionAction.ConstrainPhase); + Assert.Equal(RuntimeTeleportHookPhase.AfterPositionOperation, positionAction.HookPhase); + Assert.True(positionAction.UnparentBeforeRouting); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + } + + [Fact] + public void RemoteNearContactPositionInterpolatesAfterInitialResidency() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 7UL); + const uint guid = 0x70023200u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f, isGrounded: true); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 10f); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.Interpolate, + positionAction.PositionDisposition); + Assert.Equal(25f, canonical.Snapshot.Position!.Value.PositionX); + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + // Round 4 R4-8: remote near route trace flag - constrain-after + // (remote route: MoveOrTeleport runs first, ConstrainTo second). + Assert.Equal(RuntimePositionConstrainPhase.AfterPositionOperation, positionAction.ConstrainPhase); + } + + // Round 4 R4-7: wire IsGrounded=true disagrees with the body's OWN + // contact bit (forced false) - the local ordinary interpolate gate must + // follow the WIRE fact, not the body, per Round 3 A3. + [Fact] + public void LocalOrdinaryPositionRouteFollowsWireGroundedTrueWhenBodyContactIsFalse() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 97UL); + const uint guid = 0x70039000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + // Force the body's OWN contact bit to DISAGREE with the wire fact + // below - the route must ignore this. + ForceContact(canonical, inContact: false); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 15f, + isGrounded: true); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: true, PlayerDistance: 0f); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.Interpolate, + positionAction.PositionDisposition); + } + + // Round 4 R4-7: wire IsGrounded=false disagrees with the body's OWN + // contact bit (forced true) - the remote effective-contact gate must + // follow the WIRE fact, not the body. + [Fact] + public void RemotePositionRouteFollowsWireGroundedFalseWhenBodyContactIsTrue() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 98UL); + const uint guid = 0x70039100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + // Force the body's OWN contact bit to DISAGREE with the wire fact + // below - the route must ignore this. + ForceContact(canonical, inContact: true); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f, + isGrounded: false); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 10f); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.NoPositionOperation, + positionAction.PositionDisposition); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + } + + [Fact] + public void RemoteFarPositionStopsInterpolatingAndRunsSetPositionSimple() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 8UL); + const uint guid = 0x70023300u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 200f); + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); + Assert.True(route.StopInterpolating); + CompletePendingContinuationPlacement(lifetime, key, route); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + // Round 4 R4-8: remote far route trace flags - StopInterpolating + // and constrain-after. + Assert.True(positionAction.StopInterpolating); + Assert.Equal(RuntimePositionConstrainPhase.AfterPositionOperation, positionAction.ConstrainPhase); + } + + [Fact] + public void ParentedInitialCreateNeverRunsAWorldPlacementThroughTheExecutor() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 9UL); + const uint parentGuid = 0x70024100u; + const uint guid = 0x70024000u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); + Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, lease.Route.Disposition); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal(0u, receipt.FullCellId); + Assert.Equal(RuntimeTeleportHookPhase.None, receipt.TeleportHookPhase); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void ForcePositionContinuationRecordsSetPositionSimpleWithPreservedHeadingAndNoParentClear() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 45UL); + const uint guid = 0x7002F700u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + // Round 4 R4-12: give the entity a parent attachment BEFORE the + // ForcePosition update arrives, to structurally pin the merge + // body's deliberate combined shape - retail's FORCE_POSITION Gate A + // returns before CPhysicsObj::unset_parent ever runs, so the + // parent attachment must survive alongside the newly-applied + // Position after this drain. + const uint parentGuid = 0x7002F701u; + const uint parentLocation = 3u; + Assert.True(lifetime.Entities.TryCommitParent( + guid, parentGuid, parentLocation, placementId: 0u, positionSequence: 1, + out WorldSession.EntitySpawn parented)); + lifetime.Entities.RefreshSnapshot(canonical, parented); + + // isLocalPlayer + a fresh FORCE_POSITION_TS (1, newer than the + // seeded 0) + an EQUAL teleport sequence (0 == gate's current 0) + // blips immediately per PhysicsTimestampGate.TryAcceptPositionEvent. + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 1, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); + CompletePendingContinuationPlacement(lifetime, key, route); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + positionAction.PositionDisposition); + Assert.True(positionAction.PreserveHeading); + Assert.True(positionAction.SendPositionImmediately); + Assert.False(positionAction.StopInterpolating); + Assert.False(positionAction.ZeroVelocity); + Assert.Equal(RuntimePositionConstrainPhase.None, positionAction.ConstrainPhase); + // The FORCE_POSITION branch precedes unset_parent in retail's + // MoveOrTeleport - no parent clearing here. + Assert.False(positionAction.UnparentBeforeRouting); + // Round 4 R4-12: structurally pin the deliberate combined shape - + // Position AND the pre-existing Parent attachment are BOTH + // non-null after this merge. + Assert.NotNull(canonical.Snapshot.Position); + Assert.Equal(40f, canonical.Snapshot.Position!.Value.PositionX); + Assert.Equal(parentGuid, canonical.Snapshot.ParentGuid); + Assert.Equal(parentLocation, canonical.Snapshot.ParentLocation); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + } + + [Fact] + public void MissileFlaggedEntityPositionContinuationClassifiesToProjectileAuthoritativeOperationKind() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 46UL); + const uint guid = 0x7002F800u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, missile: true), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal( + RuntimeSetPositionOperationKind.ProjectileAuthoritative, + lease.Route.OperationKind); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + // Already resident + far distance forces SetPositionSimple (the + // same "remote-shaped" branch a Projectile entity kind shares with + // Remote - the classifier has no Projectile-specific branch, only a + // Projectile-specific OperationKind mapping). + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 200f); + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); + Assert.Equal(RuntimeSetPositionOperationKind.ProjectileAuthoritative, route.OperationKind); + Assert.True(route.StopInterpolating); + CompletePendingContinuationPlacement(lifetime, key, route); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + Assert.NotEmpty(receipt.Trace); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + // Round 4 R4-8: projectile route trace flags - same StopInterpolating + // + constrain-after shape as the remote-far branch (the classifier + // has no Projectile-specific branch, only an OperationKind mapping). + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.True(positionAction.StopInterpolating); + Assert.Equal(RuntimePositionConstrainPhase.AfterPositionOperation, positionAction.ConstrainPhase); + } + + // Contrasts with ParentedInitialCreateNeverRunsAWorldPlacementThroughTheExecutor: + // the PickedUp residence kind (no parent AND no position) is the OTHER + // branch that classifies to AwaitFreshPosition at Begin/Create time (see + // RuntimeInitialCreateResidenceState.Begin's residence-kind switch). + // NOTE (mandated-case coverage gap): a standalone Position CONTINUATION + // (as opposed to this initial-lease route) can never itself classify to + // AwaitFreshPosition - RuntimeAuthoritativePositionRouteClassifier. + // ClassifyAcceptedPosition (the only classifier ApplyPositionAction ever + // calls) has no branch that returns AwaitFreshPosition; that disposition + // is produced exclusively by ClassifyCreate (Parented/PickedUp) and + // ClassifyLeaveWorld (neither of which ApplyPositionAction calls). A + // parented/picked entity's raw Position wire events are retained as + // Position continuations exactly like any other entity's and are + // classified with the SAME Remote/LocalPlayer logic once drained - the + // "picked" or "parented" fact plays no role in that classification. This + // is a structural property of the current classifier, not a gap in this + // test suite; the reachable form of "AwaitFreshPosition for a + // parented/picked entity" is the INITIAL LEASE route exercised here and + // by ParentedInitialCreateNeverRunsAWorldPlacementThroughTheExecutor. + [Fact] + public void PickedUpInitialCreateNeverRunsAWorldPlacementThroughTheExecutor() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 47UL); + const uint guid = 0x7002F900u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); + Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, lease.Route.Disposition); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal(0u, receipt.FullCellId); + Assert.Equal(RuntimeTeleportHookPhase.None, receipt.TeleportHookPhase); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 B10 (execution-time-rejected retained Position): admission + // ACCEPTED (disposition Apply - POSITION_TS genuinely advanced), but + // execution-time classification REJECTS on a nonfinite derived distance. + // The gate-consumed timestamps must still land in the snapshot; no pose + // ever applies. + [Fact] + public void ExecutionTimeRejectedPositionStampsRetainedTimestampsWithoutPoseApplication() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 48UL); + const uint guid = 0x7002FA00u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + Assert.Equal(Cell, canonical.FullCellId); + float originalPositionX = canonical.Snapshot.Position!.Value.PositionX; + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: float.NaN); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.RejectedData, + positionAction.PositionDisposition); + // The retained Position/Teleport/ForcePosition channels the gate + // actually moved land in the snapshot ... + Assert.Equal(2, canonical.Snapshot.Physics!.Value.Timestamps.Position); + Assert.Equal(0, canonical.Snapshot.Physics!.Value.Timestamps.Teleport); + Assert.Equal(0, canonical.Snapshot.Physics!.Value.Timestamps.ForcePosition); + // ... but no pose ever applies. + Assert.Equal(originalPositionX, canonical.Snapshot.Position!.Value.PositionX); + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // --------------------------------------------------------------- + // E. Missing-parent raw deferred-create replay + // --------------------------------------------------------------- + + [Fact] + public void MissingParentReplayConsumesExactAdmissionIdAndRegistersChildThroughCanonicalRoute() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 10UL); + const uint parentGuid = 0x70025100u; + const uint childGuid = 0x70025000u; + RuntimeEntityRegistrationResult deferred = lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false); + Assert.True(deferred.DeferredForParent); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, + out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + RuntimeInitialCreateExecutedAction replay = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.DeferredChildReplay); + Assert.Equal(RuntimeDeferredChildReplayOutcome.Registered, replay.DeferredChildOutcome); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord child)); + Assert.True(lifetime.TryGetInitialCreateResidence(child, out _)); + } + + [Fact] + public void StaleAdmissionIdCannotConsumeAReplacementQueuedAfterThePeek() + { + var parents = new ParentAttachmentState(); + const uint parentGuid = 0x70025200u; + WorldSession.EntitySpawn spawn = Spawn( + 0x70025300u, 1, includePosition: false, parentGuid: parentGuid); + parents.EnqueueDeferredCreate(spawn, isLocalPlayer: false); + Assert.True(parents.TryPeekDeferredCreate(parentGuid, out DeferredParentCreate stale)); + + parents.Clear(); + parents.EnqueueDeferredCreate(spawn, isLocalPlayer: false); + Assert.True(parents.TryPeekDeferredCreate(parentGuid, out DeferredParentCreate replacement)); + Assert.NotEqual(stale.AdmissionId, replacement.AdmissionId); + + Assert.False(parents.ConsumeDeferredCreate(parentGuid, stale)); + Assert.Equal(1, parents.DeferredCreateCount); + Assert.True(parents.ConsumeDeferredCreate(parentGuid, replacement)); + Assert.Equal(0, parents.DeferredCreateCount); + } + + // Round 3 B7: multiple children queued behind the SAME missing parent + // all replay atomically through the executor's whole-bucket detach + // (ParentAttachmentState.DetachDeferredCreates), in FIFO admission + // order, exercised through the canonical RunInitialTail path rather than + // against ParentAttachmentState directly. + [Fact] + public void MultipleChildrenQueuedBehindOneMissingParentReplayInFifoOrderThroughTheExecutor() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 32UL); + const uint parentGuid = 0x7002E100u; + const uint firstChildGuid = 0x7002E000u; + const uint secondChildGuid = 0x7002E001u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(2, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(2, receipt.ReplayedDeferredChildCount); + RuntimeInitialCreateExecutedAction[] replays = receipt.Trace + .Where(static a => a.Kind is RuntimeInitialCreateExecutedActionKind.DeferredChildReplay) + .ToArray(); + Assert.Equal(2, replays.Length); + Assert.All( + replays, + static a => Assert.Equal(RuntimeDeferredChildReplayOutcome.Registered, a.DeferredChildOutcome)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.TryGetActive(firstChildGuid, out _)); + Assert.True(lifetime.Entities.TryGetActive(secondChildGuid, out _)); + } + + // Round 4 R4-1: one child's registration THROWING must not strand the + // remaining siblings or escape Execute as an exception. Three children + // queued behind one missing parent; the SECOND child's own registration + // callback is made to throw (via a reflection-swapped + // _registerDeferredChild delegate - the standard fault-injection + // technique this file already uses for private-state pokes, e.g. + // SetCompletedAdoptionRevision). The third child must still register. + [Fact] + public void DeferredChildReplayContainsOneChildsThrowingRegistrationAndContinuesWithSiblings() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 92UL); + const uint parentGuid = 0x70035000u; + const uint firstChildGuid = 0x70035001u; + const uint secondChildGuid = 0x70035002u; + const uint thirdChildGuid = 0x70035003u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(thirdChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(3, lifetime.CaptureOwnership().DeferredParentCreateCount); + + WrapDeferredChildRegistration(lifetime, (original, spawn, isLocalPlayer) => + { + if (spawn.Guid == secondChildGuid) + { + throw new InvalidOperationException( + "Injected R4-1 containment test failure."); + } + return original(spawn, isLocalPlayer); + }); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + // No exception escapes Execute - it returns a typed status. + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(3, receipt.ReplayedDeferredChildCount); + RuntimeInitialCreateExecutedAction[] replays = receipt.Trace + .Where(static a => a.Kind is RuntimeInitialCreateExecutedActionKind.DeferredChildReplay) + .ToArray(); + Assert.Equal(3, replays.Length); + Assert.Equal(RuntimeDeferredChildReplayOutcome.Registered, replays[0].DeferredChildOutcome); + Assert.Equal(RuntimeDeferredChildReplayOutcome.Rejected, replays[1].DeferredChildOutcome); + Assert.Equal(RuntimeDeferredChildReplayOutcome.Registered, replays[2].DeferredChildOutcome); + Assert.True(lifetime.Entities.TryGetActive(firstChildGuid, out _)); + Assert.False(lifetime.Entities.TryGetActive(secondChildGuid, out _)); + Assert.True(lifetime.Entities.TryGetActive(thirdChildGuid, out _)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + // Round 5 R5-3: the contained exception is recorded on the + // observable failure surface, not silently swallowed. + Assert.Equal(1, lifetime.CaptureOwnership().ReplayFailureCount); + Assert.True(lifetime.CaptureOwnership().HasLastReplayFailure); + } + + // Round 4 R4-1: a mid-loop abandonment (the parent entity is no longer + // current) must RESTORE the unprocessed remainder rather than + // permanently destroy it. Two children queued behind one missing + // parent; the FIRST child's registration callback reentrantly deletes + // the PARENT (simulating a synchronous observer reaction fired from + // within registration). The second child's raw Create must be back in + // the deferred bucket afterward (observable via ContainsDeferredCreate), + // and every ledger must still converge. + [Fact] + public void DeferredChildReplayRestoresTheUnprocessedRemainderWhenTheParentIsDeletedReentrantlyMidReplay() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 93UL); + const uint parentGuid = 0x70036000u; + const uint firstChildGuid = 0x70036001u; + const uint secondChildGuid = 0x70036002u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(2, lifetime.CaptureOwnership().DeferredParentCreateCount); + + WrapDeferredChildRegistration(lifetime, (original, spawn, isLocalPlayer) => + { + RuntimeEntityRegistrationResult result = original(spawn, isLocalPlayer); + if (spawn.Guid == firstChildGuid) + { + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + } + return result; + }); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + + // Child 1 legitimately registered before the reentrant delete + // fired; child 2's raw Create is restored to the deferred bucket + // rather than lost. + Assert.True(lifetime.Entities.TryGetActive(firstChildGuid, out RuntimeEntityRecord firstChild)); + Assert.True(lifetime.Entities.ParentAttachments.ContainsDeferredCreate(secondChildGuid, 1)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + // Only the PARENT's own residence/progress was touched by this + // abandoned Execute call (matching the existing + // DeleteDuringDeferredChildReplayAbandonsExecutionWithoutResurrection + // precedent) - child 1 is a distinct entity the deferred replay + // legitimately registered through the canonical route before the + // reentrant delete happened; its own (never executed) residence + // lease correctly remains open, accounting for the sole surviving + // lease count. + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.True(lifetime.TryGetInitialCreateResidence(firstChild, out _)); + } + + /// + /// Round 4 R4-1 test-only fault injection: swaps the executor's private + /// _registerDeferredChild delegate for one that wraps the + /// original, matching this file's existing reflection-based + /// private-state pokes (e.g. SetCompletedAdoptionRevision below). + /// + private static void WrapDeferredChildRegistration( + RuntimeEntityObjectLifetime lifetime, + Func< + Func, + WorldSession.EntitySpawn, + bool, + RuntimeEntityRegistrationResult> wrapper) + { + Type executorType = typeof(RuntimeInitialCreateContinuationExecutor); + System.Reflection.FieldInfo field = executorType.GetField( + "_registerDeferredChild", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + var original = (Func) + field.GetValue(lifetime.InitialCreateExecution)!; + Func wrapped = + (spawn, isLocalPlayer) => wrapper(original, spawn, isLocalPlayer); + field.SetValue(lifetime.InitialCreateExecution, wrapped); + } + + /// + /// Round 5 R5-1 test helper: both RuntimeEntityObjectLifetime. + /// TryApplyParent (line ~908) and RegisterEntityCore's own + /// missing-parent gate divert an UNADDRESSABLE-at-admission parent + /// relation to a DIFFERENT mechanism entirely (the legacy unresolved- + /// wait queue, or a raw deferred Create) - neither ever reaches the + /// residence continuation FIFO this round's executor dispatch code + /// operates on. The scenarios this round's tests exercise (drain-time/ + /// replay-time staleness) all require the parent to be ADDRESSABLE at + /// the moment of admission and only become stale/unaddressable + /// afterward - exactly the shape the original Round 3/4 test used. + /// This helper registers a real parent at + /// , admits the standalone + /// Parent continuation naming it, then removes the parent from the + /// ACTIVE directory directly (RuntimeEntityDirectory.RemoveActive) + /// rather than through the full DeleteObject-acceptance ceremony - a + /// real delete retains a permanent per-(guid,generation) teardown + /// tombstone (RuntimeEntityDirectory.RetainTeardown), which would + /// collide if a later test step re-registers the SAME guid at the SAME + /// incarnation (an invalid combination this scenario has no reason to + /// exercise - retail incarnations only ever increase). RemoveActive + /// makes the parent unaddressable for TryGetActive purposes + /// without that permanent marker, while the gate stays intact so a + /// LATER same-incarnation re-registration takes the codebase's own + /// existing "recovered CreateObject" ExistingGeneration path. + /// + private static void AdmitThenOrphanParentRelation( + RuntimeEntityObjectLifetime lifetime, + uint parentGuid, + uint childGuid, + ushort namedParentIncarnation, + uint parentLocation = 1u) + { + // includePosition:false - besides needing no physics engine, this + // also matters for the "same incarnation returns" scenario: a + // position-bearing retained snapshot would carry over through + // MergeUntimestampedCreate's Position=retained.Position copy when + // the SAME (guid, incarnation) is later re-registered on the + // "recovered CreateObject" ExistingGeneration path, reclassifying + // the recovered residence as TopLevel (SetPosition) instead of + // PickedUp (AwaitFreshPosition) and stalling RunToCompletion on an + // unrequested placement ack. + RuntimeEntityRecord parent = lifetime.RegisterEntity( + Spawn(parentGuid, namedParentIncarnation, includePosition: false)).Canonical!; + var parentUpdate = new ParentEvent.Parsed( + parentGuid, + childGuid, + ParentLocation: parentLocation, + PlacementId: 0u, + ParentInstanceSequence: namedParentIncarnation, + ChildPositionSequence: 2); + Assert.True(lifetime.TryApplyParent(parentUpdate, null, out _)); + Assert.True(lifetime.Entities.RemoveActive(parent)); + } + + // Round 3 B9 revalidated a standalone Parent continuation's parent + // incarnation at EXECUTION time, not just admission time - admission + // succeeds while the parent is still active; the parent is then + // deleted before the drain reaches this continuation. Round 4 R4-5 + // pinned the mismatch outcome as a DISCARD; Round 5 R5-1 OVERTURNS + // that with hard retail evidence (QueueBlobForObject, pseudo-C 92326; + // GUID-keyed CObjectMaint bucket, 271082-271088) - retail QUEUES a + // relation whose parent is unaddressable under the PARENT's guid and + // replays it when that guid is created; it never discards on this + // path. This test now covers R5-1's mandated "parent-returns -> queued + // relation applies exactly once" scenario end-to-end. + [Fact] + public void ParentContinuationRevalidatesLiveParentAtExecutionAndQueuesOnMismatchThenAppliesWhenTheParentArrives() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 33UL); + const uint parentGuid = 0x7002E200u; + const uint childGuid = 0x7002E300u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var parentUpdate = new ParentEvent.Parsed( + parentGuid, + childGuid, + ParentLocation: 1u, + PlacementId: 0u, + ParentInstanceSequence: 1, + ChildPositionSequence: 2); + // Admission succeeds - the parent is still active at this moment. + Assert.True(lifetime.TryApplyParent(parentUpdate, null, out _)); + + // The parent is deleted BEFORE the drain ever reaches this + // continuation - admission-time validation cannot see this. + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // Queued (retail-faithful), not discarded: the residence still + // converges cleanly and the child's own canonical record survives. + RuntimeInitialCreateExecutedAction parentAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Parent); + Assert.Equal( + RuntimeParentRelationOutcome.DeferredAwaitingParent, + parentAction.ParentRelationOutcome); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out _)); + Assert.True(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + Assert.Null(canonical.Snapshot.ParentGuid); + } + + // Round 5 R5-1 mandated: "parent-returns -> queued relation applies + // exactly once" (trace + attach), end-to-end. Uses + // AdmitThenOrphanParentRelation (RemoveActive, not a full + // DeleteObject-acceptance) specifically so the SAME (guid, incarnation) + // can validly reappear afterward through the codebase's own existing + // "recovered CreateObject" ExistingGeneration path, without an + // artificial teardown-tombstone collision the scenario has no reason + // to exercise. + [Fact] + public void DeferredAcceptedParentRelationAppliesExactlyOnceWhenTheSameParentIncarnationReturns() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 113UL); + const uint parentGuid = 0x70049000u; + const uint childGuid = 0x70049001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AdmitThenOrphanParentRelation(lifetime, parentGuid, childGuid, namedParentIncarnation: 1); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + RuntimeInitialCreateExecutedAction parentAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Parent); + Assert.Equal(RuntimeParentRelationOutcome.DeferredAwaitingParent, parentAction.ParentRelationOutcome); + Assert.True(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + + // The SAME parent incarnation (1) reappears - the queued relation + // replays and applies exactly once. + RuntimeEntityRecord recreatedParent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + recreatedParent, out RuntimeInitialCreateResidenceLease parentLease)); + RuntimeInitialCreateExecutionReceipt parentReceipt = RunToCompletion( + lifetime, recreatedParent, parentLease.Token, NoContact); + + RuntimeInitialCreateExecutedAction replayAction = Assert.Single( + parentReceipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ParentRelationReplay); + Assert.Equal(RuntimeParentRelationOutcome.Applied, replayAction.ParentRelationOutcome); + // Applied commits the SAME position-timestamp-only merge a live + // Parent continuation commits (already stamped at the relation's + // original drain, before it was queued) - it does not itself set + // ParentGuid/ParentLocation, matching + // StandaloneParentContinuationAppliesPositionTimestampOnlyMergeWithNoReDefer's + // established precedent that the actual attach commit is + // TryCommitParent's (App-layer EquippedChildRenderController's) job. + Assert.Equal(2, canonical.Snapshot.Physics!.Value.Timestamps.Position); + Assert.Null(canonical.Snapshot.ParentGuid); + Assert.False(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 5 R5-1 mandated: the arriving parent's incarnation is NEWER + // than the one the relation named - discard per Resolve's own rule + // (the current live parent supersedes the packet). + [Fact] + public void DeferredAcceptedParentRelationDiscardsWhenTheArrivingParentIsANewerIncarnationThanTheRelationNamed() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 104UL); + const uint parentGuid = 0x7003F000u; + const uint childGuid = 0x7003F001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + // Admits the standalone Parent continuation while the parent IS + // addressable (required - see AdmitThenOrphanParentRelation's + // remarks), then deletes the parent so the child's OWN drain + // discovers it stale and queues the relation. + AdmitThenOrphanParentRelation(lifetime, parentGuid, childGuid, namedParentIncarnation: 1); + _ = RunToCompletion(lifetime, canonical, lease.Token, NoContact); + Assert.True(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + + // Parent arrives directly at incarnation 2 - NEWER than the + // relation's named incarnation 1. + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 2, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + RuntimeInitialCreateExecutedAction replayAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ParentRelationReplay); + Assert.Equal(RuntimeParentRelationOutcome.DiscardedStaleParent, replayAction.ParentRelationOutcome); + Assert.False(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + Assert.Null(canonical.Snapshot.ParentGuid); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + } + + // Round 5 R5-1 mandated: the arriving parent's incarnation is OLDER + // than the one the relation named - stays queued (wait), then applies + // on the NEXT matching incarnation. This scenario is not constructible + // through the normal admission ceremony: naming ParentInstanceSequence + // 5 in a Parent continuation requires the LIVE parent to already BE at + // instance 5 at admission time (TryApplyParent's own gate), which + // pins the parent's OWN PhysicsTimestampGate at 5 - a client can never + // subsequently see that SAME guid at an OLDER instance 3 afterward + // (retail incarnations are per-guid monotonic; the gate enforces it). + // This test instead enqueues the relation directly through + // ParentAttachmentState's own public API (the same technique the ABA + // test below uses) to isolate ApplyReplayedParentRelation's OWN + // incarnation-compare logic from that inapplicable precondition. + [Fact] + public void DeferredAcceptedParentRelationStaysQueuedWhenTheArrivingParentIsOlderThanTheRelationNamedAndAppliesOnTheNextMatchingIncarnation() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 105UL); + const uint parentGuid = 0x70040000u; + const uint childGuid = 0x70040001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + _ = RunToCompletion(lifetime, canonical, lease.Token, NoContact); + var parentUpdate = new ParentEvent.Parsed( + parentGuid, childGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 5, ChildPositionSequence: 2); + lifetime.Entities.ParentAttachments.EnqueueDeferredAcceptedRelation( + childGuid, canonical.Key!.Value, parentUpdate, null, default); + + // Parent arrives at incarnation 3 - OLDER than the relation's + // named incarnation 5. + RuntimeEntityRecord parentAtThree = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 3, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parentAtThree, out RuntimeInitialCreateResidenceLease leaseThree)); + RuntimeInitialCreateExecutionReceipt receiptThree = RunToCompletion( + lifetime, parentAtThree, leaseThree.Token, NoContact); + RuntimeInitialCreateExecutedAction replayThree = Assert.Single( + receiptThree.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ParentRelationReplay); + Assert.Equal(RuntimeParentRelationOutcome.DeferredAwaitingParent, replayThree.ParentRelationOutcome); + Assert.True(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + + // Parent recreated at incarnation 5 - now matches. + RuntimeEntityRecord parentAtFive = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 5, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parentAtFive, out RuntimeInitialCreateResidenceLease leaseFive)); + RuntimeInitialCreateExecutionReceipt receiptFive = RunToCompletion( + lifetime, parentAtFive, leaseFive.Token, NoContact); + RuntimeInitialCreateExecutedAction replayFive = Assert.Single( + receiptFive.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ParentRelationReplay); + Assert.Equal(RuntimeParentRelationOutcome.Applied, replayFive.ParentRelationOutcome); + // Applied never sets ParentGuid itself - see + // StandaloneParentContinuationAppliesPositionTimestampOnlyMergeWithNoReDefer's + // established precedent (the actual attach commit is + // TryCommitParent's job, out of scope for this residence drain). + Assert.Null(canonical.Snapshot.ParentGuid); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + } + + // Round 5 R5-1 mandated: the child is deleted while its relation is + // still queued (parent never showed up) - the entry is cancelled and + // every ledger converges. + [Fact] + public void DeferredAcceptedParentRelationIsCancelledWhenTheChildIsDeletedWhileQueued() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 106UL); + const uint parentGuid = 0x70041000u; + const uint childGuid = 0x70041001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AdmitThenOrphanParentRelation(lifetime, parentGuid, childGuid, namedParentIncarnation: 1); + _ = RunToCompletion(lifetime, canonical, lease.Token, NoContact); + Assert.Equal(1, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(childGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + + Assert.False(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + } + + // Round 5 R5-1 mandated: a full session reset while a relation is + // queued clears it and converges. + [Fact] + public void DeferredAcceptedParentRelationClearsOnSessionResetAndConverges() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 107UL); + const uint parentGuid = 0x70042000u; + const uint childGuid = 0x70042001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AdmitThenOrphanParentRelation(lifetime, parentGuid, childGuid, namedParentIncarnation: 1); + _ = RunToCompletion(lifetime, canonical, lease.Token, NoContact); + Assert.Equal(1, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + + IReadOnlyList retirements = lifetime.BeginSessionClear(); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + // Round 5 R5-1 mandated ABA coverage, tested directly against + // ParentAttachmentState (mirroring + // StaleAdmissionIdCannotConsumeAReplacementQueuedAfterThePeek's own + // direct-state style): a window token minted BEFORE a Clear() must not + // restore anything afterward, even though a NEW window for the same + // parent guid could otherwise reuse its Id space. + [Fact] + public void DeferredAcceptedParentRelationStaleWindowTokenCannotRestoreAfterClearAndRequeue() + { + var parents = new ParentAttachmentState(); + const uint parentGuid = 0x70043000u; + const uint childGuid = 0x70043001u; + var childKey = new RuntimeEntityKey(1u, 1); + var parentUpdate = new ParentEvent.Parsed( + parentGuid, childGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 2); + parents.EnqueueDeferredAcceptedRelation(childGuid, childKey, parentUpdate, null, default); + + ImmutableArray detached = + parents.DetachDeferredAcceptedRelations(parentGuid, out DeferredReplayWindowToken staleWindow); + DeferredAcceptedParentRelation stale = Assert.Single(detached); + + // Reentrant Clear() (session reset) while the window is still open. + parents.Clear(); + + // A restore against the now-stale token must be a silent no-op. + parents.RestoreDeferredAcceptedRelations(staleWindow, [stale]); + Assert.Equal(0, parents.DeferredAcceptedRelationCount); + Assert.False(parents.ContainsDeferredAcceptedRelation(childGuid, childKey)); + } + + // Round 5 R5-1 mandated: the envelope's CreateParent stage, end-to-end, + // for the unaddressable-parent flavor (addressability-only, no + // incarnation to compare). RegisterEntityCore's OWN missing-parent gate + // (checked before PreviewCreateDisposition, for ANY beginInitialResidence + // call) diverts a same-incarnation update naming an UNADDRESSABLE parent + // to a raw deferred Create entirely, bypassing the envelope/CreateParent + // stage - so, exactly like the standalone Parent continuation, this + // scenario requires the parent to be ADDRESSABLE at the moment of + // admission and only orphaned afterward. + [Fact] + public void EnvelopeCreateParentQueuesForUnaddressableParentAndAppliesWhenTheParentArrives() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 108UL); + const uint parentGuid = 0x70044000u; + const uint childGuid = 0x70044001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + // includePosition:false, matching AdmitThenOrphanParentRelation's own + // remarks - avoids both the physics-engine dependency and a + // position-bearing retained snapshot bleeding into the LATER + // same-incarnation "recovered CreateObject" merge below. + RuntimeEntityRecord orphanedParent = lifetime.RegisterEntity( + Spawn(parentGuid, 1, includePosition: false)).Canonical!; + + WorldSession.EntitySpawn sameCreate = Spawn( + childGuid, 1, includePosition: false, positionSequence: 2, parentGuid: parentGuid); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + // The parent is now orphaned - removed from the active directory + // AFTER the envelope's CreateParent stage was admitted while it was + // still addressable. RemoveActive (not a full DeleteObject-acceptance) + // for the SAME reason AdmitThenOrphanParentRelation uses it: a real + // delete retains a permanent per-(guid,generation) teardown tombstone + // that would collide when the SAME incarnation is re-registered below. + Assert.True(lifetime.Entities.RemoveActive(orphanedParent)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + RuntimeInitialCreateExecutedAction createParentAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.CreateParent); + Assert.Equal(RuntimeParentRelationOutcome.DeferredAwaitingParent, createParentAction.ParentRelationOutcome); + Assert.True(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + RuntimeInitialCreateExecutionReceipt parentReceipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + RuntimeInitialCreateExecutedAction replayAction = Assert.Single( + parentReceipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ParentRelationReplay); + Assert.Equal(RuntimeParentRelationOutcome.Applied, replayAction.ParentRelationOutcome); + // Applied never sets ParentGuid itself - see + // StandaloneParentContinuationAppliesPositionTimestampOnlyMergeWithNoReDefer's + // established precedent (the actual attach commit is + // TryCommitParent's job, out of scope for this residence drain). + Assert.Null(canonical.Snapshot.ParentGuid); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + } + + // --------------------------------------------------------------- + // Round 5 R5-2: cancellation-aware detach/restore window, both + // deferred buckets. + // --------------------------------------------------------------- + + // Reviewer scenario (a) for the CREATES bucket: two children queued + // behind one missing parent; C1's own registration callback delivers + // DeleteObject(C2) then DeleteObject(parent) - C2 must NOT be restored, + // the recreated parent drains nothing, and every ledger converges. + [Fact] + public void DeferredChildReplayWindowFiltersASiblingDeletedMidReplayFromTheRestoredRemainder() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 109UL); + const uint parentGuid = 0x70045000u; + const uint firstChildGuid = 0x70045001u; + const uint secondChildGuid = 0x70045002u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + + RuntimeEntityRecord? firstChild = null; + WrapDeferredChildRegistration(lifetime, (original, spawn, isLocalPlayer) => + { + RuntimeEntityRegistrationResult result = original(spawn, isLocalPlayer); + if (spawn.Guid == firstChildGuid) + { + firstChild = result.Canonical; + // C2 was never individually registered (still a raw + // deferred blob) - its own delete legitimately reports + // false (TryAcceptDelete's known-object gate never + // accepted it), but CancelDeferredChildGeneration still + // runs unconditionally before that gate and records the + // window filter. + _ = lifetime.TryAcceptDelete( + new DeleteObject.Parsed(secondChildGuid, 1), isLocalPlayer: false, + removeRetainedObject: true, out _); + + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), isLocalPlayer: false, + removeRetainedObject: true, out RuntimeEntityDeleteAcceptance parentAcceptance)); + lifetime.CompleteAcceptedDelete(parentAcceptance); + } + return result; + }); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.True(lifetime.Entities.TryGetActive(firstChildGuid, out _)); + Assert.False(lifetime.Entities.ParentAttachments.ContainsDeferredCreate(secondChildGuid, 1)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredCreateCount); + + // C1 was itself admitted as its own ("Parented") initial-residence + // registration - a REAL entity, not a raw blob - and that lease is + // independent of the parentGuid-keyed deferred-create window this + // test targets. Drain it too so every ledger genuinely converges + // (round5-fixes.md's own "ledgers converge" wording), rather than + // leaving an unrelated open lease that has nothing to do with the + // window/filter mechanism under test. + Assert.NotNull(firstChild); + Assert.True(lifetime.TryGetInitialCreateResidence( + firstChild!, out RuntimeInitialCreateResidenceLease firstChildLease)); + _ = RunToCompletion(lifetime, firstChild!, firstChildLease.Token, NoContact); + + RuntimeEntityRecord recreatedParent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 2, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + recreatedParent, out RuntimeInitialCreateResidenceLease recreatedLease)); + RuntimeInitialCreateExecutionReceipt recreatedReceipt = RunToCompletion( + lifetime, recreatedParent, recreatedLease.Token, NoContact); + Assert.Equal(0, recreatedReceipt.ReplayedDeferredChildCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + } + + // Reviewer scenario (b) for the CREATES bucket: C1's callback drives a + // full session clear - restore no-ops, DeferredParentCreateCount stays + // 0, IsConverged holds after teardown. + [Fact] + public void DeferredChildReplayWindowReleasesSilentlyWhenSessionClearFiresMidReplay() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 110UL); + const uint parentGuid = 0x70046000u; + const uint firstChildGuid = 0x70046001u; + const uint secondChildGuid = 0x70046002u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + + WrapDeferredChildRegistration(lifetime, (original, spawn, isLocalPlayer) => + { + RuntimeEntityRegistrationResult result = original(spawn, isLocalPlayer); + if (spawn.Guid == firstChildGuid) + { + IReadOnlyList retirements = lifetime.BeginSessionClear(); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + } + return result; + }); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + // Reviewer scenario (a), relation-queue flavor: two queued relations + // for the same parent; C1's own attach-commit publish delivers + // DeleteObject(C2) then DeleteObject(parent) - C2's relation must NOT + // be restored. + [Fact] + public void DeferredAcceptedRelationReplayWindowFiltersASiblingRelationDeletedMidReplay() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 111UL); + const uint parentGuid = 0x70047000u; + const uint firstChildGuid = 0x70047001u; + const uint secondChildGuid = 0x70047002u; + + RuntimeEntityRecord firstChild = lifetime + .RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence(firstChild, out RuntimeInitialCreateResidenceLease firstLease)); + RuntimeEntityRecord secondChild = lifetime + .RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence(secondChild, out RuntimeInitialCreateResidenceLease secondLease)); + + // Both continuations admit while the FIRST parent incarnation is + // addressable, then the parent is orphaned once for both. + // includePosition:false + RemoveActive (not a full DeleteObject- + // acceptance) for the same reason AdmitThenOrphanParentRelation uses + // them: a real delete retains a permanent per-(guid,generation) + // teardown tombstone and a position-bearing retained snapshot would + // bleed into the SAME-incarnation "recovered CreateObject" merge + // when parentGuid is re-registered at incarnation 1 again below. + RuntimeEntityRecord orphanedParent = lifetime.RegisterEntity( + Spawn(parentGuid, 1, includePosition: false)).Canonical!; + var firstParentUpdate = new ParentEvent.Parsed( + parentGuid, firstChildGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 2); + Assert.True(lifetime.TryApplyParent(firstParentUpdate, null, out _)); + var secondParentUpdate = new ParentEvent.Parsed( + parentGuid, secondChildGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 2); + Assert.True(lifetime.TryApplyParent(secondParentUpdate, null, out _)); + Assert.True(lifetime.Entities.RemoveActive(orphanedParent)); + + _ = RunToCompletion(lifetime, firstChild, firstLease.Token, NoContact); + _ = RunToCompletion(lifetime, secondChild, secondLease.Token, NoContact); + + Assert.Equal(2, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Updated + || delta.Entity.Identity.ServerGuid != firstChildGuid) + { + return; + } + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(secondChildGuid, 1), isLocalPlayer: false, + removeRetainedObject: true, out RuntimeEntityDeleteAcceptance secondAcceptance)); + lifetime.CompleteAcceptedDelete(secondAcceptance); + + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), isLocalPlayer: false, + removeRetainedObject: true, out RuntimeEntityDeleteAcceptance parentAcceptance)); + lifetime.CompleteAcceptedDelete(parentAcceptance); + })); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.False(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(secondChildGuid, secondChild.Key!.Value)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Reviewer scenario (b), relation-queue flavor: C1's own attach-commit + // publish drives a full session clear - restore no-ops for C2's + // relation, converges. + [Fact] + public void DeferredAcceptedRelationReplayWindowReleasesSilentlyWhenSessionClearFiresMidReplay() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 112UL); + const uint parentGuid = 0x70048000u; + const uint firstChildGuid = 0x70048001u; + const uint secondChildGuid = 0x70048002u; + + RuntimeEntityRecord firstChild = lifetime + .RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence(firstChild, out RuntimeInitialCreateResidenceLease firstLease)); + RuntimeEntityRecord secondChild = lifetime + .RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence(secondChild, out RuntimeInitialCreateResidenceLease secondLease)); + + // includePosition:false + RemoveActive (not a full DeleteObject- + // acceptance) for the same reason AdmitThenOrphanParentRelation uses + // them: a real delete retains a permanent per-(guid,generation) + // teardown tombstone and a position-bearing retained snapshot would + // bleed into the SAME-incarnation "recovered CreateObject" merge + // when parentGuid is re-registered at incarnation 1 again below - + // and the reentrant BeginSessionClear below retires every currently + // active record (including the recreated parent), which would + // collide with a stale tombstone left by a full delete here. + RuntimeEntityRecord orphanedParent = lifetime.RegisterEntity( + Spawn(parentGuid, 1, includePosition: false)).Canonical!; + var firstParentUpdate = new ParentEvent.Parsed( + parentGuid, firstChildGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 2); + Assert.True(lifetime.TryApplyParent(firstParentUpdate, null, out _)); + var secondParentUpdate = new ParentEvent.Parsed( + parentGuid, secondChildGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 2); + Assert.True(lifetime.TryApplyParent(secondParentUpdate, null, out _)); + Assert.True(lifetime.Entities.RemoveActive(orphanedParent)); + + _ = RunToCompletion(lifetime, firstChild, firstLease.Token, NoContact); + _ = RunToCompletion(lifetime, secondChild, secondLease.Token, NoContact); + + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Updated + || delta.Entity.Identity.ServerGuid != firstChildGuid) + { + return; + } + IReadOnlyList retirements = lifetime.BeginSessionClear(); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + })); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + // Round 3 E-matrix (a)+(d): TRUE ParentAttachmentState semantics, verified + // by reading the source before writing this test. ParentAttachmentState. + // DeleteGeneration(parentGuid, oldIncarnation) - called from + // TryAcceptDelete when the PARENT is what's being deleted - only ever + // touches the _unresolvedByChild ParentEvent-relation queue (via + // FilterParentCandidates, keeping relations addressed to a STRICTLY + // newer parent incarnation). It never touches _deferredCreatesByParent, + // the raw-netblob bucket ReplayDeferredChildren/DetachDeferredCreates + // actually replays - that bucket is keyed ONLY by parent GUID, with no + // per-incarnation filtering at all (PhysicsAttachment carries no parent + // instance sequence to filter by). So a child queued while parent + // incarnation 1 was missing survives the parent's own delete untouched, + // and DOES replay against a same-GUID recreated incarnation 2 - this + // mirrors retail's own GUID-keyed netblob dispatch (see + // ReplayDeferredChildren's remarks: "a parent's own successful Create + // replays every blob queued waiting on ITS guid"), not a defect. + [Fact] + public void DeferredChildQueuedForDeletedParentIncarnationStillReplaysAgainstTheRecreatedParentGuid() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 50UL); + const uint parentGuid = 0x70030100u; + const uint childGuid = 0x70030000u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + // Parent incarnation 1 arrives, then is deleted, WITHOUT ever + // completing a residence (simulating "gone before the child's + // replay"). + RuntimeEntityRecord parentGen1 = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.False(lifetime.Entities.IsCurrent(parentGen1)); + // The deferred child create survives the parent's delete untouched - + // the TRUE, verified behavior. + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + // Parent GUID reused at a NEWER incarnation. + RuntimeEntityRecord parentGen2 = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 2, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parentGen2, out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parentGen2, parentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord child)); + Assert.True(lifetime.TryGetInitialCreateResidence(child, out _)); + } + + // Round 3 E-matrix (b): a child's own exact Delete before the parent's + // replay cancels only that child (CancelDeferredChildGeneration's side + // effect runs even though TryAcceptDelete's overall return is false for + // a guid with no seeded gate - a purely-deferred child was never + // registered, so it never got one). + [Fact] + public void ChildExactDeleteBeforeParentReplayCancelsOnlyThatChild() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 51UL); + const uint parentGuid = 0x70030200u; + const uint firstChildGuid = 0x70030300u; + const uint secondChildGuid = 0x70030400u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(2, lifetime.CaptureOwnership().DeferredParentCreateCount); + + // The purely-deferred child has no gate; TryAcceptDelete's overall + // gate-consuming path fails, but the deferred-create cancellation + // side effect at the top of the method already ran. + Assert.False(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(firstChildGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out _)); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + Assert.False(lifetime.Entities.TryGetActive(firstChildGuid, out _)); + Assert.True(lifetime.Entities.TryGetActive(secondChildGuid, out _)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + } + + // Round 3 E-matrix (c): two raw Creates queued for the SAME child guid + // (instance 1 and instance 2, both waiting on the same missing parent). + // Deleting the child at instance 1 cancels only the older, same-or-older + // queued generation; the strictly-newer instance 2 survives and replays. + [Fact] + public void NewerDeferredChildGenerationSurvivesOlderGenerationCleanupAndReplays() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 52UL); + const uint parentGuid = 0x70030500u; + const uint childGuid = 0x70030600u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 2, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(2, lifetime.CaptureOwnership().DeferredParentCreateCount); + + Assert.False(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(childGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out _)); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord child)); + Assert.Equal(2, child.Incarnation); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + } + + // Round 3 E-matrix (e): child GUID reuse across a COMPLETE lifecycle - + // register+replay+run-to-completion the first incarnation, delete it + // fully (gate removed), then reuse the SAME guid for a fresh incarnation + // that is AGAIN deferred (different missing parent) and replays cleanly + // with no leakage from the torn-down first generation. + [Fact] + public void ChildGuidReuseAfterFullLifecycleReplaysCleanlyWithoutStaleState() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 53UL); + const uint firstParentGuid = 0x70030700u; + const uint secondParentGuid = 0x70030800u; + const uint childGuid = 0x70030900u; + + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: firstParentGuid), + isLocalPlayer: false) + .DeferredForParent); + RuntimeEntityRecord firstParent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(firstParentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + firstParent, out RuntimeInitialCreateResidenceLease firstParentLease)); + _ = RunToCompletion(lifetime, firstParent, firstParentLease.Token, NoContact); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord firstChild)); + Assert.True(lifetime.TryGetInitialCreateResidence(firstChild, out RuntimeInitialCreateResidenceLease childLease)); + _ = RunToCompletion(lifetime, firstChild, childLease.Token, NoContact); + + // Fully delete the first incarnation (its gate is removed too). + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(childGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance deleteAcceptance)); + lifetime.CompleteAcceptedDelete(deleteAcceptance); + Assert.False(lifetime.Entities.TryGetActive(childGuid, out _)); + + // The SAME guid reused for a fresh incarnation, deferred behind a + // DIFFERENT missing parent. + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 2, includePosition: false, parentGuid: secondParentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord secondParent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(secondParentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + secondParent, out RuntimeInitialCreateResidenceLease secondParentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, secondParent, secondParentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord reusedChild)); + Assert.Equal(2, reusedChild.Incarnation); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + } + + // Round 3 E-matrix (f): a full session reset before the missing parent + // ever arrives clears the deferred-create bucket to zero. + [Fact] + public void SessionResetBeforeParentArrivalClearsDeferredBucket() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 54UL); + const uint parentGuid = 0x70030A00u; + const uint childGuid = 0x70030B00u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + IReadOnlyList retirements = lifetime.BeginSessionClear(); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + // Round 3 E-matrix (g): a full session reset triggered REENTRANTLY from + // inside the deferred-child replay's own Registered callback. The + // parent's own in-flight Execute() must abandon cleanly rather than + // resurrect anything, and the session-clear transaction itself must + // still converge once its own retirements are drained. + [Fact] + public void SessionResetFromWithinReplayDrivenCallbackConverges() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 55UL); + const uint parentGuid = 0x70030C00u; + const uint childGuid = 0x70030D00u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + IReadOnlyList? retirements = null; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Registered + || delta.Entity.Identity.ServerGuid != childGuid + || retirements is not null) + { + return; + } + retirements = lifetime.BeginSessionClear(); + })); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt); + + // The parent's own in-flight execution is no longer current once the + // reentrant reset retires everything - a typed abandonment, not a + // resurrection or an escaping exception. + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + Assert.NotNull(retirements); + foreach (RuntimeEntityRecord record in retirements!) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + } + + // Round 3 E-matrix (h): instance sequence zero is a normal retail + // timestamp, not an empty sentinel (see ParentAttachmentState's own + // remarks on CancelDeferredChildGeneration) - a deferred child at + // incarnation 0 must replay exactly like any other incarnation. + [Fact] + public void InstanceSequenceZeroChildReplaysCorrectly() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 56UL); + const uint parentGuid = 0x70030E00u; + const uint childGuid = 0x70030F00u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 0, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord child)); + Assert.Equal(0, child.Incarnation); + Assert.True(lifetime.TryGetInitialCreateResidence(child, out _)); + } + + // --------------------------------------------------------------- + // F. Failure boundaries. + // + // (b) delete from the hook-emission window (after InitialAdoption, + // before the FIFO drain) has NO reachable callback of its own: + // RunInitialTail's Adopted phase (InitialAdoption) and HookRecorded + // phase (the AfterEnterWorld teleport-hook trace entry) are both pure + // trace-only mutations with no event dispatch - there is no observer + // boundary between them. The ONLY publish inside RunInitialTail is + // ReplayDeferredChildren's per-child Registered event (the + // DeferredReplayed phase), which already has dedicated coverage: + // DeleteDuringDeferredChildReplayAbandonsExecutionWithoutResurrection + // (above, in section G) deletes reentrantly from exactly that callback + // and proves the abandonment converges without resurrecting anything. + // Adding a second, structurally-identical test here would only + // duplicate that coverage; the closest reachable boundary already has a + // test. + // --------------------------------------------------------------- + + [Fact] + public void DeleteFromObserverBeforeInitialAdoptionAbandonsFirstExecuteCleanly() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 60UL); + const uint guid = 0x70031000u; + + // RegisterEntityCore's InitializeAcceptedCreateResidence (Begin()) + // runs BEFORE PublishEntity(Registered, ...) - a real lease/token + // already exists by the time this observer fires, so it can be + // captured and later handed to Execute() to observe that FIRST + // call's own outcome directly, rather than merely proving no lease + // survives. + RuntimeEntityRecord? capturedCanonical = null; + RuntimeInitialCreateResidenceLease capturedLease = default; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Registered + || delta.Entity.Identity.ServerGuid != guid) + { + return; + } + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord active)); + capturedCanonical = active; + Assert.True(lifetime.TryGetInitialCreateResidence(active, out capturedLease)); + // Reentrant delete BEFORE Execute() is ever called for this + // entity at all - before RunInitialTail's InitialAdoption phase, + // the very first thing an Execute() call would otherwise do. + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, active.Incarnation), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + })); + + // The reentrant delete bumps the lifetime-mutation counter for this + // guid DURING the Registered publish, so RegisterEntityCore's own + // post-publish currency check reports this registration as + // superseded (Canonical: null in the returned result) - the record + // captured from inside the observer, above, is the one to assert + // against. + _ = lifetime.RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false); + Assert.NotNull(capturedCanonical); + RuntimeEntityRecord canonical = capturedCanonical!; + Assert.False(lifetime.Entities.IsCurrent(canonical)); + // TryAcceptDelete's own ForgetInitialCreateResidence already retired + // the residence reentrantly - nothing was ever adopted. + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + var observed = new List(); + using IDisposable secondSubscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + // The FIRST (and only) Execute() call for this entity, using the + // token captured before the reentrant delete: the residence is + // already gone (RejectedToken, not RejectedAuthority - there was + // never a Progress to Abandon), publishes nothing new, and + // converges. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute( + canonical, capturedLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Empty(observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void DeleteFromObserverBetweenFifoEntriesAppliesFirstOnlyAndConverges() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 61UL); + const uint guid = 0x70031100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x09000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + var vector = new VectorUpdate.Parsed( + guid, new Vector3(11f, 12f, 13f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + var observed = new List(); + bool deletedFromObserver = false; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + observed.Add(delta.Change); + if (deletedFromObserver || delta.Change is not RuntimeEntityChange.Updated) + return; + deletedFromObserver = true; + // Between FIFO entry 1 (ObjDesc, already applied+published) and + // entry 2 (Vector, not yet reached) - delete reentrantly. + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, canonical.Incarnation), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + })); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + // ObjDesc applied+published exactly once; Vector never applied + // (canonical's velocity stays at its pre-drain value); the delete's + // own Deleted is the only other observed event. + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Deleted], + observed); + Assert.Equal(0x09000002u, canonical.Snapshot.BasePaletteId); + Assert.Null(canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + // A stale retry never resurrects anything or reapplies Vector. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt retryReceipt)); + Assert.Equal(default, retryReceipt); + Assert.Null(canonical.Snapshot.Physics!.Value.Velocity); + } + + // Round 3 F(d): an observer that throws during a publish must not + // corrupt the drain or escape Execute() - RuntimeEntityObjectEventStream. + // Dispatch contains each observer's exception per-call (RecordDispatchFailure) + // and continues to the next observer/pending item. Pin that actual, + // verified behavior here rather than assuming propagation. + [Fact] + public void ObserverThrowDuringPublishIsContainedAndDrainConvergesWithNoDuplicateRetry() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 62UL); + const uint guid = 0x70031200u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + var vector = new VectorUpdate.Parsed( + guid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + throw new InvalidOperationException("Deliberate observer failure for F(d)."))); + long failuresBefore = lifetime.Events.DispatchFailureCount; + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.Vector, + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(Vector3.One, canonical.Snapshot.Physics!.Value.Velocity); + Assert.True(lifetime.Events.DispatchFailureCount > failuresBefore); + Assert.NotNull(lifetime.Events.LastDispatchFailure); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + // A stale retry after the (contained) throw does not duplicate any + // side effect. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute(canonical, lease.Token, NoContact, out _)); + Assert.Equal(Vector3.One, canonical.Snapshot.Physics!.Value.Velocity); + } + + // --------------------------------------------------------------- + // G. Reentrancy + // --------------------------------------------------------------- + + [Fact] + public void ReentrantExecuteForTheSameEntityFailsClosedRatherThanInterleaving() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 11UL); + const uint guid = 0x70026000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + + var reentrantStatuses = new List(); + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Updated || reentrantStatuses.Count != 0) + return; + // Firing a nested Execute from inside an event observer while the + // outer Execute is still on the stack must fail closed. + reentrantStatuses.Add(lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _)); + })); + var vector = new VectorUpdate.Parsed( + guid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [RuntimeInitialCreateExecutionStatus.RejectedAuthority], + reentrantStatuses); + Assert.NotEmpty(receipt.Trace); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void DeleteDuringDeferredChildReplayAbandonsExecutionWithoutResurrection() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 12UL); + const uint parentGuid = 0x70026100u; + const uint childGuid = 0x70026200u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, + out RuntimeInitialCreateResidenceLease parentLease)); + + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Registered + || delta.Entity.Identity.ServerGuid != childGuid) + { + return; + } + // A reentrant delete of the PARENT itself, triggered from inside + // the child-registration callback the deferred replay drives. + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, parent.Incarnation), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + })); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + Assert.False(lifetime.Entities.IsCurrent(parent)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + // Only the PARENT's own residence/progress was ever touched by this + // abandoned Execute call. The CHILD is a distinct entity the + // deferred replay legitimately registered through the canonical + // route before the reentrant delete happened - its own (never + // executed) residence lease correctly remains open; deleting the + // parent does not cascade-tear-down an unrelated child. + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord child)); + Assert.True(lifetime.TryGetInitialCreateResidence(child, out _)); + } + + // Round 3 B2 regression (reentrancy from a publish callback): a wire + // apply (TryApplyMotion) reentrantly enqueued from inside an EARLIER + // continuation's own publish must NOT retire the residence, and the + // continuation that was ALREADY queued before the drain even started + // (Vector) must still drain - proving the reentrant enqueue never + // displaces or skips the pre-existing FIFO tail. + [Fact] + public void WireApplyDuringDrainPublishDoesNotRetireResidenceAndPreExistingFifoStillDrains() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 63UL); + const uint guid = 0x70031300u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x0A000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + var vector = new VectorUpdate.Parsed( + guid, new Vector3(21f, 22f, 23f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + var observed = new List(); + bool reentered = false; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + observed.Add(delta.Change); + if (reentered || delta.Change is not RuntimeEntityChange.Updated) + return; + reentered = true; + var motion = new WorldSession.EntityMotionUpdate( + guid, + new CreateObject.ServerMotionState(0x3d, 0x11), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 1, + IsAutonomous: false); + Assert.True(lifetime.TryApplyMotion(motion, retainPayload: true, null, out _, out _)); + })); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // Vector (already queued before the drain started) drains BEFORE + // the reentrantly-enqueued Motion, and both drain exactly once. + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.ObjDesc, + RuntimeInitialCreateExecutedActionKind.Vector, + RuntimeInitialCreateExecutedActionKind.Movement, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + Assert.Equal(new Vector3(21f, 22f, 23f), canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(new CreateObject.ServerMotionState(0x3d, 0x11), canonical.Snapshot.MotionState); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void RegisterDifferentEntityFromCallbackDuringDrainConvergesBothIndependently() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 64UL); + const uint firstGuid = 0x70031400u; + const uint secondGuid = 0x70031500u; + RuntimeEntityRecord first = lifetime + .RegisterEntityWithInitialResidence( + Spawn(firstGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + first, out RuntimeInitialCreateResidenceLease firstLease)); + var vector = new VectorUpdate.Parsed( + firstGuid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + RuntimeEntityRecord? secondCanonical = null; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Updated + || delta.Entity.Identity.ServerGuid != firstGuid + || secondCanonical is not null) + { + return; + } + // A completely unrelated entity registered reentrantly, from + // inside the FIRST entity's own drain publish. + secondCanonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(secondGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + })); + + RuntimeInitialCreateExecutionReceipt firstReceipt = RunToCompletion( + lifetime, first, firstLease.Token, NoContact); + + Assert.NotEmpty(firstReceipt.Trace); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.NotNull(secondCanonical); + Assert.True(lifetime.Entities.IsCurrent(secondCanonical!)); + Assert.True(lifetime.TryGetInitialCreateResidence(secondCanonical!, out RuntimeInitialCreateResidenceLease secondLease)); + + // The second entity's own independent residence still drains + // normally afterward. + RuntimeInitialCreateExecutionReceipt secondReceipt = RunToCompletion( + lifetime, secondCanonical!, secondLease.Token, NoContact); + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + secondReceipt.Trace.Select(static a => a.Kind)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 G(c)/I2: recreating the SAME guid at a NEWER incarnation from + // a reentrant callback mid-drain. The old execution must abandon + // cleanly (its own progress+residence converge) with NO FIFO transfer + // to the new incarnation; the new incarnation is a completely + // independent, unaffected registration. This is the same scenario the + // task's "replacement convergence" item names - one test covers both. + [Fact] + public void RecreateSameGuidNewerIncarnationFromCallbackAbandonsOldExecutionWithoutFifoTransfer() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 65UL); + const uint guid = 0x70031600u; + RuntimeEntityRecord canonicalGen1 = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonicalGen1, out RuntimeInitialCreateResidenceLease lease)); + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x0B000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + var vector = new VectorUpdate.Parsed( + guid, new Vector3(31f, 32f, 33f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + bool recreated = false; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (recreated || delta.Change is not RuntimeEntityChange.Updated) + return; + recreated = true; + // Same guid, NEWER incarnation, from inside the FIRST + // continuation's (ObjDesc's) own publish - a bare Create with no + // residence, to isolate the replacement mechanics from any + // second drain. + _ = lifetime.RegisterEntity(Spawn(guid, 2, includePosition: false)); + })); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonicalGen1, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + Assert.False(lifetime.Entities.IsCurrent(canonicalGen1)); + // Old progress+residence fully converge. + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + // The new incarnation is unaffected: no residence pending (a bare + // RegisterEntity never begins one), and none of generation 1's + // FIFO (Vector, still undrained) ever transferred to it. + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord canonicalGen2)); + Assert.Equal(2, canonicalGen2.Incarnation); + Assert.False(lifetime.TryGetInitialCreateResidence(canonicalGen2, out _)); + Assert.Null(canonicalGen2.Snapshot.Physics!.Value.Velocity); + } + + // Round 3 G(d): Dispose() called reentrantly from a publish callback. + // Pin the ACTUAL observed behavior (verified by running this test) + // rather than assuming either containment or propagation. + [Fact] + public void DisposeFromCallbackDuringDrainConvergesTheCompleteLedger() + { + var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 66UL); + const uint guid = 0x70031700u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + var vector = new VectorUpdate.Parsed( + guid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is RuntimeEntityChange.Updated) + lifetime.Dispose(); + })); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt); + + // Dispose() reentrantly retires everything (BeginSessionClear -> + // teardown -> converge) before the outer Execute() call's own + // currency check runs again; that check sees a no-longer-current + // canonical and abandons in the SAME typed way any other reentrant + // teardown does. No invalid enumeration/exception escapes. + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + Assert.True(lifetime.CaptureOwnership().IsConverged); + } + + // --------------------------------------------------------------- + // H. Saturation / stale-progress + // --------------------------------------------------------------- + + [Fact] + public void StaleProgressLeaseIdIsDiscardedAndFailsClosedThenRetrySucceedsCleanly() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 13UL); + const uint guid = 0x70027000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + RuntimeEntityKey key = canonical.Key!.Value; + + // Directly plant a stale Progress entry under a LeaseId that does + // NOT match the current lease's token - the exact "an old + // incarnation's progress leaking into a reused key" scenario the + // contract requires Execute to discard and fail closed on for this + // one call, never silently reusing it. + PlantStaleProgress(lifetime, key, lease.Token.LeaseId + 1_000UL); + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute(canonical, lease.Token, NoContact, out _)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + Assert.NotEmpty(receipt.Trace); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + private static void PlantStaleProgress( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityKey key, + ulong staleLeaseId) + { + Type progressType = typeof(RuntimeInitialCreateContinuationExecutor) + .GetNestedType("Progress", System.Reflection.BindingFlags.NonPublic)!; + object stale = System.Runtime.CompilerServices.RuntimeHelpers + .GetUninitializedObject(progressType); + System.Reflection.PropertyInfo leaseIdProperty = progressType.GetProperty( + "LeaseId", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic)!; + leaseIdProperty.SetValue(stale, staleLeaseId); + System.Reflection.FieldInfo progressField = typeof(RuntimeInitialCreateContinuationExecutor) + .GetField( + "_progress", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic)!; + var dictionary = (System.Collections.IDictionary)progressField.GetValue( + lifetime.InitialCreateExecution)!; + dictionary[key] = stale; + } + + // Round 3 H (saturation): adoption Revision pinned at ulong.MaxValue - + // CanEnqueue's completed-entry branch requires + // "Revision < ulong.MaxValue", so a NEW continuation fails closed BEFORE + // any wire consumption; whatever was ALREADY committed before saturation + // still drains and converges normally through the executor. + [Fact] + public void AdoptionRevisionSaturationFailsClosedBeforeAnyNewContinuationCanEnqueue() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 70UL); + const uint guid = 0x70032000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + var firstVector = new VectorUpdate.Parsed( + guid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(firstVector, null, out _)); + + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.InitialCreateResidences.Complete(canonical, lease.Token, out _)); + SetCompletedAdoptionRevision(lifetime, canonical.Key!.Value, ulong.MaxValue); + + var secondVector = new VectorUpdate.Parsed( + guid, new Vector3(2f, 2f, 2f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 3); + Assert.False(lifetime.TryApplyVector(secondVector, null, out _)); + Assert.True(lifetime.InitialCreateResidences.TryGetTransaction( + canonical, out RuntimeInitialCreateResidenceLease afterFailedEnqueue)); + Assert.Single(afterFailedEnqueue.Continuations); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // Only the ONE continuation committed before saturation ever drains - + // exactly once, never duplicated by the failed second attempt. + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.Vector, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(Vector3.One, canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + /// + /// Local copy of RuntimeInitialCreateResidenceStateTests' + /// SetCompletedAdoptionRevision reflection helper (per this task's + /// explicit instruction to reuse the pattern locally rather than share + /// test-project internals across files). + /// + private static void SetCompletedAdoptionRevision( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityKey key, + ulong revision) + { + Type stateType = typeof(RuntimeInitialCreateResidenceState); + System.Reflection.FieldInfo completedField = stateType.GetField( + "_completed", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + var completed = (System.Collections.IDictionary)completedField.GetValue( + lifetime.InitialCreateResidences)!; + object entry = completed[key]!; + System.Reflection.PropertyInfo receiptProperty = entry.GetType().GetProperty( + "Receipt", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic)!; + var receipt = (RuntimeInitialCreateResidenceReceipt)receiptProperty.GetValue(entry)!; + receiptProperty.SetValue( + entry, + receipt with { Adoption = receipt.Adoption with { Revision = revision } }); + } + + // --------------------------------------------------------------- + // External-race detection on the executor's staleness baseline + // (regression coverage for the narrowed pre-Complete + // AdvanceExecutorBaseline guard - Finding 1) + // --------------------------------------------------------------- + + [Fact] + public void ExternalPositionAuthorityMutationWithNoPendingPlacementFailsClosedAndConverges() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 16UL); + const uint guid = 0x70029000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + // Drive the residence directly to "completed + adopted" - the exact + // state RunInitialTail leaves behind before draining any + // continuation, with NO pending continuation placement. This is + // the only state in which the narrowed pre-Complete + // AdvanceExecutorBaseline guard does NOT resync the baseline + // before the next Complete() call runs. + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.InitialCreateResidences.Complete(canonical, lease.Token, out _)); + Assert.True(lifetime.InitialCreateResidences.AdoptCompletedPlacement(canonical, lease.Token)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + // External, non-executor mutation of one of the four baseline + // fields (never through AdvanceExecutorBaseline) - simulating a + // genuine race between Execute calls with nothing in flight. Before + // Finding 1's fix, the OLD unconditional pre-Complete rebaseline + // would have silently absorbed this and returned Completed instead. + lifetime.Entities.AdvancePositionAuthority(canonical); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Empty(observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void ExternalFullCellMutationWithNoPendingPlacementFailsClosedAndConverges() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 17UL); + const uint guid = 0x70029100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.InitialCreateResidences.Complete(canonical, lease.Token, out _)); + Assert.True(lifetime.InitialCreateResidences.AdoptCompletedPlacement(canonical, lease.Token)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + // FullCellId is the second field the reviewer explicitly named - + // exercise it independently of PositionAuthorityVersion. + lifetime.Entities.SetFullCell(canonical, Cell, Landblock); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Empty(observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 4 R4-2 / R4-9(b): the reviewer's exact scenario - external + // SetFullCell DURING the AwaitingContinuationPlacement window (after the + // continuation's OWN placement has been acknowledged but before the + // executor consumes it), not merely with no placement in flight at all + // (that is the PRECEDING test above). Before the fix, both failure arms + // in ResumePendingPlacement cleared PendingContinuationPlacement and + // abandoned WITHOUT ever forgetting the retained acknowledged-placement + // entry - HasRetainedCompletion for this key would then stay true + // forever, blocking EVERY later placement begin (the runtime-surface.md + // 3.1 deadlock this executor exists to resolve). + [Fact] + public void ExternalFullCellMutationDuringAwaitingContinuationPlacementForgetsThePendingPlacementAndAllowsAFreshOneToBegin() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 90UL); + const uint guid = 0x70034000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, true, null, out _, out _, out _)); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + // Drives the continuation's own placement through prepare/submit/ + // acknowledge - the acknowledged projection is now retained under + // this exact token, ready for the NEXT Execute call to consume. + CompletePendingContinuationPlacement(lifetime, key, route); + + // External mutation of the record's cell AFTER the continuation's + // own placement was acknowledged but BEFORE the executor consumes + // it - ResumePendingPlacement's projection/record agreement check + // must catch this exactly like Complete() does for the initial + // placement. + lifetime.Entities.SetFullCell(canonical, canonical.FullCellId + 999u, Landblock); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + RuntimeSetPositionOwnershipSnapshot physicsOwnership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(0, physicsOwnership.ActiveOperationCount); + // The central R4-2 claim: no retained-completion leak. + Assert.Equal(0, physicsOwnership.AcknowledgedPlacementCompletionCount); + + // A subsequent FRESH authored placement for the SAME entity can + // begin - proves HasRetainedCompletion no longer blocks it. + RuntimeEntityPlacementToken fresh = lifetime.Physics.SetPosition + .TryBeginExclusiveAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative); + Assert.True(fresh.IsValid); + } + + // Round 4 R4-9(a): guards B3's residence-retirement notification edge + // for a placement STILL IN FLIGHT (not yet acknowledged) - a THIRD + // PARTY (not the executor) discovers residence staleness through the + // residence's own host-facing TryGetTransaction query, which internally + // Retires the completed entry. That Retire must notify the executor so + // it forgets its OWN separately-tracked pending continuation placement + // token, not just the residence's initial-lease placement. + [Fact] + public void ThirdPartyTryGetTransactionRetireWhileAwaitingContinuationPlacementDiscardsExecutorProgressAndForgetsThePendingPlacement() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 91UL); + const uint guid = 0x70034100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, true, null, out _, out _, out _)); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationPlacement(key, out RuntimeEntityPlacementToken pendingPlacement)); + Assert.True(lifetime.Physics.SetPosition.IsPlacementCurrent(pendingPlacement)); + + // External, non-executor mutation of one of the four executor- + // tracked baseline fields - simulates something OTHER than the + // executor discovering staleness through a THIRD-PARTY call to the + // residence's own host-facing TryGetTransaction, never through + // Execute at all. + lifetime.Entities.AdvancePositionAuthority(canonical); + + Assert.False(lifetime.InitialCreateResidences.TryGetTransaction(canonical, out _)); + + // B3's notification edge: the residence's own Retire (triggered by + // a THIRD PARTY, not the executor) must notify the executor so it + // forgets the pending continuation placement, not just the + // residence's own initial-lease placement. + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.False(lifetime.Physics.SetPosition.IsPlacementCurrent(pendingPlacement)); + RuntimeSetPositionOwnershipSnapshot physicsOwnership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(0, physicsOwnership.ActiveOperationCount); + Assert.Equal(0, physicsOwnership.PlacementCompletionWatchCount); + Assert.Equal(0, physicsOwnership.AcknowledgedPlacementCompletionCount); + + // A subsequent FRESH authored placement for the SAME entity can + // begin - no lingering block from the forgotten pending placement. + RuntimeEntityPlacementToken fresh = lifetime.Physics.SetPosition + .TryBeginExclusiveAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative); + Assert.True(fresh.IsValid); + } + + // --------------------------------------------------------------- + // I. Ownership convergence + // --------------------------------------------------------------- + + [Fact] + public void ResetDuringAwaitingContinuationPlacementConvergesEveryLedger() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 14UL); + const uint guid = 0x70028000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, true, null, out _, out _, out _)); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + IReadOnlyList retirements = lifetime.BeginSessionClear(); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + [Fact] + public void DisposalConvergesTheCompleteOwnershipLedgerAfterASuccessfulExecution() + { + var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 15UL); + const uint guid = 0x70028100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + _ = RunToCompletion(lifetime, canonical, lease.Token, NoContact); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + lifetime.Dispose(); + + Assert.True(lifetime.CaptureOwnership().IsConverged); + } + + // --------------------------------------------------------------- + // J. Gap closures (B1 operation-slot contention). + // --------------------------------------------------------------- + + // Round 3 B1-gap: a Position continuation's own placement-begin can fail + // on TRANSIENT operation-slot contention (another operation already owns + // this entity's SetPosition slot) rather than genuine staleness. The + // merge+publish must already have committed by that point (this is NOT + // an abandonment); a retry after the slot frees must complete WITHOUT + // re-running the merge or re-publishing. + [Fact] + public void OperationSlotContentionYieldsRetryableWithoutAbandoningThenRetryCompletesWithNoDuplicatePublish() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 80UL); + const uint guid = 0x70033000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + Assert.Equal(Cell, canonical.FullCellId); + + // Entry 1: a Vector continuation whose own publish is the injection + // point for occupying the entity's SetPosition slot from OUTSIDE the + // executor - by the time this fires, RunInitialTail's + // AdoptCompletedPlacement has already freed the slot the INITIAL + // placement held, so an external begin here genuinely succeeds. + var vector = new VectorUpdate.Parsed( + guid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + // Entry 2: a far-distance Remote Position continuation that will + // require its OWN real SetPosition placement. + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + RuntimeEntityPlacementToken unrelatedOccupant = default; + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + observed.Add(delta.Change); + if (unrelatedOccupant.IsValid || delta.Change is not RuntimeEntityChange.Updated) + return; + unrelatedOccupant = lifetime.Physics.SetPosition.TryBeginExclusiveAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(unrelatedOccupant.IsValid); + })); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 200f); + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out RuntimeInitialCreateExecutionReceipt receipt); + + // Contention, not staleness: retryable, residence/progress both + // still open (no abandonment). + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Equal(default, receipt); + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + // The merge+publish for BOTH entries already committed before the + // contention was even discovered. + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + Assert.Equal(25f, canonical.Snapshot.Position!.Value.PositionX); + + // Free the slot; retry. + lifetime.Physics.SetPosition.PublishCancellation( + lifetime.Physics.SetPosition.ForgetExactPlacement(unrelatedOccupant)); + + RuntimeInitialCreateExecutionStatus retryStatus = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out RuntimeInitialCreateExecutionReceipt retryReceipt); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, retryStatus); + Assert.Equal(default, retryReceipt); + // No re-merge, no re-publish on the contention retry itself. + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); + CompletePendingContinuationPlacement(lifetime, key, route); + + RuntimeInitialCreateExecutionReceipt finalReceipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.Vector, + RuntimeInitialCreateExecutedActionKind.Position, + ], + finalReceipt.Trace.Select(static a => a.Kind)); + // Still exactly two publishes across the entire contention + + // retry + completion sequence. + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + } + + // --------------------------------------------------------------- + // Harness + // --------------------------------------------------------------- + + private static RuntimeEntityObjectLifetime EngineLifetime() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return new RuntimeEntityObjectLifetime(engine); + } + + private static RuntimeInitialCreateExecutionReceipt RunToCompletion( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeInitialCreateExecutionInputs inputs, + int maxSteps = 25) + { + for (int step = 0; step < maxSteps; step++) + { + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, token, inputs, out RuntimeInitialCreateExecutionReceipt receipt); + switch (status) + { + case RuntimeInitialCreateExecutionStatus.Completed: + return receipt; + case RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement: + { + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + CompletePendingContinuationPlacement(lifetime, key, route); + continue; + } + default: + throw new InvalidOperationException( + $"RunToCompletion hit unexpected status {status}; drive its precondition explicitly instead."); + } + } + throw new InvalidOperationException("RunToCompletion exceeded its step budget."); + } + + private static void CompleteInitialPlacement( + RuntimeEntityObjectLifetime lifetime, + in RuntimeInitialCreateResidenceLease lease) + { + RuntimeSetPositionCommand command = Prepare( + lifetime, + lease.Placement, + lease.Route.OperationKind, + lease.Route.SetPositionFlags); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(lease.Placement, command); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(outcome.Projection)); + } + + private static void CompletePendingContinuationPlacement( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityKey key, + in RuntimeAuthoritativePositionRoute route) + { + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationPlacement(key, out RuntimeEntityPlacementToken placement)); + RuntimeSetPositionCommand command = Prepare( + lifetime, placement, route.OperationKind, route.SetPositionFlags); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(placement, command); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(outcome.Projection)); + } + + private static RuntimeSetPositionCommand Prepare( + RuntimeEntityObjectLifetime lifetime, + in RuntimeEntityPlacementToken placement, + RuntimeSetPositionOperationKind operationKind, + PhysicsSetPositionFlags flags) + { + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + operationKind, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + flags); + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + placement, preparation, out RuntimeSetPositionCommand command)); + return command; + } + + private static void AttachDormantBody( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord canonical, + bool inContact = false) + { + var body = new PhysicsBody + { + State = canonical.FinalPhysicsState, + Orientation = Quaternion.Identity, + InWorld = false, + TransientState = inContact ? TransientStateFlags.Contact : TransientStateFlags.None, + }; + lifetime.Entities.SetPhysicsBody(canonical, body); + } + + /// + /// The real physics sweep an initial-placement commit runs against the + /// test landblock decides the body's own contact bit from scratch, + /// clobbering whatever set beforehand. + /// Call this AFTER the placement commits to pin the contact bit a + /// Position-route test actually needs. + /// + private static void ForceContact(RuntimeEntityRecord canonical, bool inContact) + { + if (canonical.PhysicsBody is not { } body) + return; + body.TransientState = inContact + ? body.TransientState | TransientStateFlags.Contact + : body.TransientState & ~TransientStateFlags.Contact; + } + + private static void Bind(RuntimeEntityObjectLifetime lifetime, ulong generation) + { + var token = new RuntimeGenerationToken(generation); + lifetime.BindEventContext(() => token, static () => 1UL); + } + + private static WorldSession.EntityPositionUpdate PositionUpdate( + uint guid, + ushort positionSequence, + ushort teleportSequence, + ushort forcePositionSequence, + float positionX, + bool isGrounded = true) + { + return new WorldSession.EntityPositionUpdate( + guid, + new CreateObject.ServerPosition(Cell, positionX, 20f, 7f, 1f, 0f, 0f, 0f), + new Vector3(positionSequence, 2f, 3f), + PlacementId: positionSequence, + IsGrounded: isGrounded, + InstanceSequence: 1, + PositionSequence: positionSequence, + TeleportSequence: teleportSequence, + ForcePositionSequence: forcePositionSequence); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation, + bool includePosition = true, + uint? parentGuid = null, + ushort positionSequence = 1, + float positionX = 10f, + bool missile = false, + ushort teleportSequence = 0, + ushort forcePositionSequence = 0, + ushort movementSequence = 1, + ushort serverControlSequence = 1) + { + CreateObject.ServerPosition? position = includePosition + ? new CreateObject.ServerPosition(Cell, positionX, 20f, 7f, 1f, 0f, 0f, 0f) + : null; + uint rawState = (uint)(PhysicsStateFlags.Gravity + | (missile ? PhysicsStateFlags.Missile : 0)); + var timestamps = new PhysicsTimestamps( + Position: positionSequence, + Movement: movementSequence, + State: 1, + Vector: 1, + Teleport: teleportSequence, + ServerControlledMove: serverControlSequence, + ForcePosition: forcePositionSequence, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + rawState, + position, + Movement: null, + AnimationFrame: null, + SetupTableId: null, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: parentGuid is { } parent ? new PhysicsAttachment(parent, 1u) : null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + SetupTableId: null, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "initial-create", + ItemType: null, + MotionState: null, + MotionTableId: null, + PhysicsState: rawState, + InstanceSequence: incarnation, + MovementSequence: timestamps.Movement, + ServerControlSequence: timestamps.ServerControlledMove, + PositionSequence: positionSequence, + ParentGuid: parentGuid, + ParentLocation: parentGuid is null ? null : 1u, + Physics: physics); + } + + private sealed class EntityObserver(Action onEntity) + : IRuntimeEntityObjectObserver + { + public void OnEntity(in RuntimeEntityDelta delta) => onEntity(delta); + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + } +} From 9ad590dcc7757c19bd4a19320e8e5c8241b02020 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 03:52:30 +0200 Subject: [PATCH 48/73] docs(physics): hand off placement continuation executor Synchronize the architecture doc, milestones, roadmap, and ISSUES with the continuation-executor behavior commit (5db3de3c): the residence system is now a complete dormant mechanism, both independent reviews PASS, and the next boundary is the all-host production cutover. The admission handoff gains its superseded banner; the successor handoff records the executor's ownership, the retail anchors proven during review (the wire-contact gate, queue-by-parent-GUID relation replay, HasAnims semantics), the seven new register rows, exact test totals, the rollback command, and the cutover checklist. #275 filed for the post-cutover legacy-Position unification. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 19 ++ docs/architecture/acdream-architecture.md | 25 ++- docs/plans/2026-04-11-roadmap.md | 20 +- docs/plans/2026-05-12-milestones.md | 24 +- ...ime-initial-placement-admission-handoff.md | 6 + ...2-runtime-continuation-executor-handoff.md | 211 ++++++++++++++++++ 6 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 docs/research/2026-08-02-runtime-continuation-executor-handoff.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bfe40dfd..43677b55 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -99,6 +99,25 @@ Copy this block when adding a new issue: --- +## #275 — Unify the legacy Position wire path onto the executor's route classifier + +**Status:** OPEN — post-cutover (physics campaign, filed 2026-08-02) +**Severity:** LOW (internal refactor debt; not a retail divergence) +**Component:** Runtime / inbound Position + +**Description:** the legacy `InboundPhysicsStateController.TryApplyPosition` +(today's only production Position wire caller) has no route-classification or +contact concept; the continuation executor's `ApplyPositionAction` runs +`RuntimeAuthoritativePositionRouteClassifier` with the wire's own `IsGrounded` +bit and threads the classified `installPlacementFrame`/`clearParent` flags +(register rows AP-131 documents the legacy caller's unconditional flags, AD-60 +the cell-semantics difference). When the production cutover wires the executor +in, unify the legacy caller onto the same classifier (or delete it with the +route) and retire AP-131/AD-60's legacy halves. See +`InboundPhysicsStateController.TryApplyPosition` remarks. + +--- + ## #274 — Restricted/barred-house entry needs a connected retail comparison **Status:** OPEN — explicitly deferred by the user on 2026-07-31 diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index cd2422b7..6a356684 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -233,6 +233,9 @@ src/ boundary for dormant initial placement RuntimeInitialCreateResidenceState.cs -> exact-incarnation initial placement lease and accepted mixed-update FIFO + RuntimeInitialCreateContinuationExecutor.cs -> retry-idempotent + adoption + retail Create tail + strict-order + FIFO/replay execution over the residence Gameplay/ RuntimeCommunicationState.cs -> one chat/social owner + ordered stream RuntimeInventoryState.cs -> exact object-table borrower + inventory @@ -608,9 +611,25 @@ entity key. A Create whose parent is not yet addressable is retained even earlier as a complete raw packet, before child timestamp admission, and is guarded by a non-reused admission token. Delete, generation replacement, reset, GUID reuse, and reentrant teardown discard only the matching ownership. -This checkpoint (`30012361`) intentionally stops before executing the FIFO or -switching graphical/no-window production routes; that later cutover must use -this owner rather than create another snapshot or placement path. +The admission checkpoint (`30012361`) intentionally stopped before executing +the FIFO. The continuation executor (`5db3de3c`, +`RuntimeInitialCreateContinuationExecutor`) completes the mechanism: one +synchronous, retry-idempotent `Execute` transaction adopts the acknowledged +initial placement exactly once, emits the local player's after-enter-world +hook request, replays deferred missing-parent raw Creates and queued parent +relations by parent GUID (whole-bucket detach, FIFO dispatch, +cancellation-aware restore windows), and drains the mixed FIFO strictly by +sequence — classifying each retained Position at execution time with live +inputs and driving authored placements through the canonical +`RuntimeSetPositionState` lifecycle with retryable yields. Apply bodies are +shared with the legacy fused inbound paths through gate-less instance seams +that keep the one snapshot store in lockstep; same-incarnation Create +envelopes apply atomically with buffered publication; every abandonment path +retires the residence and converges the combined ownership ledger. The +executor has NO production caller yet — graphical and no-window Create still +use legacy `RegisterEntity` — and the next checkpoint must switch both +production routes onto this owner rather than create another snapshot or +placement path. `LiveEntityRuntime` is the App projection/lifecycle host. `RegisterLiveEntity` first creates or refreshes canonical Runtime state without diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 587db849..5afef9d6 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -76,12 +76,20 @@ accepted mixed updates remain deep-frozen in exact arrival order while the initial placement waits, with no early canonical/public snapshot, event, or presentation mutation. Missing-parent raw Create, delete, reconnect/reset, GUID reuse, malformed projections, saturation, and reentrant teardown are -covered. Production Create registration is not yet cut over. Next are the -synchronous Runtime continuation executor, retail's exact Create tail -ordering, and the all-host/all-route cutover which can retire AP-1/AD-1. AP-22 -authored object shapes and AD-10 remote contact-plane projection follow, then -the final matrix and ledger closeout. Current handoff: -[`2026-08-01-runtime-initial-placement-admission-handoff.md`](../research/2026-08-01-runtime-initial-placement-admission-handoff.md). +covered. Commit `5db3de3c` (2026-08-02) completes the continuation executor: +retry-idempotent single adoption of the acknowledged initial placement, +retail's exact Create tail, GUID-keyed deferred-child and parent-relation +replay with cancellation-aware windows, strict-sequence mixed-FIFO drain with +execution-time retail Position routing, shared apply bodies keeping one +snapshot store in lockstep, and converged ownership ledgers on every +abandonment path — dual independent reviews PASS; register rows +AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 filed in the same commit. +Production Create registration is not yet cut over (the executor has no +production caller). Next is the all-host/all-route cutover which can retire +AP-1/AD-1 behind its connected/visual gates. AP-22 authored object shapes and +AD-10 remote contact-plane projection follow, then the final matrix and +ledger closeout. Current handoff: +[`2026-08-02-runtime-continuation-executor-handoff.md`](../research/2026-08-02-runtime-continuation-executor-handoff.md). --- diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 3f83721d..42beaa8a 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -99,14 +99,22 @@ The bounded admission checkpoint is complete at `30012361`: every accepted same-incarnation Create, ObjDesc, Parent, Pickup, Position, Movement, State, and Vector update is retained as a deep-frozen, arrival-ordered Runtime action without changing the canonical/public snapshot or presentation while initial -placement waits. Raw missing-parent Create packets remain pre-timestamp, and -delete, reconnect/reset, GUID reuse, malformed projections, FIFO saturation, -and reentrant teardown are covered. Production Create registration is not yet -cut over. The remaining order is one synchronous continuation executor with -retail's exact Create tail, the all-host/all-route cutover, AP-22 shape -fidelity, AD-10 remote contact-plane projection, and the final matrix/ledger -closeout. Resume Slice 5 vendor browsing only after that closeout or a new -explicit user direction. +placement waits. The continuation executor is complete at `5db3de3c` +(2026-08-02): one retry-idempotent Runtime `Execute` transaction adopts the +acknowledged initial placement exactly once, applies retail's Create tail, +replays deferred missing-parent raw Creates and queued parent relations by +parent GUID with cancellation-aware detach/restore windows, and drains the +mixed FIFO strictly by sequence with execution-time retail Position routing +through the canonical SetPosition lifecycle. Independent retail-conformance +and architecture/adversarial reviews both PASS after five implementation +rounds; register rows AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 document +the slice's deviations; Runtime tests 903/903, complete Release solution +10,696/4 skips. Production Create registration is still NOT cut over — the +executor has no production caller. The remaining order is the all-host/ +all-route cutover (which can retire AP-1/AD-1 behind its connected/visual +gates), AP-22 shape fidelity, AD-10 remote contact-plane projection, and the +final matrix/ledger closeout. Resume Slice 5 vendor browsing only after that +closeout or a new explicit user direction. The separately authorized modern-runtime performance program has completed Slices A–D: corrected measurement, prepared-package bake/dedup, package-only diff --git a/docs/research/2026-08-01-runtime-initial-placement-admission-handoff.md b/docs/research/2026-08-01-runtime-initial-placement-admission-handoff.md index 4a3ec33f..06e790f3 100644 --- a/docs/research/2026-08-01-runtime-initial-placement-admission-handoff.md +++ b/docs/research/2026-08-01-runtime-initial-placement-admission-handoff.md @@ -1,5 +1,11 @@ # Runtime initial-placement admission handoff - 2026-08-01 +> **Status:** this remains the `30012361` admission-checkpoint history. The +> continuation executor this file scoped as "the next implementation +> boundary" is complete at `5db3de3c`; the current boundary (production +> cutover) is recorded in +> [`2026-08-02-runtime-continuation-executor-handoff.md`](2026-08-02-runtime-continuation-executor-handoff.md). + ## Purpose and exact stopping point Behavior commit `30012361e12222e8271b1531574257ba910c77cb` diff --git a/docs/research/2026-08-02-runtime-continuation-executor-handoff.md b/docs/research/2026-08-02-runtime-continuation-executor-handoff.md new file mode 100644 index 00000000..a19cb239 --- /dev/null +++ b/docs/research/2026-08-02-runtime-continuation-executor-handoff.md @@ -0,0 +1,211 @@ +# Runtime initial-placement continuation executor handoff - 2026-08-02 + +## Purpose and exact stopping point + +Behavior commit `5db3de3c7ab2c6350d11af7f34b852464fc1e0f9` implements the +Runtime continuation executor: the missing mechanism that, once an entity's +initial authored placement is acknowledged, adopts that placement exactly +once, applies the retail Create tail, replays deferred missing-parent work, +and drains the admission checkpoint's mixed continuation FIFO in exact +arrival order with retail route decisions made at execution time. The +residence system built by `38fd4b8d` (residence/FIFO) and `30012361` +(admission) is now COMPLETE as a mechanism: an entity can enter the world +through it and every packet accepted while its placement was pending is +applied exactly once, in order, with retail semantics. + +This checkpoint deliberately does NOT cut the graphical or headless +production routes over — `RuntimeLiveEntitySessionController.cs` (headless) +and App's `LiveEntityRuntime` still call legacy `RegisterEntity`, and no host +calls `Execute`. It does not begin AP-22 or AD-10 and does not retire +AP-1/AD-1. The executor is exercised by deterministic Runtime tests only, so +no connected visual gate was required. + +This file supersedes the executor-boundary portions of +[`2026-08-01-runtime-initial-placement-admission-handoff.md`](2026-08-01-runtime-initial-placement-admission-handoff.md). + +## Exact workspace and Git state + +- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream` +- Branch: `codex/port-claude-agents` +- Behavior checkpoint: `5db3de3c7ab2c6350d11af7f34b852464fc1e0f9` +- Documentation checkpoint: the commit containing this file +- The same eight unrelated dirty paths as the admission handoff remain + intentionally unstaged; never stage by blanket. +- No push or merge is part of this checkpoint. + +## What the executor owns + +`RuntimeInitialCreateContinuationExecutor` (constructed inside +`RuntimeEntityObjectLifetime` beside the residence state; internal +`InitialCreateExecution`; generation bound through `BindEventContext`) owns, +per exact `RuntimeEntityKey` + lease: + +- the synchronous, retry-idempotent `Execute` transaction: + `Complete` -> `AdoptCompletedPlacement` (consumes the acknowledged initial + placement exactly once, resolving the `HasRetainedCompletion` deadlock so + later authored placements for the key can begin, with `PlacementAdopted` + keeping the completed entry current) -> AfterEnterWorld hook request + (local player, exactly once) -> deferred replay -> strict-sequence FIFO + drain -> `ConsumeExecuted` release (adoption-revision-checked; `Revised` + re-drains only the tail); +- per-continuation applies through gate-less instance seams on + `InboundPhysicsStateController` (`ApplyAccepted*Snapshot`) that read and + write the ONE snapshot store — the legacy fused paths are re-expressed as + gate + the same shared merge bodies, so there is no drift and no second + canonical snapshot; +- execution-time Position routing via + `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` + with live inputs: the retained wire packet's own `IsGrounded` bit as the + server-asserted contact (never the local body), the data-driven + `(MotionTableId ?? Physics?.MotionTableId)` animation proxy (AP-130), live + distance/`UsePositionFromServer`, and the record's committed cell — driving + authored placements for SetPosition routes through the canonical + `RuntimeSetPositionState` Begin/Watch/resume lifecycle with a retryable + `AwaitingContinuationPlacement` yield (a contention flavor with no pending + token means "retry Execute later"); +- atomic `SameIncarnationCreate` envelopes: per-stage index idempotency, + buffered publication flushed in stage order after the final stage (AD-59), + the object-table apply via the accepted-spawn seam (result observed; a + nested replacement abandons), and the three-branch resident-cell + disposition (TS-63); +- missing-parent replay, both flavors, keyed by parent GUID exactly as + retail's `QueueBlobForObject`/`ProcessObjectNetBlobs`: raw child Creates + AND queued accepted parent relations, drained in the initial tail with + whole-bucket atomic detach, per-entry exception containment + (`ReplayFailureCount`/`LastReplayFailure`), typed outcomes + (Registered/ReDeferred/Rejected; ParentApplied/DeferredAwaitingParent/ + DiscardedStaleParent), and cancellation-aware restore windows whose tokens + record every cancellation fired while a batch is detached (ABA-safe; + a cleared token restores nothing); +- the field-masked executor baseline: each apply re-syncs ONLY the tracked + fields its own mutations moved, before publication, so external mutations + are detected in every quiet window and publish-callback; +- one shared abandonment routine on every non-retryable exit: forgets any + pending continuation placement (cancellation published), retires the + residence through the lifetime choke point, discards progress, returns a + typed status — the combined ownership ledger (residences, executor + progress, deferred buckets, replay windows, placement watches) converges, + and residence retirement notifies the executor + (`BindRetirementNotification`); +- an ordered immutable execution receipt/trace carrying every fact a cutover + host needs: per-action kind/sequence/stage, Position route facts + (disposition, constrain phase, teleport-hook phase, stop-interpolation, + zero-velocity, preserve-heading, send-position-immediately, unparent), + replay outcomes, and resident-cell dispositions. + +## Retail anchors proven this slice + +Beyond the admission handoff's eight anchor functions: + +- retail's local-ordinary interpolate gate is + `UsePositionFromServer && wire-contact` — `PositionPack` bit 0x4 → + `has_contact` (pseudo-C 284654) → `UnpackPositionEvent` arg5 (93092) → + the gate at 93044. The earlier research note's "isForce" reading was a + misnomer disproven during review; the shipped classifier was correct. +- `ProcessObjectNetBlobs` detaches the whole per-GUID bucket before + dispatching (93617 → 93649) — mirrored by the detach/restore windows. +- missing-parent relations are QUEUED by parent GUID (standalone parent + handler 0x004535D0: lookup 92312, queue 92326; `QueueBlobForObject` + 0x005092D0's GUID-keyed placeholder bucket 271082-271088) — never + discarded; the round-4 discard was overturned on this evidence. +- `HandleReceivedPosition`'s `HasAnims` gate (92992) is animation-queue + presence (`CSequence::has_anims` = non-empty list), anchoring AP-130. +- the same-incarnation tail order and resident-cell cleanup + (93865..93943) are mirrored stage-for-stage, with the claimedCell==0 + destruction branch proven structurally unreachable for admitted envelopes + (every envelope carries a WeenieDescription by shape). + +## Divergence register + +Rows filed in the behavior commit: **AD-59** (envelope buffered live-record +events), **AD-60** (executor canonical cell semantics — wire positions never +directly commit residency), **AP-130** (HasAnims MotionTableId proxy), +**AP-131** (legacy Position merge's unconditional placement-frame/parent- +clear flags — retired by construction at cutover), **AP-132** (parent +incarnation gating vs retail's pointer-only GUID replay), **TS-62** (no live +ConstrainTo binding in the dormant slice — trace-only), **TS-63** +(resident-cell abandonment/delegation split). AP-1 and AD-1 remain open +until the cutover. Issue **#275** tracks the post-cutover unification of the +legacy Position path onto the classifier. + +## Validation + +- Focused executor/residence/classifier gate: **161/161**. +- Complete Runtime project: **903/903** (829 baseline + 74 slice tests). +- Complete Release solution: **10,696 passed / 4 intentional skips / 0 + failed** (`-m:1`, installed `acdream.pak`); Release build 0 errors, + 21 pre-existing test-project warnings. +- `git diff --check` clean; the eight unrelated dirty paths untouched. +- Independent reviews (both read-only, both required to PASS): the + retail-conformance reviewer and the architecture/adversarial reviewer each + ran four passes across five implementation rounds. Finding classes fixed + at root cause along the way: wire-vs-body contact source; two-store + snapshot divergence; WeenieDescription wholesale-overwrite; non-converging + abandonment; reentrant mid-drain residence retirement; the + acknowledged-completion leak that would have blocked all future placements + for a key; per-field baseline blessing; replay exception containment and + detached-batch resurrection; and the stale-parent discard overturned in + favor of retail's queue-by-parent-GUID replay. Final verdicts: RETAIL + REVIEW: PASS; ARCHITECTURE REVIEW: PASS (three residual NOTEs, all + defense-in-depth observations, none blocking). + +## Production routes intentionally unchanged + +Graphical Create still flows through `LiveEntityRuntime.RegisterEntity`; +headless still uses `RuntimeLiveEntitySessionController`'s legacy +`RegisterEntity`; no production code calls +`RegisterEntityWithInitialResidence` or `Execute`. The residence+executor +system is a complete, reviewed, dormant mechanism awaiting the cutover. + +## Next implementation boundary — the production cutover + +Route graphical AND headless registration through the residence+executor +owner together, then every Create, Position, ForcePosition, Parent, Pickup, +withdrawal, delete, remote-movement, projectile-correction, and dropped-item +edge through the same transaction. Hosts project immutable Runtime results +only; they may not resolve a second placement or create another body. Delete +the legacy duplicate paths only after parity tests pass (this retires AP-131 +and closes #275 by construction). Run the exact lifecycle/reconnect and +canonical nine-stop connected routes, two-client observation, and the user +visual matrix. Only then retire AP-1 and AD-1. + +Cutover-specific notes from this slice: + +- The execution receipt carries every route fact a host must bind — the + constrain phases and stop-interpolation/zero-velocity flags (TS-62), the + teleport-hook phases, and the send-position-immediately echo. +- `AwaitingContinuationPlacement` has two flavors: pending token (host must + prepare/submit/acknowledge the placement, then retry Execute) and + contention (no pending token; retry Execute after the conflicting + operation resolves). +- The dormant placement path's 1,880-bytes/operation allocation budget + (2,048 cap) remains the standing 4B2 activation blocker for + frame-frequency traffic; resolve or budget it before the cutover routes + high-frequency Position traffic through the owner. + +After the cutover: **AP-22** (authored collision shapes; +`ShadowShapeBuilder` sole authority), then **AD-10** (remote contact-plane +projection), then the final automated + connected matrix and ledger +synchronization close the campaign; vendor Slice 5 resumes after. + +## Rollback + +```powershell +git revert 5db3de3c7ab2c6350d11af7f34b852464fc1e0f9 +``` + +The documentation checkpoint containing this file is separate and may be +reverted independently. Do not revert the `38fd4b8d`/`30012361` foundation +beneath it without a separately proven defect. + +## Resume checklist + +1. Continue in the exact worktree/branch above; confirm `git log` contains + `5db3de3c` and the documentation commit containing this file. +2. Preserve the eight unrelated dirty paths; never `git add -A`. +3. Read this file, the admission handoff, and + `docs/architecture/acdream-architecture.md`. +4. Re-run the focused gate before modifying execution code: + the Residence + Classifier + Executor filter must report 161/161. +5. Begin ONLY the production cutover checkpoint. Do not fold AP-22, AD-10, + or vendor work into it. From 27e05b99e49a6727f107c7af52d31acac07cd9a7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 04:10:38 +0200 Subject: [PATCH 49/73] docs(physics): plan the placement production cutover The continuation executor (5db3de3c) completed the dormant residence mechanism; the cutover is the campaign leg that makes it production truth. The committed 8-route inventory maps every duplicate placement authority in both hosts with exact call chains, confirms the placement-receipt observer seam is fully built but unattached, and surfaces five pre-cutover gaps the shipped mechanism cannot yet express (executor-to-channel bridge, atomic controller/body publication, the 1,880 B/op activation budget, Runtime-side live-input derivation, the portal-authority adapter). The plan decomposes the cutover into C0-C5 bisectable slices under the campaign's standing contract/dual-review/gate discipline, ending at the connected routes and the user visual matrix that retire AP-1/AD-1. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-02-placement-cutover.md | 89 ++ .../2026-08-02-cutover-route-inventory.md | 1042 +++++++++++++++++ 2 files changed, 1131 insertions(+) create mode 100644 docs/plans/2026-08-02-placement-cutover.md create mode 100644 docs/research/2026-08-02-cutover-route-inventory.md diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md new file mode 100644 index 00000000..6ececdbe --- /dev/null +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -0,0 +1,89 @@ +# Placement production cutover — campaign plan (2026-08-02) + +The final leg of the remaining physics-divergence campaign before AP-22 and +AD-10: route graphical AND headless production placement through the +residence + continuation-executor owner (`38fd4b8d` / `30012361` / +`5db3de3c`), delete the legacy duplicate authorities, and retire AP-1/AD-1 +behind connected + user-visual gates. + +**Inputs (read in order):** +1. [`2026-08-02-runtime-continuation-executor-handoff.md`](../research/2026-08-02-runtime-continuation-executor-handoff.md) + — the completed dormant mechanism and its cutover notes. +2. [`2026-08-02-cutover-route-inventory.md`](../research/2026-08-02-cutover-route-inventory.md) + — the full 8-route, both-host call-chain inventory with exact file:line + for every duplicate authority to remove. THE map for all slices below. +3. [`2026-07-31-remaining-physics-campaign-handoff.md`](../research/2026-07-31-remaining-physics-campaign-handoff.md) + — the original per-route requirements and prerequisite definitions. + +**Standing discipline per slice:** pinned contract → single implementer → +independent retail-conformance + architecture/adversarial reviews (both must +PASS on the final diff) → focused + complete Runtime + Release build + +complete solution gates → bisectable behavior commit (register rows in the +same commit) → docs/handoff commit. No workarounds; no fused slices. + +## Confirmed pre-cutover gaps (from the inventory) + +- The executor publishes only generic entity deltas; nothing bridges its + completion to `RuntimePlacementProjectionChannel`, so no host can learn + "my initial placement committed" through the built observer seam. +- No atomic controller/body publication owner exists (prerequisite C); + App and headless hand-write divergent `PlayerMovementController` + construction, and `SubmitPreparedPlacement` requires a canonical + `PhysicsBody` that nothing currently publishes atomically. +- The dormant placement path's 1,880 B/operation (2,048 cap) allocation + remains the activation blocker for frame-frequency routes. +- `Execute`'s live inputs (`UsePositionFromServer`, `PlayerDistance`) are + computed by no host; they must derive from Runtime's own character-option + and local-player owners. +- `RuntimePortalPlacementAuthority` has zero producing call sites; the + adapter from `RuntimeWorldTransitState` does not exist. +- The exact-Setup mover chain (`PrepareMover` / + `RuntimeSetPositionMoverPreparer.TryBuild` / + `IPreparedCollisionSource.ReadSetupCollision`) exists piecewise, unwired. +- Route-6 split-recovery creates need an effect-replay suppression signal; + route-7 needs `TryCommitParent`/`CommitWithdrawal` cancellation-symmetry + fixes and host-visible cancellation receipts; headless lacks any + parent-realize sequence (pre-existing, adjacent). + +## Slices + +- **C0 — Runtime bridge + live inputs (dormant).** Publish executor + adoption/completion through `Placements` as the receipt stream hosts + consume; derive `UsePositionFromServer`/`PlayerDistance` inside Runtime + from its own owners (no host-supplied gameplay inputs); wire the + exact-Setup mover chain end-to-end for the initial-Create placement; fix + the route-7 cancellation asymmetries. Still no production caller. +- **C1 — atomic controller/body publication (prerequisite C).** One + Runtime-owned transaction publishing the exact same canonical body to + graphical and no-window controllers, prepared off-canonical, committed + atomically, respected by every body writer/binding/clock-epoch/teardown + path. The rejected snapshot-lease design stays rejected. Adversarial-gate + heavy (the campaign handoff's full list). +- **C2 — placement allocation budget.** Pool or eliminate the operation/ + projection envelope allocations on the accepted placement path (root + cause, not a raised cap), or obtain explicit user approval for a measured + budget. Re-measure; the regression gate keeps the ceiling. +- **C3 — spawn-frequency cutover: routes 1 + 8.** Flip graphical AND + headless initial Create/login registration to + `RegisterEntityWithInitialResidence` + executor + placement receipts + together; `MaterializeProjection`/`BuildControllerAndCamera`/ + `SynchronizeLocalPlayer` become projection + acknowledgement only; + presentation-only rebucketing (`RebucketLiveEntity` loses `CommitRebucket`). + Gates add the exact lifecycle/reconnect connected route. +- **C4 — remaining routes: 2 (ForcePosition), 3 (portal, with the + `RuntimeWorldTransitState` → `RuntimePortalPlacementAuthority` adapter), + 4 (remote Create/Position; delete `RemoteTeleportController`/`Placement` + and the inline MoveOrTeleport duplicate), 5 (projectile authoritative), + 6 (drops + split-recovery marking), 7 (residual pickup/parent/delete + polish).** May land as more than one commit if a route proves large; + each sub-landing keeps the full review discipline. +- **C5 — legacy deletion + closeout gates.** Delete every superseded legacy + path; parity tests; exact lifecycle/reconnect + canonical nine-stop + connected routes; two-client observation; **user visual matrix** (the + campaign's stopping point for user acceptance). Retire AP-1, AD-1, + AP-131, AD-60's legacy half, and close #275. Update register/roadmap/ + milestones/architecture/memory + successor handoff. + +After C5: AP-22 (authored collision shapes), then AD-10 (remote +contact-plane projection), then the campaign's final matrix and ledger +closeout; vendor Slice 5 resumes. diff --git a/docs/research/2026-08-02-cutover-route-inventory.md b/docs/research/2026-08-02-cutover-route-inventory.md new file mode 100644 index 00000000..4b0c5885 --- /dev/null +++ b/docs/research/2026-08-02-cutover-route-inventory.md @@ -0,0 +1,1042 @@ +# Production cutover — 8-route inventory (2026-08-02) + +Repo: `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch `codex/port-claude-agents`, +HEAD `9ad590dc`. READ-ONLY research; this file is the only write target. + +Sources read first: +- `docs/research/2026-08-02-runtime-continuation-executor-handoff.md` (executor mechanism, complete/dormant) +- `docs/research/2026-07-31-remaining-physics-campaign-handoff.md` sections "What remains" (prereqs A-E) and "Production route cutover" (routes 1-8) +- `docs/research/2026-07-31-canonical-set-position.md` (1,880 B/op allocation finding, line 327-333) +- scratchpad `runtime-surface.md` sections 8-9 (GameRuntime construction, the two legacy `RegisterEntity` call sites) + +Key confirmed facts carried in from the map: +- `RuntimeInitialCreateResidenceState`/executor is a COMPLETE, TESTED, but 100% UNWIRED + mechanism. Grep confirms (map file, line 545-549) the ONLY two production Create call + sites are: + - `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:76` — `Entities.RegisterEntity(spawn)` (headless/direct-host) + - `src/AcDream.App/World/LiveEntityRuntime.cs:537` — `_entityObjects.RegisterEntity(...)` (graphical/App) + Neither calls `RegisterEntityWithInitialResidence`. `CompleteInitialCreateResidence`/ + `AcknowledgeInitialCreateResidenceAdoption` have NO production caller anywhere. +- `GameRuntime.cs:180-183` constructs `RuntimeEntityObjectLifetime` once as `context.EntityObjects`. + `BindEventContext` at `GameRuntime.cs:266-269` supplies the generation function + `() => generationReset.ActiveRetiringGeneration ?? context.Session.Generation`. +- 1,880 B/op measured on the warmed dormant placement commit/ack route vs a 2,048 B cap + (`docs/research/2026-07-31-canonical-set-position.md:327-333`) — explicit 4B2 activation + blocker, not yet resolved (pool/remove envelopes or record an approved budget). + +--- + +## Cross-cutting: observer-seam status (grep results) + +`IRuntimePlacementObserver` **already exists** as a Runtime-side interface — +it is NOT vaporware, contrary to a naive reading of "prerequisite D" as +all-still-to-build: + +- Defined `src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs:18-21` + (`interface IRuntimePlacementObserver { void OnPlacement(in RuntimePlacementDelta delta); }`). +- `RuntimeEntityObjectEventStream` (same file) already has the full pub/sub + plumbing: `_placementObservers` array (line 40), `SubscribePlacement` + (119-133, copy-on-write add), `UnsubscribePlacement` (356-369), dispatch + loop (302-310ish inside the drain routine), `PublishPlacement` (164-171, + builds a `RuntimePlacementDelta(NextStamp(), placement)` and enqueues it + through the same ordered synchronous drain as entity/inventory deltas). +- Public host seam: `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs` + (88 lines, read in full) — `Subscribe(IRuntimePlacementObserver)` (31-32, + thin passthrough to `_events.SubscribePlacement`), `Acknowledge(expectedGeneration, token)` + (51-55, generation-gated call into `_setPosition.AcknowledgeProjection`), + `RetryPending` (61-67), `TryPeek` (72-80, non-consuming), `PendingCount` (82). + This channel is constructed once inside `RuntimeEntityObjectLifetime`'s ctor + (`Placements = new RuntimePlacementProjectionChannel(Events, Physics.SetPosition)`, + per the surface map's file-1 section) and exposed on `GameRuntime.Placements`. +- Receipt shape: `RuntimePlacementProjectionSnapshot` (`RuntimeSetPositionState.cs:210-217`): + `Token, Kind (Withdraw|Place|Discard — enum at line 57-61), WorldPosition, + Orientation, CellLocalPosition, InContact, OnWalkable`. +- **What does NOT exist**: grep across `src/AcDream.App/**/*.cs` and + `src/AcDream.Headless/**/*.cs` for `IRuntimePlacementObserver`, + `SubscribePlacement`, or `Placements.Subscribe` returns ZERO matches + (confirmed via `grep -rln` across both trees; the only hits anywhere in the + repo outside `src/AcDream.Runtime/` are 5 Runtime-test fake-observer classes + in `tests/AcDream.Runtime.Tests/**`, e.g. + `RuntimeInitialCreateResidenceStateTests.cs:2651`, + `RuntimeSetPositionStateTests.cs:2610`/`2641` (`ThrowingPlacementObserver`), + `RuntimeCollisionPrefixQuiescenceTests.cs:877`, + `RuntimeLocalPlayerPhysicsPublicationStateTests.cs:2326`). **Neither host + has ever constructed a production `IRuntimePlacementObserver` implementation.** + Prerequisite D ("Add one graphical and one headless `IRuntimePlacementObserver` + using `GameRuntime.Placements`") is 100% unbuilt on the host side even though + the Runtime-side plumbing it will attach to is complete and tested. + +## Cross-cutting: connected-gate harness (exact scripts/env) + +- **Exact lifecycle/reconnect gate**: `tools/run-connected-world-lifecycle-gate.ps1` + (477 lines, read in full). Drives two sequential `AcDream.App.exe` sessions + against the always-up local ACE (127.0.0.1:9000): + 1. `capped` session using route script `tools/connected-world-lifecycle.route.txt`, + expects exactly 6 named checkpoints (`capped_login, aerlinthe_first, rynthid, + facility_hub, holtburg_after_dungeon, aerlinthe_revisit`) and 5 screenshots. + 2. `uncapped-reconnect` session (starts as soon as ACE's own log records the + first session's transport Disconnect — no artificial settle delay) using + `tools/connected-world-reconnect.route.txt`, expects one checkpoint + `uncapped_reconnect`. + Per-session env (lines 250-281): `ACDREAM_DAT_DIR`, `ACDREAM_LIVE=1`, + `ACDREAM_TEST_HOST=127.0.0.1`, `ACDREAM_TEST_PORT=9000`, + `ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS`, `ACDREAM_RETAIL_UI=1`, + `ACDREAM_FRAME_PROF=1`, `ACDREAM_UNCAPPED_RENDER` (1 for the reconnect leg + only), `ACDREAM_UI_PROBE_SCRIPT=`, + `ACDREAM_AUTOMATION_ARTIFACT_DIR=`; several other + vars explicitly cleared (`ACDREAM_RENDER_BACKEND`, `ACDREAM_NET_DROP_*`, + `ACDREAM_DUMP_MOVE_TRUTH`, `ACDREAM_WB_DIAG`) to keep the gate a clean + baseline. `Validate-Checkpoint` (140-227) asserts, per checkpoint: reveal + readiness/no invariant failures, Runtime environment ownership initialized + with exactly one active day group, **every `transitOwnership` counter + (`bufferedTeleportDestinationCount, pendingTeleportStartCount, + activeTeleportCount, acceptedTeleportDestinationCount, activeRevealCount, + pendingDestinationReadinessCount, hostProjectionCount, + pendingHostAcknowledgementCount`) is exactly zero at a stable checkpoint**, + zero pending live teardowns/landblock retirements/staged mesh + uploads/composite warmups, and (optionally) zero collision-shadow + mismatches/faults. Client must exit only via graceful `WM_CLOSE` + (`Close-ClientGracefully`, 85-92) and ACE's own log must record + `graceful logout confirmed` + a `PacketHeader Disconnect` drop + (347-354) — **this cutover MUST add placement-owner counters + (pending placement receipts, active leases, pending FIFO continuations) to + this SAME zero-at-stable-checkpoint discipline**, matching the existing + `transitOwnership`/`resources` pattern, or the gate will not actually prove + placement convergence. +- **Canonical nine-stop route**: `tools/run-connected-r6-soak.ps1` (only first + 60 lines read) driving `tools/connected-r6-soak.route.txt`, 9 named + checkpoints in order: `caul-baseline, sawato-baseline, rynthid, aerlinthe, + sawato-return, holtburg, caul-return, sawato-plateau, caul-plateau` (Caul/ + Sawato/Holtburg legs additionally "Exercise"d — likely movement stress, not + just a static stop). Supports `-Uncapped`/`-DenseTown` switches (DenseTown + swaps in `connected-dense-town.route.txt` against a single `arwic-dense` + checkpoint instead). Same env-var family as the lifecycle gate + (`-SkipBuild`, `-LoginTimeoutSeconds`, `-CollisionShadowEvery`). Historical + evidence of both capped and uncapped passing runs: + `docs/research/2026-07-25-slice-g5-production-profile.md:38-39` + (`logs/connected-r6-soak-20260725-105133.report.json` / + `...-121035.report.json`). +- Both scripts require a pre-existing, already-listening local ACE on UDP + 9000 and a live `AceLogPath` (default `C:\ACE\Server\ACE_Log.txt`) and will + themselves invoke `dotnet build ... -c Release` unless `-SkipBuild`. + +## Cross-cutting: 1,880 B/op allocation budget (4B2 activation blocker) + +`docs/research/2026-07-31-canonical-set-position.md:327-333`: "The warmed +immediate commit/ack route currently measures exactly **1,880 managed bytes +per operation** in the Release Runtime test host (1,000 iterations after 64 +warmups); the regression gate caps it at 2,048 bytes. This dormant-path +result is an explicit 4B2 activation blocker rather than a claim of +allocation-free production readiness: 4B2 must either pool/remove the +operation and projection envelopes or record an approved measured budget +before routing frame-frequency placement through this owner." Restated +verbatim in the executor handoff +(`docs/research/2026-08-02-runtime-continuation-executor-handoff.md:181-184`). +**Not yet resolved as of this research pass** — no commit after +`2026-07-31-canonical-set-position.md` claims pooling/removal of the +operation/projection envelopes, and the executor handoff still calls it "the +standing 4B2 activation blocker." This matters most for routes 2 (Local +ForcePosition, frame-frequency-ish under repeated corrections), 4 (remote +Position, genuinely per-network-tick high frequency), and 5 (projectile +per-quantum integration, explicitly excluded from SetPosition routing for +exactly this reason per route 5's "Do not route ordinary per-quantum +projectile integration through SetPosition"). + +## Cross-cutting: `Execute`'s caller-supplied live inputs are unwired in BOTH hosts + +`RuntimeInitialCreateContinuationExecutor.Execute` takes an +`in RuntimeInitialCreateExecutionInputs inputs` parameter +(`RuntimeInitialCreateContinuationExecutor.cs:47-49`): +`bool UsePositionFromServer, float PlayerDistance` — doc comment (38-46): +"Executor-time inputs sampled at the retail decision point. These cannot be +retained at admission time because they describe LIVE state (the local +player, the current physics simulation)... contact is NOT one of these — it +comes solely from the retained wire packet's own `IsGrounded` bit, never from +a live body query." Both fields feed directly into +`RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s +`RuntimeAcceptedPositionRouteRequest.UsePositionFromServer`/`PlayerDistance` +fields (`RuntimeAuthoritativePositionRouteClassifier.cs:137`, consumed at +line 338 for the LocalPlayer interpolate-vs-not branch and later for the +Remote/Projectile near/far 96 m threshold). **Grep across +`src/AcDream.App/` and `src/AcDream.Headless/` for `UsePositionFromServer` +returns ZERO source hits (only compiled `bin`/`obj` binary matches).** +Neither host currently computes or tracks a `UsePositionFromServer` concept +anywhere in production code — this is a genuinely new per-call input a +cutover caller must supply on every `Execute` call, not something that can be +threaded through unchanged from an existing option/flag. `PlayerDistance` is +likely derivable today from existing local-player/WorldEntity state (it is +NOT a new concept — some form of distance-to-local-player computation +already exists for other purposes, e.g. streaming radius), but it has never +been wired specifically into this call contract either. + +--- + +## Prerequisite C context (atomic local controller/body publication) + +Campaign handoff (`docs/research/2026-07-31-remaining-physics-campaign-handoff.md:203-221`) +requires ONE Runtime-owned exclusive/versioned controller/body publication +transaction "respected by **every** canonical body writer" — rejects the +prior prototype (a snapshot/rollback lease) as failure-atomic-only, not +reentrant-safe (line 143-168: a nested SetPosition/remote-bind/GUID-reuse/ +clock-epoch-change/disposal racing the open lease could let the outer +rollback erase newer authority or detach a body another owner already uses). + +Today's per-host body construction (both confirmed by direct read, both are +literally the two things prerequisite C must unify): +- **Graphical**: `PlayerModeController.BuildControllerAndCamera` + (`src/AcDream.App/Input/PlayerModeController.cs:244-...`, only the first + ~140 lines read directly by me) constructs `new PlayerMovementController(...)` + (line 259-262) directly against `_physics`/`playerRecord.ObjectClock`, wires + a `MoveToManager` facade and `EntityPhysicsHost` closures over captured + locals (267-345+), and stores the result in the App-local `_controllerSlot.Controller` + (not `_runtime.MovementOwner.Controller` directly at this point in the file — + Agent 1's detailed route-1 report is authoritative for exactly where/whether + this crosses into `RuntimeLocalPlayerMovementState`/`GameRuntime.MovementOwner`). +- **Headless**: `HeadlessSessionWorldProjection.CreateController` + (`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:639-655`) + constructs `new PlayerMovementController(_runtime.EntityObjects.Physics.Engine, + record.ObjectClock, PlayerMovementConstructionOptions.From(...))` (642-646), + applies physics state/step heights/movement skills (647-652), and is the + ONLY production writer of `_runtime.MovementOwner.Controller = controller` + in the entire repo (confirmed by grep — the graphical host does NOT write + `_runtime.MovementOwner.Controller` anywhere findable by that exact token; + it may go through a different Runtime seam — flag as an open question for + whoever designs prerequisite C: **confirm whether the graphical host's + built controller is ever actually published into `RuntimeLocalPlayerMovementState` + the same way headless's is, or whether this is itself an existing + graphical/headless asymmetry prerequisite C must resolve**). +- `HeadlessSessionWorldProjection.SynchronizeLocalPlayer` + (566-615, read in full) is the duplicate placement authority for headless + route 1/8: calls `_runtime.EntityObjects.Physics.Engine.Resolve(...)` (589-594) + then `.ResolvePlacement(...)` (595-605) directly, then + `controller.SetPosition(resolved.Position, resolved.CellId, wirePosition)` + (610-613) and `controller.SetBodyOrientation(orientation)` (614) — entirely + independent of `RuntimeSetPositionState`/`RuntimeInitialCreateResidenceState`. + `HeadlessSessionWorldProjection.BlipLocalPlayer` (617-637) similarly calls + `controller.BlipPosition(...)` (633-636) directly with no Runtime placement + commit/ack in between — this is the headless twin of route 2's "direct + BlipPosition/pre-commit acknowledgement" duplicate authority. + +--- + +## Cross-cutting: accessibility is NOT a blocker (correction of an initial hypothesis) + +I initially suspected the residence/executor surface being entirely `internal` +(`RegisterEntityWithInitialResidence`, `InitialCreateResidences`, +`InitialCreateExecution`, `Execute`, `CompleteInitialCreateResidence`, +`AcknowledgeInitialCreateResidenceAdoption` — confirmed all `internal` via +direct grep of `RuntimeEntityObjectLifetime.cs` and +`RuntimeInitialCreateContinuationExecutor.cs:426`) might block the graphical +host (a different assembly) from calling them at all. **This is WRONG — +verify before citing it as a gap.** `src/AcDream.Runtime/AcDream.Runtime.csproj:11-16` +declares `InternalsVisibleTo` for `AcDream.Runtime.Tests`, **`AcDream.App`**, +`AcDream.App.Tests`, `AcDream.Core.Tests`, **`acdream-headless`**, and +`AcDream.Headless.Tests` — both hosts already have full internal-member +access to `AcDream.Runtime`. Route 1's research agent independently confirmed +this at the exact same csproj lines. **There is no accessibility barrier to +either host calling `RegisterEntityWithInitialResidence`/driving the executor +today** — the reason nobody does is purely that the call sites haven't been +switched yet, not an assembly-boundary problem. Do not resurrect this as a +"gap" in the final report. + +--- + +## Route 1 — Initial login and CreateObject (from parallel research agent, spot-checked) + +### Graphical host chain (wire -> presentation) + +1. `src/AcDream.App/Net/LiveEntitySessionController.cs:51-53` — `OnSpawned` wire + entry, routes `CreateObject` via `RetailInboundEventDispatcher.Run` to + `LiveEntityHydrationController.OnCreate`. +2. `src/AcDream.App/World/LiveEntityHydrationController.cs:226-250` — `OnCreate` + (dormant/stale-generation classification) -> `OnCreateCore`. +3. `LiveEntityHydrationController.cs:259-262` — `OnCreateCore` (under `_datLock`) + calls `_runtime.RegisterLiveEntity(spawn)`. +4. `src/AcDream.App/World/LiveEntityRuntime.cs:526-539`, **line 537**: + `_entityObjects.RegisterEntity(incoming, RetirePriorProjection)` — the + LEGACY non-residence call, confirmed exact line from the handoff doc. +5. `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:315-322` — + `RegisterEntity` -> `RegisterEntityCore(incoming, beginInitialResidence:false, ...)` + — never touches `InitialCreateResidences`/`InitialCreateExecution`. +6. `LiveEntityHydrationController.cs:288-294` — `ApplyAcceptedSpawn(...)` commits + the accepted snapshot (still cellless authority, no position/rebucket yet). +7. `LiveEntityHydrationController.cs:938-1040` (`ProjectExactOnce`), line 1036: + `_materializer.TryMaterialize(...)`. +8. `src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs:407-427` -> + `MaterializeProjection` (680-857). +9. **Duplicate authority** — `DatLiveEntityProjectionMaterializer.cs:712-737`: + `_runtime.MaterializeLiveEntity(..., out expectedRecord)` with the + `residence` parameter OMITTED, defaulting to + `LiveEntityMaterializationResidence.LegacyImmediate` + (`LiveEntityRuntime.cs:596-597`). +10. `LiveEntityRuntime.cs:589-789` (`MaterializeLiveEntity`) — constructs the + `WorldEntity` (sets Position/Rotation/ParentCellId directly from + wire-decoded world position, ~718-729); because residence is + `LegacyImmediate` not `AwaitRuntimePlacement`, falls through at **line 777** + to `RebucketLiveEntity(serverGuid, fullCellId)`. +11. **Duplicate authority** — `LiveEntityRuntime.cs:792-931` (`RebucketLiveEntity`): + immediately calls `_spatial.RebucketLiveEntity(...)` (spatial bucket + mutation), sets `IsSpatiallyProjected/Visible`, calls + `_entityObjects.CommitRebucket(...)` (canonical `FullCellId`/ + `CanonicalLandblockId` write), resets/suspends the object clock — all + synchronous, no Runtime placement receipt involved anywhere in this path. +12. `DatLiveEntityProjectionMaterializer.cs:791-820` — builds collision, binds + projectile runtime; for static-animating physics objects, + `RegisterAnimation` (859-1021) at **1003-1016** calls + `_runtime.GetOrCreatePhysicsBody(spawn.Guid, incarnation => new + PhysicsBody{...}.SnapToCell(...))` — a SECOND, narrower body-construction + duplicate authority (non-player static-animation bodies). +13. `LiveEntityHydrationController.cs:739-759`/`1070-1098` — publish-ready via + `ILiveEntityReadyPublisher.Publish`. +14. `src/AcDream.App/Input/PlayerModeAutoEntry.cs:214-230` — per-frame guard; + once `IsPlayerEntityPresent` + `IsWorldReady` (world-reveal, unrelated to + any Runtime placement receipt) -> `EnterPlayerMode()` -> + `PlayerModeController.EnterFromAutoEntry`. +15. `src/AcDream.App/Input/PlayerModeController.cs:126-133,217-242` -> + `TryEnter` -> **`BuildControllerAndCamera`** (244-525). +16. **Duplicate authority** — `PlayerModeController.cs:409-413`: + `_physics.Resolve(playerEntity.Position, initialCellId, Vector3.Zero, 100f)`. +17. **Duplicate authority** — `PlayerModeController.cs:422-430`: + `_physics.ResolvePlacement(...)` using a locally computed Setup cylinder + (`_motionBindings.GetSetupCylinder`) — no shared "exact Setup mover" step. +18. **Duplicate authority** — `PlayerModeController.cs:449-480`: builds + `EntityPhysicsHost`/`MoveToManager`, installs via + `EntityPhysicsHostComposition.SelectStableHostWithoutRebind`/`InstallOrRebind` + (`src/AcDream.App/Physics/EntityPhysicsHostComposition.cs:16,35`) — its own + body/controller construction, independent of headless's copy. +19. Camera — `PlayerModeController.cs:440-447`: constructs + `ChaseCamera`/`RetailChaseCamera`, `_camera.EnterChaseMode(...)` + (host-only concern, stays, but must be gated by the Runtime ack not + ad-hoc resolve success). +20. **Duplicate authority — final commit** — `PlayerModeController.cs:482-484`: + `playerEntity.SetPosition(initial.Position); playerEntity.ParentCellId = + initial.CellId; controller.CommitPreparedPosition();` — direct write, + bypasses any Runtime `Place` receipt. + +### Headless host chain + +Same shape as the graphical route through `RegisterEntity`/`ApplyAcceptedSpawn` +(`RuntimeLiveEntitySessionController.cs:73-95`, see Route 8 section above for +the full per-line breakdown), then `IRuntimeDirectWorldProjection.ProjectSpawn` +-> `HeadlessSessionWorldProjection.SynchronizeLocalPlayer` +(566-615)/`CreateController` (639-655) — same duplicate-authority shape as +graphical hops 16-20, but a SEPARATELY HAND-WRITTEN copy, not shared code +(confirmed by the codebase's own comment at +`HeadlessSessionWorldProjection.cs:685-689` contrasting itself with "the +graphical `PlayerModeController.ApplyStepHeights`"). No headless equivalent of +graphical hops 12-14/19 exists (no `WorldEntity`/render sidecar/camera). + +### What each hop must become / capability gaps (route 1) + +- Switch `RegisterLiveEntity`/`OnSpawned` from `RegisterEntity` to + `RegisterEntityWithInitialResidence` — **no accessibility blocker** (see + correction above); this is a pure call-site + follow-up-loop change. +- DELETE `MaterializeProjection`'s direct `RebucketLiveEntity` fall-through; + pass `residence: LiveEntityMaterializationResidence.AwaitRuntimePlacement` + (the enum value ALREADY EXISTS and is used by other routes via + `LiveEntityRuntime.cs:765-776`/`938-1127`'s + `TryApplyRuntimePlacementPlace`/`Withdrawal` — this route just isn't + plumbed into it yet). +- DELETE `PlayerModeController.cs:409-430` (own Resolve/ResolvePlacement) and + `482-484` (direct SetPosition/CommitPreparedPosition); body/controller + construction becomes acknowledge-only from the shared Runtime-resolved + position. +- DELETE headless `SynchronizeLocalPlayer:589-607` and `610-614` the same way. +- DELETE the legacy `RegisterEntity` overload entirely once both hosts move + (`RegisterEntityWithInitialResidence` is already a strict superset). +- **Gap 1 (real)**: no shared "exact Setup mover" / DAT-preparation API. + `RuntimeInitialCreateResidenceState.Own` only OPENS the placement slot + (`TryBeginExclusiveAuthoredPlacement`); nothing in Runtime resolves a + Setup-derived cylinder/sphere-list and feeds it into + `RuntimeSetPositionState.SubmitPreparedPlacement`. Today that DAT read + + Resolve/ResolvePlacement call is hand-duplicated with DIFFERENT defaults in + App (`_motionBindings.GetSetupCylinder`) vs. headless (hardcoded + `DefaultRadius=0.48f`/`DefaultHeight=1.835f` unless `_preparedCollision` + supplies a Setup) — this is exactly prerequisite B, confirmed still open. +- **Gap 2 (real)**: no shared "atomic Runtime controller/body" owner exists + anywhere (`RuntimeEntityRecord` has storage slots — `PhysicsBody`, + `PhysicsHost` — but no construction logic); `BuildControllerAndCamera` and + `HeadlessSessionWorldProjection.CreateController` are two independently + written, divergent implementations of the same "build a + `PlayerMovementController`/host from a `RuntimeEntityRecord`" job. This IS + prerequisite C, confirmed still unbuilt in production code. +- **Gap 1 REFINEMENT (verified directly, not from an agent)**: "no shared Setup + mover API" is not quite right — `RuntimeSetPositionState.PrepareMover` + (`RuntimeSetPositionState.cs:1079-1133`) + `RuntimeSetPositionMoverPreparer.TryBuild` + (`RuntimeSetPositionMoverPreparation.cs:69+`) **already exist** as the shared + authored-mover-preparation API prerequisite B calls for, and + `SubmitPreparedPlacement` (`RuntimeSetPositionState.cs:2041`) already + requires `operation.Record.PhysicsBody is not { } body` to be non-null + BEFORE a placement can even be submitted — i.e. prerequisite C's body must + exist first, structurally enforced. What's genuinely missing is the HOST + input: `PrepareMover` takes a `RuntimeSetPositionMoverPreparation` whose + `Setup` field is a host-supplied `RuntimeSetPositionMoverSetup` (Setup + table ID + `FlatSetupCollision?`, `RuntimeSetPositionMoverPreparation.cs:23-42`) + — Runtime does NOT read DAT itself; the host must call + `IPreparedCollisionSource.ReadSetupCollision(setupId)` (already used by + headless at `HeadlessSessionWorldProjection.cs:668-679`) and feed the + result in. **No production call site anywhere chains + `ReadSetupCollision` -> `PrepareMover` -> `SubmitPreparedPlacement`** — both + hosts instead read Setup DAT/prepared-collision data ad hoc and call + `PhysicsEngine.Resolve`/`ResolvePlacement` directly, entirely bypassing this + already-built pipeline. This is a wiring gap, not a missing-mechanism gap. +- **Gap 3 (real, previously unstated)**: the executor publishes only generic + `RuntimeEntityChange` via `_events.PublishEntity` + (`RuntimeInitialCreateContinuationExecutor.cs:2053-2061`), NOT through + `RuntimePlacementProjectionChannel`/`Placements` — the channel the existing + App `TryApplyRuntimePlacementProjection`/headless placement sinks already + consume for OTHER routes. Whether a residence-driven initial commit + actually surfaces a `Place` token on the SAME channel other routes use is + unconfirmed/unbuilt — this bridge must be built, not assumed. +- Confirms the executor handoff's own claim: zero production callers of + `Execute`/`RegisterEntityWithInitialResidence` today. + +--- + +## Route 2 — Local ForcePosition (from parallel research agent) + +Graphical-only (headless equivalent is folded into Route 8's ForcePosition +branch, already covered above). + +### Call chain + +`src/AcDream.Core.Net/WorldSession.cs:1767` (wire opcode `0xF748` +UpdatePosition — same opcode for ordinary and force; "force" is purely a +`ForcePositionSequence` freshness fact) -> `WorldSession.cs:1774-1786` raises +`PositionUpdated` -> `src/AcDream.App/Net/LiveEntitySessionController.cs:67-69` +-> `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:1024` `OnPosition` +-> `:1035-1048` `_authorityGate.TryAcceptPosition` -> +`LiveEntityInboundAuthorityGate.cs:148-206` -> `_liveEntities.TryApplyPosition` -> +`PhysicsTimestampGate.cs:181-218` `TryAcceptPositionEvent` (retail +`SmartBox::HandleReceivedPosition` 0x00453FD0 FORCE_POSITION branch) returns +`ForcePosition` when newer AND `teleport == current teleport stamp` -> +`LiveEntityNetworkUpdateController.cs:1101-1117` `forceLocal` computed -> +**`LocalForcePositionTransaction.Apply`** (`src/AcDream.App/Physics/LocalForcePositionTransaction.cs:10-28`, +**duplicate authority**): `if (!isCurrent()) return false; blip(); acknowledge(); +return isCurrent();` where `blip` = `PlayerMovementController.BlipPosition` +(`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1759-1773`: `_body.SnapToCell`, +`UpdateCellId` at `:1577-1599` which ALSO publishes the render root via +`_physics.UpdatePlayerCurrCell`) and `acknowledge` = `LocalPlayerOutboundController.SendImmediatePosition` +(`src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs:152-185`, +**sends the outbound `AutonomousPosition` ack BEFORE any Runtime canonical +commit exists — the exact pre-commit-ack bug the campaign handoff names**). +Falls through unconditionally into the **generic tail** +(`LiveEntityNetworkUpdateController.cs:1252-1270`, **second duplicate +authority**): `entity.SetPosition`/`ParentCellId`/`Rotation` + +`_liveEntities.RebucketLiveEntity(...)` on the render-facing `WorldEntity` — +a SECOND independent mutation of the SAME accepted Position, parallel to the +`PlayerMovementController` body mutated by the first authority (two-store +divergence, the exact bug class the executor handoff says it already fixed +for Create). + +### What becomes what / gaps + +- DELETE `LocalForcePositionTransaction` outright; its job (validate + ownership, blip, ack-once) becomes `RuntimeSetPositionState.Apply`/ + `BeginAcceptedPlacement` with `RuntimeSetPositionOperationKind.LocalAuthoritative`. +- `OnPosition`'s force branch reduces to: classify via + `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` (its + `ForcePosition` branch — lines 291-313 — **already produces exactly** + `SetPositionSimple`, `PreserveHeading: true`, `SendPositionImmediately: true` + — this route kind is FULLY MODELED already, just unused) -> submit through + `RuntimeSetPositionState` -> only call `SendImmediatePosition` AFTER commit + (fixes the ordering bug). +- `BlipPosition` becomes acknowledge-only (applies the already-committed + Runtime projection instead of being an independent authority). +- DELETE the generic tail's second `entity.SetPosition`/`RebucketLiveEntity` + for the local player (1252-1270) — dead code once the host projects only + the one canonical result. +- **Gap (confirmed)**: zero production references to + `RuntimeAuthoritativePositionRouteClassifier`/`RuntimeSetPositionState` + anywhere in `src/AcDream.App` for Position handling — tracked by issue + **#275** ("post-cutover unification of the legacy Position path onto the + classifier"), cited in both the executor handoff and the divergence + register's AP-131 row. +- **Gap (confirmed)**: "reject a stale host ack" already exists structurally + (`RuntimeSetPositionState.IsPlacementCurrent`/`ConsumeAcknowledgedPlacement`, + exact-token requirement) but is unwired for Route 2. +- **Scope note**: the continuation EXECUTOR (`Execute`/FIFO drain) is scoped + to initial-Create admission, not steady-state Position on an + already-resident entity — Route 2's correct integration point is + `RuntimeSetPositionState`/`InboundPhysicsStateController.ApplyAcceptedPositionSnapshot` + directly, NOT `Execute`. The `AwaitingContinuationPlacement` retry flavors + are documented generically but demonstrated only for Create — their + applicability to a live ForcePosition is unproven, not just unwired. +- The 1,880/2,048-byte allocation budget applies directly here: ForcePosition + is genuinely frame-frequency-ish traffic. + +--- + +## Route 3 — Portal transit and materialization (from parallel research agent) + +`LocalPlayerTeleportPlacement` exists verbatim (not renamed) inside +`src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:183-290`. + +### Call chain + +Two wire inputs join in `RuntimeWorldTransitState`: **F751 start** (`WorldSession.cs:1972-1987` +opcode `0xF751`) -> `LiveEntitySessionController.cs:83-85` `OnTeleportStarted` +-> `LocalPlayerTeleportController.cs:436-459` gates on +`LiveLocalPlayerTeleportAuthority.IsFreshStart` -> +`PhysicsTimestampGate.IsFreshTeleportStart` and +`RuntimeWorldTransitState.TryQueueTeleportStart` (`:426-442`). **Destination +Position** rides the SAME wire path as Route 2, then at +`LiveEntityNetworkUpdateController.cs:1906-1913` (guarded on `disposition==Apply +&& guid==playerServerGuid`) calls `_localPlayerTeleport.OfferDestination(...)` +-> `RuntimeTeleportDestinationAdapter.cs:15-36` (pure mapping) -> +`RuntimeWorldTransitState.OfferTeleportDestination` (`:480-520`). Every frame, +`LocalPlayerTeleportController.Tick` (`:472-570`) drives: +`TryAimAcceptedDestination`/`AimDestination` (`:626-734`, classifies via +`TeleportLandblockTransition.Classify` — streaming-only, no world mutation) -> +`WorldRevealCoordinator.TryBeginPortal` -> `RuntimeWorldTransitState.TryBeginPortalReveal` +(mints reveal generation + host projection token). On `TeleportAnimEvent.Place`: +preflight `RuntimeWorldTransitState.CanPlacePortalDestination` (read-only gate) +-> **`LocalPlayerTeleportPlacement.Place`** (`LocalPlayerTeleportController.cs:214-278`, +**the duplicate authority**) -> THEN `WorldRevealCoordinator.ObserveMaterialized` +-> `RuntimeWorldTransitState.AcknowledgePortalMaterialized` runs AFTER `Place` +has already fully mutated world state — **a rubber-stamp, not a gate**. On +`TeleportAnimEvent.FireLoginComplete`: `_session.SendLoginComplete()` (outbound +ack) -> `RuntimeWorldTransitState.Complete`. + +`LocalPlayerTeleportPlacement.Place` (full method, 214-278) mutates in order: +`_physics.Resolve(...)` (own collision placement) -> `controller.SetPosition(...)` +(body+cell) -> `entity.SetPosition`/`ParentCellId`/`Rotation` on `WorldEntity` +-> `_liveEntities.RebucketLiveEntity(...)` (throws on failure) -> +`_host.Host?.NotifyTeleported()` (retail `teleport_hook` tail) -> +`controller.SetBodyOrientation(rotation)` -> camera reset +(`_cameras.Legacy?.Update`/`_cameras.Retail?.ResetViewerToPlayer`, **unique to +this route vs. Route 2**) -> `_spatial.Reconcile()`. +`TeleportViewPlaneController` (view-plane/FOV easing) is presentation-only, +NOT in scope for deletion. + +### What becomes what / gaps + +- DELETE `Place`'s resolve/controller/world-entity/rebucket/host-notify/ + orientation mutations outright; replace with + `RuntimeSetPositionState.BeginAuthoredPlacement`/`Apply` carrying a + populated `RuntimePortalPlacementAuthority`, then project only the + committed result. +- Re-sequence `WorldRevealCoordinator.CanPlacePortalDestination`/ + `ObserveMaterialized` to be the ACTUAL gate/confirmation around the + canonical commit (not rubber-stamps around a host-owned mutation) — i.e. + materialization fires from the Runtime commit callback, not immediately + after `Place()`'s direct mutations. +- Camera reset and `_spatial.Reconcile()` become acknowledge-only reactions. +- Headless has the analogous duplicate authority in + `HeadlessSessionWorldProjection.PrepareDestination`/`SynchronizeLocalPlayer` + (already documented in the Route 8 section above) — must cut over in + lockstep, same target (`RuntimeSetPositionState`). +- **Gap (confirmed, important)**: `RuntimeWorldTransitState` (1012 lines, + Slice J6) already fully owns reveal generation/sequence/destination-cell, + readiness, materialization/completion/cancellation, and the 4-stage host + acknowledgement protocol (`ProjectionRegistered -> SimulationReleaseProjected + -> DestinationReservationReleased -> TerminalProjected`) — but ONLY for + streaming/render-resource bookkeeping, NEVER for gating an actual placement + mutation. `RuntimePortalPlacementAuthority` (`RuntimeSetPositionState.cs:64-78`, + carrying `RevealGeneration`/`TeleportSequence`/`RuntimeWorldHostProjectionToken`) + is a REAL, VALIDATED constructor parameter on `RuntimeSetPositionCommand.Portal` + (validated in `BeginAcceptedPlacementCore`, `:978-984`) and + `LiveEntityRuntime.cs:1162-1171` even has an `IsValidPortalPlacementAuthority` + presentation-side validator — **but grep across App and Headless finds ZERO + call sites that ever construct one with `Present: true`.** The binding + machinery is real but 100% dormant end to end. +- **Missing for Route 3 specifically**: (a) the adapter that reads + `WorldRevealCoordinator`'s current generation/sequence/destination-cell/host + projection token into a `RuntimePortalPlacementAuthority`; (b) the actual + `RuntimeSetPositionState.BeginAuthoredPlacement` call site using + `LocalAuthoritative` + that authority (does not exist anywhere today); (c) + re-sequencing `ObserveMaterialized` to run from the commit callback. +- **Discard/cancellation semantics** (a stale generation/sequence/cell/token + must never reveal) look structurally sufficient + (`RuntimePlacementProjectionKind.Discard`, `IsPlacementCurrent`, + `RuntimeWorldTransitState.Cancel`/`BeginHostProjectionSupersession`) but are + UNEXERCISED together for a real placement — treat as an explicit cutover + test, not an assumed-correct combination. +- Executor scope note: same as Route 2 — the FIFO/`Execute` mechanism is + Create-admission-scoped; Route 3's target is `RuntimeSetPositionState` + directly. + +--- + +## Route 4 — Remote CreateObject and Position (from parallel research agent) + +### Foundational fact (independently confirmed by this agent AND by direct +grep during this research pass) + +`InboundPhysicsStateController.cs:596-608`'s own doc comment states verbatim: +"this file's `TryApplyPosition` is today's **only PRODUCTION Position wire +caller**; the classifier-based path is **test-only** until a host wires the +executor." `RuntimeEntityObjectLifetime.TryApplyPosition` only enters the +classifier/executor path when `TryGetPendingInitialResidence` finds an +in-flight CreateObject continuation (a narrow transient window) — steady-state +remote UpdatePosition always falls to the legacy unconditional-merge path +with **no route-classification concept at all**. + +### Call chain + +`LiveEntitySessionController.cs:67-69` -> `LiveEntityNetworkUpdateController.OnPosition` +(`:1035`) -> `LiveEntityInboundAuthorityGate.cs:161` `TryApplyPosition` -> +`LiveEntityRuntime.cs:2116,2135` -> `RuntimeEntityObjectLifetime.cs:1229,1299` +steady-state branch -> `RuntimeEntityDirectory.cs:478` -> +`InboundPhysicsStateController.cs:610` (legacy unconditional timestamp-gated +merge). Then `LiveEntityNetworkUpdateController.cs:1197-1211` computes +`remoteHardTeleport`/`remotePlacementRequired` from `timestamps.TeleportHookRequired` +and `_remoteTeleportController.HasPending`; `RunRemoteTeleportHook` (`:1210`) +-> `RemoteTeleportHook.Execute` (`RemoteTeleportHook.cs:17`, CancelMoveTo/ +UnStick/StopInterpolating/UnConstrain/NotifyTeleported/ReportCollisionEnd). +**Then, UNCONDITIONALLY** (`LiveEntityNetworkUpdateController.cs:1252-1270`, +**duplicate authority**, BEFORE any placement/teleport branch runs): +`entity.SetPosition`/`ParentCellId`/`Rotation` + `RebucketLiveEntity`. If +`remotePlacementRequired`: `RemoteTeleportController.BeginPlacement` (`:269`) +-> `TryApply` (`:117/151`) -> `Resolve` (`:278`) -> `_physics.ResolvePlacement` +(`:303`) -> `CommitResolved` (`:320`) -> `RemoteTeleportPlacement.Apply` +(`RemoteTeleportPlacement.cs:15`) -> `PhysicsObjUpdate.CommitSetPositionTransition`. +Else (ordinary MoveOrTeleport): **inline classification hand-duplicated in +App**, `LiveEntityNetworkUpdateController.cs:1594-1900` (local constants +`MaxPhysicsDistance=96f`/`BodySnapThreshold=4f` duplicated for player vs NPC) +decides far-snap/near-interpolate/airborne-noop/landing, then +`entity.SetPosition`/`ParentCellId`/`Rotation` + `LiveEntityShadowPublisher.TryPublishRemote`. +Ongoing per-tick DR: `RemotePhysicsUpdater.cs:220` -> +`RuntimeRemotePhysicsUpdater.Tick` (`:61`) — interp catch-up + `ResolveWithTransition` +sweep + shadow sync, independent of SetPosition (retail's continuous +`UpdatePositionInternal` — NOT itself a wire route, out of cutover scope). + +### Duplicate authorities + +- `LiveEntityNetworkUpdateController.cs:1252-1270` — position/cell/rotation + AND full rebucket, unconditionally, ahead of any classification. +- `RemoteTeleportController.cs:37,77-109` — `_pending` dictionary, rollback + capture/restore (`:94-109,477-569`), lost-cell tracking + (`:529-532`) — exactly the bookkeeping the residence+executor's + 25-second lost-cell lifetime already owns generically. +- `RemoteTeleportPlacement.cs:43-77` — direct `body.SnapToCell`, + contact-plane fields, `PhysicsObjUpdate.CommitSetPositionTransition` — a + hand-rolled SetPosition commit parallel to `RuntimeSetPositionState`. +- `LiveEntityNetworkUpdateController.cs:1594-1900` — the "ordinary" + MoveOrTeleport classification hand-duplicated in App, when + `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` + ALREADY implements identical near(<96m,Interpolate)/far(SetPositionSimple)/ + teleport-advanced(SetPosition)/cellless rules (classifier lines 360-439). +- `RemoteTeleportController.cs:382-475` — `ParkPending`/`OnProjectionVisibilityChanged` + re-implements deferred-until-visible placement replay, another thing the + executor's FIFO/replay machinery already generalizes. + +### What becomes what + +- DELETE outright: `RemoteTeleportController.cs`, `RemoteTeleportPlacement.cs`, + their pending dictionary/rollback/`ParkPending`, and the pre-placement + `entity.SetPosition`/`RebucketLiveEntity` at `:1252-1270`. +- DELETE + replace with classifier delegation: the inline near/far/airborne/ + landing block (`:1594-1900`) -> route through + `ClassifyAcceptedPosition` + `RuntimeSetPositionState` Begin/Watch/resume. +- Reduce to acknowledge-only: `RemoteTeleportHook.cs` stays as the App-side + execution seam, but its trigger condition should come from the classified + route's `RuntimeTeleportHookPhase`, not the App's own `remoteHardTeleport` + flag. +- `RuntimeRemotePhysicsUpdater` per-tick DR is OUT of cutover scope (retail's + continuous simulation, not a wire route). + +### Gap + +The classifier's `Remote` branch is FULLY implemented for both `ClassifyCreate` +and `ClassifyAcceptedPosition` — the only gap is wiring (no production caller +ever constructs a `RuntimeAcceptedPositionRouteRequest` for a Remote entity +outside the narrow pending-residence window). The 1,880 B/op budget applies +directly here too (remote Position is genuinely per-network-tick frequency). + +--- + +## Route 5 — Projectile authoritative create/corrections (from parallel research agent) + +### Call chain + +**Create**: `DatLiveEntityProjectionMaterializer.cs:807` `_projectiles.TryBind` -> +`ProjectileController.TryBind` (`ProjectileController.cs:133`) — constructs/ +adopts the shared `PhysicsBody` (`:176-265`), calls `body.SnapToCell` directly +(`:214/256`), then `entity.SetPosition`/`ParentCellId` + `RebucketLiveEntity` +(`:268-271`) and `ShadowPositionSynchronizer.Sync` (`:306`) — all ad hoc, no +classifier. **Vector** (launch velocity): `LiveEntityNetworkUpdateController.cs:840` +`OnVector` -> `ProjectileController.ApplyAuthoritativeVector` (`:345`) -> +`RuntimeProjectilePhysicsUpdater.ApplyAuthoritativeVector` (`:207`). **State** +(Missile bit): `:992` `OnState` -> `ApplyAuthoritativeState` (`:410`) -> +`RuntimeProjectilePhysicsUpdater.ApplyAuthoritativeState` (`:252`, direct +`SnapToCell` at `:288`). **Authoritative Position correction**: +`LiveEntityNetworkUpdateController.cs:1217-1233` (inside `OnPosition`, AHEAD of +the generic remote path — returns early if handled) -> +`ProjectileController.ApplyAuthoritativePosition` (`:508`) -> +`RuntimeProjectilePhysicsUpdater.ApplyAuthoritativePosition` (`:301`, direct +`body.SnapToCell` at `:346`, `CommitProjectileCell` at `:370`, direct +`ShadowPositionSynchronizer.Sync`/`Suspend` `:402-421`). **Ordinary per-quantum +integration**: `ProjectileController.Tick` (`:613`) -> `AdvanceQuantum`/ +`TryBeginQuantum`/`CompleteQuantum` (`:761-836`) -> +`RuntimeProjectilePhysicsUpdater.TryBegin`/`Complete` (`:37,94`) — `Complete` +does direct `body.SnapToCell` at line 145 + direct shadow sync — correctly +NOT routed through SetPosition today, but ALSO not through any shared +cell-commit authority; its own ad hoc `CommitProjectileCell` + shadow write. + +### Duplicate authorities + +- `ProjectileController.cs:214-271` — body construction, `SnapToCell`, + `entity.SetPosition`/`ParentCellId`, `RebucketLiveEntity`, shadow sync — full + ad hoc placement authority for missile creation. +- `RuntimeProjectilePhysicsUpdater.cs:145-198` (per-quantum `Complete`) and + `:346-421` (`ApplyAuthoritativePosition`) — BOTH do direct `SnapToCell` + + `CommitProjectileCell` + shadow sync, bypassing `RuntimeSetPositionState` + entirely; **neither distinguishes "ordinary integration" from "authoritative + correction" at the cell/shadow-commit layer** — only the caller's semantic + label differs. + +### What becomes what + +- DELETE outright: the authoritative-placement code in `TryBind`'s create + branch (`:214-271`) and `ApplyAuthoritativePosition` (`:508-578`); and the + direct `SnapToCell`/shadow commit inside + `RuntimeProjectilePhysicsUpdater.ApplyAuthoritativePosition` (`:301-424`). +- REPLACE with `ProjectileAuthoritative`-tagged routes via the classifier + driving `RuntimeSetPositionState`, using "the same Runtime body and exact + projectile Setup sphere" — i.e. `RuntimeProjectile.Body`/`CollisionSphere` + (`RuntimeProjectile.cs:10-40`) plugged into the shared SetPosition + transaction instead of `CommitProjectileCell`. +- KEEP UNCHANGED (must NOT route through SetPosition): per-quantum + integration (`TryBegin`/`Complete`, `:37-205`) — correctly outside + SetPosition today; only its cell/shadow-commit CALL is a candidate to unify + onto a shared (non-SetPosition) cell-commit primitive. +- Reduce to acknowledge-only: `ApplyAuthoritativeVector`/`ApplyAuthoritativeState` + (`:332-478`) — smaller scope of change since they don't do placement/rebucket + themselves today. + +### Gap + +`ProjectileAuthoritative` exists TODAY ONLY as an `OperationKind` label +(`RuntimeSetPositionState.cs:16`), produced generically by the classifier's +`OperationKind(...)` helper for both entry points — **it is not a distinct +routing rule**: `ClassifyCreate` gives Projectile the exact same +`SetPosition`/`InitialCreateFlags` route as any non-local entity, and +`ClassifyAcceptedPosition` gives Projectile the IDENTICAL near/far/teleport/ +cellless branches as Remote (proven by the test name +`ProjectilePosition_UsesRemoteMoveOrTeleportClassification`, +`RuntimeAuthoritativePositionRouteClassifierTests.cs:418-436`). So: PARTIAL — +operation-kind plumbing + generic MoveOrTeleport-shape routing exist; missing +is (a) any production wiring at all (same dormancy as route 4), and (b) +projectile-specific POLICY (should a "near" wire correction ever return +`Interpolate` for a ballistic body, or always hard-correct?) — today's +"never route per-quantum integration through SetPosition" constraint holds +only because per-quantum integration never calls the classifier at all (a +separate simulation-tick path), not because of an explicit guard. + +--- + +## Route 6 — Drops and unparent-to-world (from parallel research agent) + +Graphical-only; no headless drop UI exists. + +### Call chain + +**Whole-item/normal drop**: `ItemInteractionController.ExecutePlacementActions`, +`DropToWorld` case (`src/AcDream.App/UI/ItemInteractionController.cs:976-993`) — +optimistic move + send drop; ordinary server CreateObject flows through the +normal wire pump: `LiveEntityHydrationController.OnCreate`/`OnCreateCore` +(`:226-299`) -> `_runtime.RegisterLiveEntity(spawn)` -> `LiveEntityRuntime.cs:526-556` +-> `_entityObjects.RegisterEntity(...)` (line 537) -> `RuntimeEntityObjectLifetime.RegisterEntity` +(315-322, legacy, `beginInitialResidence:false`). **Split-to-world** (ACE omits +CreateObject, sends only the new GUID's Position first): +`ItemInteractionController.cs:994-1015` (`SplitToWorld` case, raises +`WorldDropDispatched`) -> `InventoryWorldDropProjectionController.OnWorldDropDispatched` +(`:81-98`, records `PendingSplitToWorldProjection`) -> wire Position (F748) for +the new GUID arrives at `LiveEntityNetworkUpdateController.OnPosition:1024-1029`, +FIRST calls `_worldDropProjection.TryRecoverUnknownPosition(update)` (1026) -> +`InventoryWorldDropProjectionController.cs:54-68`: builds a synthetic +`EntitySpawn` via `PendingSplitToWorldProjection.BuildSpawn` (171-209, clones +the SOURCE item's full spawn/PhysicsSpawnData, overriding +Guid/Position/StackSize/Container/Wielder/*Sequence/Parent*) -> calls +`_hydration.OnCreate(spawn)` (66) — **the IDENTICAL entry point as the +ordinary path**, converging on the same `RegisterEntity` call. +`ShortcutDropPlanner` is unrelated to this route (toolbar-slot reshuffling +only, never touches position/physics). + +### Duplicate authority / gap + +Neither drop flavor calls `Physics.SetPosition.Begin*` or +`RuntimeInitialCreateResidenceState.Begin` today — **there is no App-vs-Runtime +competing mutation for drops specifically**; the gap is identical to routes +1-5: `RegisterEntityWithInitialResidence` is never called from any production +path (confirmed by full-repo grep — only self-referential inside +`RuntimeEntityObjectLifetime`'s own constructors, as the executor's +child-replay delegate, plus tests). Both drop flavors equally bypass the +canonical create-placement transaction — this is a wiring gap shared with +routes 1-5, not a route-6-specific duplicate. +- **Real, previously-unstated Route-6-specific gap**: `PendingSplitToWorldProjection.BuildSpawn` + clones the source's DTO wholesale (correct for Position, which IS + explicitly overridden) but presentation-effect replay (create-pop VFX/sound + driven off `LiveEntityReadyPublisher.Publish`/`EntityEffectController.ReplayPendingForLiveEntity`) + has NO SIGNAL today distinguishing "real spawn" from "split continuation" — + and the executor's receipt/trace has no field for "this create is a + split-recovery continuation, suppress create-time effect replay." This must + be ADDED, not just wired — a genuine new capability, not merely dormant. + +### What becomes what + +`LiveEntityHydrationController.OnCreate`/`OnCreateCore` switches to +`RegisterEntityWithInitialResidence` for EVERY CreateObject (both flavors) so +both acquire a lease and drain through `Execute`. +`TryRecoverUnknownPosition`'s call shape doesn't need to change, but the +residence/executor layer needs the new "suppress effect replay for split +continuation" capability above. `WorldDropDispatch`/`ShortcutDropPlanner`/ +`ItemInteractionController` dispatch (pure UI/wire trigger) is unaffected. + +--- + +## Route 7 — Pickup, Parent, and Delete (from parallel research agent) + +### Confirms/refutes "Runtime hooks already exist" + +**Largely CONFIRMED at the App/headless-caller level**: none of +`LiveEntityHydrationController`, `EquippedChildRenderController`, +`LiveEntityDeletionController`, `LiveEntityRuntimeTeardownController` do their +own `SetFullCell`/`SuspendObjectClock`/rebucket math — all of it lives inside +`RuntimeEntityObjectLifetime` (`TryApplyPickup:805-868`, +`TryApplyParent/TryApplyCreateParent` -> shared helper +`CommitPositionChannelUpdate:1705-1737`, `TryCommitParent:944-974`, +`CommitAcceptedParentCellless:976-1007`, `CommitWithdrawal:1401-1422`, +`TryAcceptDelete:1466-1527`). App/headless work is exclusively presentation +(render subtree, shadows/effects/animation/selection teardown) — legitimate, +not duplicated physics authority. + +**BUT the claim needs an important qualification (previously-unstated +finding)**: every `ForgetInitialCreateResidence(...)` call in this family +(849, 1720-1721, 990, 1411, 1501) cancels a residence lease that is **NEVER +ACQUIRED** anywhere in production (same Route-1/6 finding — +`RegisterEntityWithInitialResidence` unreached), and every paired +`Physics.SetPosition.Forget(...)` cancels a placement transaction that is +likewise **NEVER BEGUN** anywhere in production +(`BeginAcceptedPlacement`/`BeginAuthoredPlacement`, +`RuntimeSetPositionState.cs:803,815`, zero external callers repo-wide). **So +today these cancellation calls are structurally present but functionally +no-ops against always-empty state** — `InitialCreateResidences.Forget` +returns false; `PreferCancellation` always falls through to the equally-empty +"ordinary" receipt. Route 7 is correctly "already cut over" for the parts +that currently do anything (SetFullCell/SuspendObjectClock/RefreshSnapshot/ +AdvanceXAuthority/CollisionReports/EndChildProjection are genuinely +Runtime-owned) — but the specific behavior the campaign doc worries about +("cancel the exact active placement/lost-cell family first") is INERT +because there is nothing upstream yet to cancel. Once routes 1-5 wire the +create/position path live, these existing calls become live with NO code +change required — genuinely pre-built and correctly sequenced already. + +### Two internal asymmetries flagged for cutover review (new findings) + +- `TryCommitParent` (944-974) is the ONLY method in the family with NO + `ForgetInitialCreateResidence`/`PreferCancellation` call at all. +- `CommitWithdrawal` (1401-1422) calls `ForgetInitialCreateResidence` but NOT + `Physics.SetPosition.Forget`/`PreferCancellation`, unlike Pickup/ + `CommitAcceptedParentCellless`/`TryAcceptDelete` which cancel both families. + Once ordinary placement transactions go live, + `WithdrawLiveEntityProjectionToCellless` (`LiveEntityRuntime.cs:1315-1331`, + calls `CommitWithdrawal`) could leave an active mover/lost-cell watch + dangling. **Flag for explicit cutover test, do not assume symmetric with + the other four methods.** + +### Headless-specific gap (real, pre-existing, adjacent to but distinct from the cutover) + +`RuntimeLiveEntitySessionController.OnParentUpdated` (216-220) calls ONLY +`Entities.TryApplyParent` directly — **headless NEVER calls +`TryCommitParent`/`CommitAcceptedParentCellless`** — there is no headless +equivalent of the App parent-realize sequence +(`EquippedChildRenderController.ResolveAndTryRealize`/`PrepareAndTryRealize`, +`LiveEntityHydrationController.cs:470` chain) AT ALL. This looks like a live +gap independent of the residence/executor cutover — a headless parented +child's `FullCellId` may never clear. Needs new code, not just wiring. +`HeadlessSessionWorldProjection`/`IRuntimeDirectWorldProjection` has no +pickup/parent/delete hook at all (only `ProjectSpawn`/`ProjectPosition`/ +`BeginTeleport`/`PrepareDestination`) — headless has ZERO duplicate +placement work for route 7 (consistent with "already cut over" for the parts +that do anything). + +### Gap vs. the executor + +"What the executor owns" already anticipates Route 7's "pickup during +preparation/deferred residence" and "parent during pending withdrawal" test +scenarios via the `TryGetPendingInitialResidence`/`EnqueueDormant` branches +already present in `TryApplyPickup`/`TryApplyParent` — no NEW executor +capability is needed for route 7's steady-state (post-create) behavior; the +gap is entirely "wire up creates to use residence" (routes 1-3's job). One +real gap: the executor's receipt/trace has no concept of "a pickup/delete +cancelled my pending residence" — `RuntimePlacementCancellationReceipt` +contents route only to `Physics.SetPosition.PublishCancellation`, never +surfaced to the calling host for observability/testing. + +--- + +## Route 8 — Headless parity (researched directly, not delegated) + +Files read in full: `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` +(693 lines), `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` +(349 lines). + +### Current production call chain — headless host + +Entry: `RuntimeLiveEntitySessionController.CreateSink()` (56-68) wires a +`LiveEntitySessionSink` whose delegates are the ONLY headless wire-dispatch +entry points for entity/position/motion/vector/state/parent/teleport traffic. +Each handler below calls straight into `RuntimeEntityObjectLifetime` (the +LEGACY, non-residence overloads — every call passes `acknowledgeProjection: null`) +and then, only for specific cases, into `IRuntimeDirectWorldProjection` +(implemented by `HeadlessSessionWorldProjection`): + +1. `OnSpawned` (73-95) — `Entities.RegisterEntity(spawn)` (75-76, **the + legacy Create entry point — confirmed by the surface map's grep as one of + only two production Create call sites in the repo**) → if `registration.Canonical` + present, `Entities.ApplyAcceptedSpawn(canonical, integrationVersion, + canonical.Snapshot, replaceGeneration: ...)` (81-87) → if applied, + `_worldProjection?.ProjectSpawn(canonical, isLocalPlayer)` (90-93) → + `HeadlessSessionWorldProjection.ProjectSpawn` (507-513): **no-op for any + non-local entity** (`if (isLocalPlayer) SynchronizeLocalPlayer(record);` — + line 511-512 IS the entire method body); for the local player, calls + `SynchronizeLocalPlayer` (566-615) which is the duplicate placement + authority (see below). +2. `OnPositionUpdated` (137-201) — `Entities.TryApplyPosition(update, isLocal, + forcePositionRotation: localController?.BodyOrientation, + currentLocalVelocity: localController?.BodyVelocity, + projectionRequiresTeleportHook: false, acknowledgeProjection: null, ...)` + (144-153, legacy path) → if `!isLocal` or `Rejected`, return (154-159) → + if `disposition is Apply`, builds a `RuntimeTeleportDestination` from the + wire position and calls `_runtime.TransitOwner.OfferTeleportDestination(destination, + timestamps.TeleportAdvanced)` (161-184, **every accepted local Position is + offered to the transit owner as a POTENTIAL portal destination — the + transit owner itself decides whether it's actually a portal**) → looks up + the active record and calls `_worldProjection?.ProjectPosition(record, + isLocalPlayer: true, disposition)` (185-193) → `HeadlessSessionWorldProjection.ProjectPosition` + (515-531): no-op for non-local; if no controller yet, `SynchronizeLocalPlayer` + (525); **else only for `disposition is ForcePosition` does it call + `BlipLocalPlayer`** (529-530) — an ordinary `Apply` disposition with an + existing controller does NOTHING here (ordinary interpolation must happen + elsewhere, at the physics tick) → back in the session controller, if + `disposition is ForcePosition`, `_localPlayerOutbound.SendImmediatePosition(_session, + _runtime.MovementOwner.Controller)` (194-199, **sends the outbound wire ack + BEFORE any Runtime placement commit/host-acknowledgement — the headless + twin of route 2's "direct BlipPosition/pre-commit acknowledgement"**) → + `TryCompletePortal()` (200, 243-332) unconditionally at the end. +3. `OnPickedUp`/`OnMotionUpdated`/`OnVectorUpdated`/`OnStateUpdated`/`OnParentUpdated`/`OnAppearanceUpdated` + (118-122, 124-135, 203-207, 209-214, 216-220, 237-241) — thin 1-line + pass-throughs to `Entities.TryApply*(..., acknowledgeProjection: null, out _)`, + no independent headless placement work. +4. `OnDeleted` (97-116) — `Entities.TryAcceptDelete(...)` → + `Entities.CompleteAcceptedDelete(acceptance)` → `Entities.RetireCanonicalOnly(retired)`. + No independent headless placement work (matches route 7's claim that + pickup/parent/delete are already mostly Runtime-owned — see route 7's + section for the App-side confirmation). +5. `OnTeleportStarted` (222-235) → `transit.TryQueueTeleportStart` → + `_worldProjection?.BeginTeleport()` → `HeadlessSessionWorldProjection.BeginTeleport` + (533-537): sets `controller.State = PlayerState.PortalSpace` directly (no + Runtime commit gate) → `transit.ActivateQueuedTeleport()` → `TryCompletePortal()`. +6. `TryCompletePortal` (243-332) — a **fully Runtime-driven, already-cutover + state machine** for the portal handshake itself: `TryGetAcceptedTeleportDestination` + → `TryBeginPortalReveal` → `TryRegisterHostProjection` → `Acknowledge(...ProjectionRegistered)` + → `_worldProjection?.PrepareDestination(generation, destination)` (271-274, + **the ONE point where the headless host does its own destination-prep + work** — see below) → `transit.AcknowledgeDestinationReadiness(readiness)` + → `transit.AcknowledgePortalMaterialized(...)` → + `Acknowledge(...SimulationReleaseProjected)` → + `transit.RequireDestinationReservationRelease(projection)` → + `Acknowledge(...DestinationReservationReleased)` → + `transit.AcknowledgeWorldViewportVisible` + `transit.Complete(generation)` → + `Acknowledge(...TerminalProjected)` → `_session.SendGameAction(GameActionLoginComplete.Build())` + → `transit.EndTeleport()`. **This entire sequence is already Runtime-owned + generation/token bookkeeping (J6.2-J6.4 landed) — it is NOT a route-8 + duplicate authority; it's the shared portal-lifecycle plumbing routes 3 + and 8 both sit on top of.** + +### Duplicate authorities — headless (what the residence+executor owner must take over) + +- **`HeadlessSessionWorldProjection.SynchronizeLocalPlayer`** (566-615, read + in full): `_collision.CenterOn(position.LandblockId)` (575) → + `CreateController(record)` if none exists (577-578, 639-655 — constructs + `PlayerMovementController` directly, see prerequisite-C section above) → + `_runtime.EntityObjects.Physics.Engine.Resolve(wirePosition, position.LandblockId, + Vector3.Zero, 100f)` (589-594) → `.ResolvePlacement(resolved.Position, + resolved.CellId, DefaultRadius, DefaultHeight, controller.StepUpHeight, + controller.StepDownHeight, ObjectInfoState.IsPlayer | EdgeSlide, + record.LocalEntityId ?? 0u)` (595-605) → `controller.SetPosition(resolved.Position, + resolved.CellId, wirePosition)` + `controller.SetBodyOrientation(orientation)` + (610-614). **This is a COMPLETE independent physics resolve + placement + commit, entirely bypassing `RuntimeSetPositionState`.** It is the headless + mirror of route-1's "Headless performs its own initial resolve/placement/body + construction" callout in the campaign handoff. +- **`HeadlessSessionWorldProjection.BlipLocalPlayer`** (617-637): direct + `controller.BlipPosition(wirePosition, position.LandblockId, wirePosition)` + (633-636) with zero Runtime commit/ack gating — this is exactly the + "direct Blip" duplicate the campaign handoff names for route 8 ("Delete the + independent resolve/placement/direct SetPosition and Blip logic in + `HeadlessSessionWorldProjection`"). + session controller's `_localPlayerOutbound.SendImmediatePosition(...)` at + `RuntimeLiveEntitySessionController.cs:196-198` fires on the SAME + `ForcePosition` branch, before any placement receipt exists — pre-commit ack. +- **`HeadlessSessionWorldProjection.PrepareDestination`** (539-564): calls + `_collision.CenterOn(destination.CellId)` (543) then, if the destination + entity is active, `SynchronizeLocalPlayer(record)` again (544-549) — i.e. + portal arrival re-runs the SAME duplicate resolve/placement authority. + Also directly sets `controller.State = PlayerState.InWorld` (550-551) + outside any Runtime placement gate. +- **`HeadlessCollisionNeighborhood`** (168-469): NOT a placement-authority + duplicate per se (it manages per-landblock collision generation + publication/retirement, a different concern from entity placement) but it + IS the thing `RuntimeSetPositionState`/`RuntimeInitialCreateResidenceState` + must be able to wait on (its `IsReady`/`CenterOn` gate the destination + readiness the residence lease's placement depends on) — this file already + looks like a reasonable candidate for the "exact authored mover preparation" + (prerequisite B) collision-readiness signal, not something to delete. + +### What each headless hop must become post-cutover + +- `OnSpawned` → keep the legacy timestamp-gate call semantics but swap + `Entities.RegisterEntity(spawn)` for `Entities.RegisterEntityWithInitialResidence(spawn, ...)` + (the wrapper already exists per the surface map, just unused) so headless + enters the SAME residence lease as graphical; `ProjectSpawn` becomes + acknowledge-only: subscribe an `IRuntimePlacementObserver` (does not exist + yet — see observer-seam section) and, on a `Place` receipt for this + entity's token, set `controller.SetPosition`/`SetBodyOrientation` from the + IMMUTABLE `RuntimePlacementProjectionSnapshot` fields only. DELETE + `SynchronizeLocalPlayer`'s direct `Physics.Engine.Resolve`/`ResolvePlacement` + calls (589-605) outright — the sweep must happen exactly once, inside + `RuntimeSetPositionState`, driven by the residence lease. +- `OnPositionUpdated`'s ForcePosition branch → DELETE the direct + `BlipLocalPlayer`/`controller.BlipPosition` call (633-636) and the + immediate `SendImmediatePosition` pre-commit ack (196-198 in the session + controller); replace with: accept timestamp only (still call + `TryApplyPosition`/`TryAcceptDeferredPosition` for the timestamp-gate + side-effects already established) → begin the Runtime placement (route 2's + "LocalAuthoritative" route kind) → wait for the `Place` receipt via the new + headless `IRuntimePlacementObserver` → apply position from the receipt → + THEN send the single outbound ack. +- `PrepareDestination` → keep the collision-neighborhood prep (`_collision.CenterOn`/`IsReady`) + as the readiness signal Runtime's portal-placement authority polls, but + DELETE the second `SynchronizeLocalPlayer` call (548) — arrival placement + must come from the SAME Runtime portal-placement commit route 3 defines, + not a second independent resolve. +- `CreateController` → becomes the headless half of prerequisite C's atomic + controller/body publication transaction; must stop being callable + ad hoc from three different places (`SynchronizeLocalPlayer` twice via + `OnSpawned`+`PrepareDestination`, indirectly via `OnPositionUpdated`) and + instead run exactly once, gated by the same atomic commit graphical + route 1 uses. + +### Route-8-specific capability gap + +The residence+executor mechanism (per +`docs/research/2026-08-02-runtime-continuation-executor-handoff.md`, +"What the executor owns") is host-agnostic by design — nothing in its +API is graphical-only. The concrete gap for route 8 is therefore NOT in the +executor; it is that **no headless `IRuntimePlacementObserver` implementation +exists to consume `GameRuntime.Placements` receipts at all** (see +observer-seam section above — zero hits in `src/AcDream.Headless/`), so +today headless has no mechanism to learn "the Runtime commit landed, project +it" even in principle. Headless also has no analog to a "camera" but DOES +have the same controller/body atomicity requirement as graphical +(prerequisite C applies equally to both hosts — confirmed by the very +existence of `HeadlessSessionWorldProjection.CreateController` as a second, +independent constructor of `PlayerMovementController` next to +`PlayerModeController.BuildControllerAndCamera`). + +--- + +(routes 1-7 sections below filled in as each parallel research agent reports) From 67f63e85e54d5ca1f2f02253753174da953967e7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 05:22:37 +0200 Subject: [PATCH 50/73] feat(runtime): bridge executor completion to the placement stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cutover slice C0 (docs/plans/2026-08-02-placement-cutover.md): the seam work that lets C3 flip hosts onto a complete receipt stream instead of growing one mid-cutover. The executor's Released exit now publishes an acknowledge-only ExecutorCompleted receipt through the one placement projection stream — registered before observer dispatch, correlated to the full execution receipt, reaped exactly once on acknowledgement/ discard/session-clear, and counted in the convergence ledger. All three production placement sinks acknowledge-and-ignore the new kind via early returns proven behavior-preserving for every existing kind; without them the first such receipt at cutover would permanently wedge the exact-head FIFO behind sinks that return false. Provably inert today: the publisher has no production caller. Execute's live inputs now derive from Runtime's own owners bound at GameRuntime construction: UsePositionFromServer is retail's exact autonomy_level != 2 (CommandInterpreter::UsePositionFromServer 0x006B3B40, startup-only knob), and PlayerDistance uses the live movement controller's position with a null-safe fallback to the caller struct — never a fabricated origin. TryPrepareAndSubmitAuthoredPlacement chains the prepared-collision Setup read through PrepareMover to submission with zero validation-semantics changes. TryCommitParent and CommitWithdrawal gain the sibling cancellation flow (residence + ordinary placement family); TryCommitParent deliberately omits LeaveWorld — retail's set_parent performs its single gated leave_world (0x00515A90) and a second would have no counterpart. Not fully dormant: the two cancellation fixes change Runtime paths production already calls (today as no-op-adjacent hardening, since nothing upstream begins a residence yet); everything else is reachable only by tests. Reviewed: retail-conformance PASS + architecture/ adversarial PASS after one fix round (sink wedge, completion-receipt lifecycle, null-controller distance). Runtime 921/921; complete Release solution 10,716 passed / 4 intentional skips. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/World/LiveEntityRuntime.cs | 13 + .../World/RuntimePlacementPresentationSink.cs | 15 +- .../HeadlessRuntimePlacementProjectionSink.cs | 18 + .../Entities/RuntimeEntityObjectLifetime.cs | 97 +++- ...untimeInitialCreateContinuationExecutor.cs | 213 +++++++- src/AcDream.Runtime/GameRuntime.cs | 17 + .../Gameplay/RuntimeCharacterState.cs | 61 ++- .../Physics/RuntimeSetPositionState.cs | 199 +++++++- .../RuntimePlacementPresentationSinkTests.cs | 38 ++ .../HeadlessSessionHostTests.cs | 50 ++ ...eInitialCreateContinuationExecutorTests.cs | 482 ++++++++++++++++++ ...RuntimeInitialCreateResidenceStateTests.cs | 76 +++ .../Gameplay/RuntimeCharacterStateTests.cs | 45 ++ .../Physics/RuntimeSetPositionStateTests.cs | 326 ++++++++++++ 14 files changed, 1639 insertions(+), 11 deletions(-) diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index e4080dee..9c818d22 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -946,6 +946,19 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return true; } + if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted) + { + // F1: acknowledge-and-ignore, same as Discard. Must NOT fall + // through to the record-lookup/spatial-load gates below - those + // legitimately reject for reasons unrelated to this receipt (no + // sidecar yet, destination backend not loaded), and a false + // return here wedges the whole ordered placement stream at the + // FIFO head (RuntimePlacementProjectionSubscription's contract). + // Provably inert today: PublishExecutorCompletion has zero + // production callers. + return true; + } + RuntimePlacementProjectionToken token = projection.Token; if (!TryGetRuntimePlacementProjectionRecord( token, diff --git a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs index 79e23e9c..9f062087 100644 --- a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs +++ b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs @@ -76,8 +76,21 @@ internal sealed class RuntimePlacementPresentationSink if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection)) return false; - if (projection.Kind is RuntimePlacementProjectionKind.Discard) + if (projection.Kind is RuntimePlacementProjectionKind.Discard + or RuntimePlacementProjectionKind.ExecutorCompleted) + { + // F1: ExecutorCompleted is acknowledge-and-ignore like Discard - + // no world/presentation mutation by definition. Must NOT fall + // through to the record-lookup gate below (that gate legitimately + // rejects for OTHER reasons, and this sink's caller + // (RuntimePlacementProjectionSubscription) treats a false return + // as "leave at the FIFO head" - a rejected ExecutorCompleted + // would permanently wedge the whole ordered stream). Provably + // inert today: PublishExecutorCompletion has zero production + // callers - see + // RuntimePlacementPresentationSinkTests.ExecutorCompleted_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone. return true; + } if (!_liveEntities.TryGetRecord( projection.Token.Entity, out LiveEntityRecord record) diff --git a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs index 51f3d156..3e325cdc 100644 --- a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs +++ b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs @@ -31,6 +31,24 @@ internal sealed class HeadlessRuntimePlacementProjectionSink return true; } + if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted) + { + // F1: acknowledge-and-ignore, same as Discard - ExecutorCompleted + // is not a placement to project (no world/presentation mutation + // by definition; the executor's own drain already committed + // every Place/Withdraw this receipt follows). It must NOT fall + // through to the record-lookup gate below: that gate can validly + // reject an unrelated entity/session mismatch, and this sink's + // caller (RuntimePlacementProjectionSubscription) treats a false + // return as "leave at the FIFO head" - a rejected ExecutorCompleted + // would permanently wedge the entire ordered placement stream + // behind it. Currently provably inert: PublishExecutorCompletion + // has zero production callers (Execute/RegisterEntityWithInitialResidence + // are both unreached in production) - see + // HeadlessSessionHostTests.ExecutorCompletedReceiptIsAcknowledgeOnlyRegardlessOfRecordValidity. + return true; + } + RuntimePlacementProjectionToken token = projection.Token; RuntimeEntityDirectory directory = _runtime.EntityObjects.Entities; if (!token.IsValid diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index a98e8c4b..2571d719 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Numerics; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; @@ -44,7 +45,18 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( int DeferredAcceptedRelationCount = 0, /// Round 5 R5-3: mirrors StreamDispatchFailureCount/HasLastStreamDispatchFailure for the executor's contained-replay failure surface. Diagnostic only - like its stream precedent, NOT gated by . long ReplayFailureCount = 0, - bool HasLastReplayFailure = false) + bool HasLastReplayFailure = false, + /// + /// F2: outstanding + /// token-to-receipt correlation entries - one per completed drain whose + /// ExecutorCompleted receipt a host has not yet acknowledged. Gated by + /// , mirroring + /// 's + /// existing "unacknowledged receipt is outstanding debt" shape for the + /// SAME underlying receipt stream - unlike ReplayFailureCount above, + /// this is NOT a diagnostic-only counter. + /// + int PendingCompletionReceiptCount = 0) { public bool IsConverged => IsDisposed @@ -65,6 +77,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && PendingMoveCount == 0 && InitialCreateResidenceLeaseCount == 0 && InitialCreateExecutorProgressCount == 0 + && PendingCompletionReceiptCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -152,6 +165,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // residence state referencing the executor type directly. InitialCreateResidences.BindRetirementNotification( key => InitialCreateExecution.DiscardProgress(key)); + // F2: reaps the executor's completion-receipt correlation entry + // exactly when a host acknowledges the ExecutorCompleted receipt it + // correlates - mirrors the residence-retirement binding immediately + // above. + Physics.SetPosition.BindExecutorCompletionAcknowledgement( + (key, sequence) => + InitialCreateExecution.ForgetCompletionReceipt(key, sequence)); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -197,6 +217,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // residence state referencing the executor type directly. InitialCreateResidences.BindRetirementNotification( key => InitialCreateExecution.DiscardProgress(key)); + // F2: reaps the executor's completion-receipt correlation entry + // exactly when a host acknowledges the ExecutorCompleted receipt it + // correlates - mirrors the residence-retirement binding immediately + // above. + Physics.SetPosition.BindExecutorCompletionAcknowledgement( + (key, sequence) => + InitialCreateExecution.ForgetCompletionReceipt(key, sequence)); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -242,6 +269,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // residence state referencing the executor type directly. InitialCreateResidences.BindRetirementNotification( key => InitialCreateExecution.DiscardProgress(key)); + // F2: reaps the executor's completion-receipt correlation entry + // exactly when a host acknowledges the ExecutorCompleted receipt it + // correlates - mirrors the residence-retirement binding immediately + // above. + Physics.SetPosition.BindExecutorCompletionAcknowledgement( + (key, sequence) => + InitialCreateExecution.ForgetCompletionReceipt(key, sequence)); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -292,7 +326,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable _disposed, parents.DeferredAcceptedRelationCount, InitialCreateExecution.ReplayFailureCount, - InitialCreateExecution.LastReplayFailure is not null); + InitialCreateExecution.LastReplayFailure is not null, + InitialCreateExecution.PendingCompletionReceiptCount); } public void BindEventContext( @@ -306,6 +341,25 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.BindGeneration(generation); } + /// + /// C0-2: forwards to , + /// the same fan-out shape already uses for + /// generation binding. Separate from + /// because GameRuntime constructs RuntimeCharacterState/ + /// RuntimeLocalPlayerMovementState (the real source owners) AFTER + /// this lifetime, so the live-input bind necessarily happens at a later + /// point in GameRuntime's construction sequence than the + /// generation bind. + /// + public void BindLiveInputs( + Func usePositionFromServer, + Func localPlayerPosition) + { + EnsureNotDisposed(); + InitialCreateExecution.BindLiveInputs( + usePositionFromServer, localPlayerPosition); + } + /// /// Owns the presentation-free half of retail's CreateObject lifetime /// transaction. An attached graphical host may synchronously retire the @@ -963,6 +1017,28 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable } Entities.RefreshSnapshot(canonical, accepted); + // C0-4(a): this method had NO cancellation choke-point at all, + // leaving a residence lease or ordinary SetPosition operation + // dangling once ordinary placement traffic goes live - fixed with + // the SAME exactly-once ForgetInitialCreateResidence -> + // Physics.SetPosition.Forget -> PreferCancellation sequence every + // other commit in this family (CommitPositionChannelUpdate, + // TryApplyPickup, CommitAcceptedParentCellless, TryAcceptDelete) + // uses for THIS part of the job. + // F4 (deliberate, NOT an oversight): unlike CommitPositionChannelUpdate, + // this method does NOT also call Physics.CollisionReports.LeaveWorld. + // Retail set_parent (0x00515A90, lines 283832-283833) performs its + // single leave_world call gated behind the SAME add_child branch this + // method's staged/deferred-replay commit represents (App's + // EquippedChildRenderController realize sequence, the executor's own + // ParentRelationReplay) - a second LeaveWorld here would double-leave- + // world with no retail counterpart. + RuntimePlacementCancellationReceipt initialCancellation = + ForgetInitialCreateResidence(canonical); + RuntimePlacementCancellationReceipt ordinaryCancellation = + Physics.SetPosition.Forget(canonical); + RuntimePlacementCancellationReceipt cancellation = + PreferCancellation(initialCancellation, ordinaryCancellation); Entities.AdvanceParentCommit(canonical); ulong parentCommitVersion = canonical.ParentCommitVersion; return AcknowledgeProjectionAndPublish( @@ -970,7 +1046,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable () => acknowledgeProjection?.Invoke(canonical), RuntimeEntityChange.Updated, () => canonical.ParentCommitVersion - == parentCommitVersion); + == parentCommitVersion, + cancellation); } public bool CommitAcceptedParentCellless( @@ -1407,9 +1484,21 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable if (!Entities.IsCurrent(canonical)) return false; - RuntimePlacementCancellationReceipt cancellation = + // C0-4(b): this cancelled the initial-create residence but never the + // ORDINARY placement family (Physics.SetPosition.Forget), unlike + // TryApplyPickup/CommitAcceptedParentCellless/TryAcceptDelete, which + // all cancel both. WithdrawLiveEntityProjectionToCellless routes + // through here, so a live ordinary SetPosition/lost-cell watch could + // dangle across a withdrawal-to-cellless once ordinary placement + // traffic goes live. Fixed symmetrically with the same + // Forget/PreferCancellation pair every sibling withdrawal uses. + RuntimePlacementCancellationReceipt initialCancellation = ForgetInitialCreateResidence(canonical); Physics.CollisionReports.LeaveWorld(canonical); + RuntimePlacementCancellationReceipt ordinaryCancellation = + Physics.SetPosition.Forget(canonical); + RuntimePlacementCancellationReceipt cancellation = + PreferCancellation(initialCancellation, ordinaryCancellation); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); ulong spatialVersion = canonical.SpatialAuthorityVersion; diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs index 9905b538..d545fe78 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs @@ -274,7 +274,26 @@ internal sealed class RuntimeInitialCreateContinuationExecutor _applyAcceptedSpawn; private readonly Dictionary _progress = []; private readonly HashSet _executing = []; + /// + /// C0-1: correlates a published + /// + /// receipt back to the full execution receipt/trace, keyed by the SAME + /// public Entity/Sequence identity every other Kind uses (the receipt's + /// own Token.Entity/Token.Sequence). Overwritten (never + /// accumulated) per entity key - an entity cannot have two drains + /// completing concurrently ('s own + /// reentrancy guard), so only the most recent completion for a key is + /// ever meaningful; the exact-sequence check in + /// rejects a stale lookup against a + /// superseded completion under a reused key. + /// + private readonly Dictionary + _completionReceipts = []; private Func? _generation; + private Func? _usePositionFromServer; + private Func? _localPlayerPosition; + private bool _liveInputsBound; internal RuntimeInitialCreateContinuationExecutor( RuntimeEntityDirectory entities, @@ -308,6 +327,157 @@ internal sealed class RuntimeInitialCreateContinuationExecutor _generation = generation; } + /// + /// C0-2: binds Runtime's own live-input sources so no host ever computes + /// / + /// + /// itself. Optional/nullable exactly like is + /// NOT (that one throws when unbound) - here an unbound source is a + /// legitimate, permanent state for bare-lifetime tests, which keep + /// constructing the executor without a and + /// keep driving with an explicit caller-supplied + /// override (see + /// ). binds the real + /// owners - RuntimeCharacterState.UsePositionFromServer and the + /// live RuntimeLocalPlayerMovementState.Controller position - + /// once both exist (they are constructed AFTER + /// /this executor, so this bind + /// cannot happen at the executor's own constructor time the way + /// does; it happens alongside + /// BindEventContext in GameRuntime's construction + /// sequence). Throws if called twice, matching every other Bind* seam on + /// this class/its siblings (, + /// RuntimeEntityObjectEventStream.BindContext"/>, + /// RuntimePlacementProjectionChannel.BindGeneration). + /// F3: itself returns + /// Vector3?, not Vector3 - a BOUND source with no live + /// controller yet (the login-window drain, before + /// RuntimeLocalPlayerMovementState.Controller exists) must yield + /// null, not a fabricated Vector3.Zero. + /// falls back to the caller-supplied struct's PlayerDistance whenever + /// this source is unbound OR returns null - the SAME fallback rule + /// either way, never a synthetic origin-point distance that could + /// misclassify a remote entity as implausibly far (>96 m) during that + /// window. + /// + internal void BindLiveInputs( + Func usePositionFromServer, + Func localPlayerPosition) + { + ArgumentNullException.ThrowIfNull(usePositionFromServer); + ArgumentNullException.ThrowIfNull(localPlayerPosition); + if (_liveInputsBound) + { + throw new InvalidOperationException( + "The initial-create continuation executor's live-input sources are already bound."); + } + _usePositionFromServer = usePositionFromServer; + _localPlayerPosition = localPlayerPosition; + _liveInputsBound = true; + } + + /// + /// C0-2: resolves the EFFECTIVE inputs for one + /// call. A bound source always wins; the caller-supplied + /// struct is the test-override shape (its own + /// doc comment still describes production usage now that this method + /// exists) and is used verbatim only for whichever field has no bound + /// source - a bare-lifetime test that never calls + /// gets EXACTLY the caller-supplied values, + /// preserving every existing test's behavior unchanged. + /// uses + /// the SAME world-space basis as today's legacy remote path + /// (LiveEntityNetworkUpdateController.cs's + /// MaxPhysicsDistance/dist computation, cutover-routes.md + /// route 4: Vector3.Distance(worldPos, localPlayerPos) where + /// localPlayerPos is the live physics-CONTROLLER position, never a + /// record snapshot) - here, Vector3.Distance between THIS + /// entity's own currently-accepted position (the exact field + /// BeginAcceptedPlacementCore/CanonicalSetupTableId already + /// trust: Snapshot.Physics?.Position ?? Snapshot.Position) and the + /// bound local-player controller position. Computed ONCE per + /// call, matching the one-shot-per-call granularity + /// already had before this slice (retail + /// recomputes player_distance per wire packet; refining this + /// executor to per-continuation freshness is out of C0-2's scope). + /// + private RuntimeInitialCreateExecutionInputs ResolveInputs( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateExecutionInputs inputs) + { + bool usePositionFromServer = _usePositionFromServer is { } source + ? source() + : inputs.UsePositionFromServer; + float playerDistance = inputs.PlayerDistance; + // F3: an unbound source AND a bound-but-null live position (no + // controller yet) both fall back to the caller-supplied struct + // identically - never fabricate Vector3.Zero as a stand-in. + if (_localPlayerPosition?.Invoke() is { } localPlayerPosition + && (canonical.Snapshot.Physics?.Position + ?? canonical.Snapshot.Position) is { } accepted) + { + var target = new Vector3( + accepted.PositionX, accepted.PositionY, accepted.PositionZ); + playerDistance = Vector3.Distance(target, localPlayerPosition); + } + return new RuntimeInitialCreateExecutionInputs( + usePositionFromServer, playerDistance); + } + + /// + /// C0-1: reaches the full execution receipt/trace correlated with an + /// observed + /// receipt, purely via that receipt's own public + /// Token.Entity/Token.Sequence identity - the same identity + /// every other placement Kind is acknowledged by. Returns false for a + /// superseded/stale sequence under a reused entity key. + /// + internal bool TryGetCompletionReceipt( + in RuntimePlacementProjectionToken token, + out RuntimeInitialCreateExecutionReceipt receipt) + { + if (_completionReceipts.TryGetValue( + token.Entity, + out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt) entry) + && entry.Sequence == token.Sequence) + { + receipt = entry.Receipt; + return true; + } + receipt = default; + return false; + } + + /// + /// F2: reaps exactly one completion-receipt correlation entry, bound as + /// 's + /// notification callback - fired the moment a host acknowledges the + /// Kind ExecutorCompleted receipt this entry correlates, never before. + /// The exact-sequence check rejects removing a NEWER completion's entry + /// under a reused key (mirrors 's + /// own currency check). + /// + internal void ForgetCompletionReceipt(RuntimeEntityKey key, ulong sequence) + { + if (_completionReceipts.TryGetValue( + key, + out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt) entry) + && entry.Sequence == sequence) + { + _completionReceipts.Remove(key); + } + } + + /// + /// F2: folded into / + /// IsConverged - an unacknowledged completion receipt is + /// outstanding host debt, mirroring + /// 's + /// existing "must be zero to converge" shape for the SAME underlying + /// receipt stream. + /// + internal int PendingCompletionReceiptCount => _completionReceipts.Count; + internal int ProgressCount => _progress.Count; /// @@ -389,6 +559,15 @@ internal sealed class RuntimeInitialCreateContinuationExecutor /// internal void DiscardProgress(RuntimeEntityKey key) { + // F2: reap this key's completion-receipt correlation entry + // unconditionally - DiscardProgress owns cleanup of every piece of + // state IT introduces, and this cache is exactly that (see + // _completionReceipts's own doc comment). Independent of whether + // _progress still tracks this key: a completed drain has ALREADY + // removed its own Progress entry before this correlation entry was + // ever added (see ExecuteCore's Released case), so this is the + // ONLY choke point that reaps it outside of a normal acknowledge. + _completionReceipts.Remove(key); if (!_progress.Remove(key, out Progress? progress)) return; if (progress.PendingContinuationPlacement.IsValid) @@ -421,6 +600,9 @@ internal sealed class RuntimeInitialCreateContinuationExecutor _physics.SetPosition.PublishCancellation(cancellation); } _progress.Clear(); + // F2: bulk-reap every completion-receipt correlation entry - a full + // session clear must not carry any of this cache across a reset. + _completionReceipts.Clear(); } internal RuntimeInitialCreateExecutionStatus Execute( @@ -458,6 +640,11 @@ internal sealed class RuntimeInitialCreateContinuationExecutor out RuntimeInitialCreateExecutionReceipt receipt) { receipt = default; + // C0-2: resolve ONCE per Execute call - a bound Runtime source always + // wins over the caller-supplied test-override struct (see + // ResolveInputs's own doc comment for the exact fallback rule). + RuntimeInitialCreateExecutionInputs effectiveInputs = + ResolveInputs(canonical, inputs); // An existing Progress for a DIFFERENT (older or ABA-reused) lease // id is discarded here, and THIS exact call fails closed - an old @@ -561,7 +748,7 @@ internal sealed class RuntimeInitialCreateContinuationExecutor return Abandon(canonical, key); RuntimeInitialCreateExecutionStatus applyStatus = - ApplyContinuation(canonical, token, key, continuation, inputs, progress); + ApplyContinuation(canonical, token, key, continuation, effectiveInputs, progress); // Round 3 B2: every apply method below now rebaselines // itself immediately after its own canonical mutation and // BEFORE its own publish (mutate -> rebaseline -> publish), @@ -592,14 +779,36 @@ internal sealed class RuntimeInitialCreateContinuationExecutor switch (release) { case RuntimeInitialCreateResidenceExecutorReleaseStatus.Released: - receipt = new RuntimeInitialCreateExecutionReceipt( + { + var completedReceipt = new RuntimeInitialCreateExecutionReceipt( key, residenceReceipt.FullCellId, residenceReceipt.TeleportHookPhase, progress.Trace.ToImmutable(), progress.ReplayedDeferredChildCount); + receipt = completedReceipt; _progress.Remove(key); + // C0-1: bridge the executor's own completion onto the + // SAME ordered placement receipt stream every + // Place/Withdraw/Discard uses (canonical is still + // current here - nothing between the last continuation + // apply and ConsumeExecuted's Released outcome mutates + // it). Correlate the full trace via the fresh token's + // Entity/Sequence identity - see TryGetCompletionReceipt. + // F2: registration happens INSIDE PublishExecutorCompletion's + // beforePublish callback (before the synchronous observer + // dispatch), not after this call returns - a subscriber + // reading the correlation back from inside its own + // OnPlacement callback must already find it. receipt is + // copied to a local (completedReceipt) because an `out` + // parameter cannot be captured by a lambda. + _physics.SetPosition.PublishExecutorCompletion( + canonical, + beforePublish: token => + _completionReceipts[key] = + (token.Sequence, completedReceipt)); return RuntimeInitialCreateExecutionStatus.Completed; + } case RuntimeInitialCreateResidenceExecutorReleaseStatus.Revised: // A new continuation arrived mid-drain (Enqueue bumps the // completed entry's Adoption.Revision in place). Re-fetch diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 08898a69..44a5bf5f 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; @@ -267,6 +268,22 @@ public sealed class GameRuntime () => generationReset.ActiveRetiringGeneration ?? context.Session.Generation, () => clock.FrameNumber); + // C0-2: bind the executor's live-input sources to the real + // Runtime owners now that both exist (RuntimeCharacterState at + // CharacterCreated, RuntimeLocalPlayerMovementState at + // MovementCreated - both after EntityObjectsCreated, so this + // cannot move earlier). UsePositionFromServer mirrors retail + // CommandInterpreter::UsePositionFromServer exactly; PlayerDistance + // is derived per Execute call from the live physics-controller + // position, matching the legacy remote path's own distance basis + // (LiveEntityNetworkUpdateController's MaxPhysicsDistance/dist). + // F3: the position source is nullable - a null Controller (the + // login-window drain, before the local player's own controller + // exists yet) must yield null, never a fabricated Vector3.Zero + // that would misclassify every remote entity as implausibly far. + context.EntityObjects.BindLiveInputs( + () => context.Character.UsePositionFromServer, + () => context.Movement.Controller?.Position); context.Events = new GameRuntimeEventHub( context.EntityObjects, context.Communication, diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index f9acaf5a..587d1f92 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -18,7 +18,9 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot( int PositionCount, int PropertyCount, bool OptionsAreDefaults, - bool MovementSkillsAreReset) + bool MovementSkillsAreReset, + /// C0-2: is back at retail's default (). + bool AutonomyIsDefault = true) { public bool IsConverged => IsDisposed @@ -33,7 +35,8 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot( && PositionCount == 0 && PropertyCount == 0 && OptionsAreDefaults - && MovementSkillsAreReset; + && MovementSkillsAreReset + && AutonomyIsDefault; } /// @@ -47,11 +50,27 @@ public sealed class RuntimeCharacterState : IDisposable public const uint RunSkillId = 24u; /// ACE Skill enum ordinal for Jump (K-fix7 / pseudocode doc §5). public const uint JumpSkillId = 22u; + /// + /// C0-2/F5(b): retail CommandInterpreter's own default, set at + /// construction (pseudo-C 699752, this->autonomy_level = 2;, a + /// direct field write, not a SetAutonomyLevel call) and by the + /// command-line-only override at admission + /// (command_line_autonomy_level, pseudo-C 1088429, itself + /// defaulting to 0x2). Exactly ONE retail caller of + /// CommandInterpreter::SetAutonomyLevel exists in the named + /// retail decomp - the startup construction path at pseudo-C 94102 + /// (cmdinterp->vtable->SetAutonomyLevel(cmdinterp, command_line_autonomy_level)) + /// - it is a startup/debug knob, not a per-play-session gameplay + /// toggle, so acdream's own default matches retail's value exactly and + /// nothing in ordinary play ever changes it. + /// + public const uint FullAutonomyLevel = 2u; private bool _disposed; private long _characterRevision; private long _spellbookRevision; private bool _internalSubscriptionsAttached; + private uint _autonomyLevel = FullAutonomyLevel; /// /// Campaign P Slice P1 (2026-07-30): the pre-EnchantSkill base @@ -88,6 +107,39 @@ public sealed class RuntimeCharacterState : IDisposable public IRuntimeCharacterView View { get; } public bool IsDisposed => _disposed; + /// Retail CommandInterpreter::GetAutonomyLevel. + public uint AutonomyLevel => Volatile.Read(ref _autonomyLevel); + + /// + /// C0-2: retail CommandInterpreter::UsePositionFromServer + /// (pseudo-C 699506-699512: result = this->autonomy_level != 2;). + /// This is the source + /// + /// binds for RuntimeInitialCreateExecutionInputs.UsePositionFromServer + /// - the local-player-only interpolate gate consumed by + /// RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition. + /// + public bool UsePositionFromServer => AutonomyLevel != FullAutonomyLevel; + + /// + /// Retail CommandInterpreter::SetAutonomyLevel (pseudo-C + /// 699542-699552): rejects any value above 2, otherwise commits. + /// F5(a): retail's own setter ALSO sends SendAutonomyLevelEvent + /// (pseudo-C 699550) after committing - this Runtime-only port has no + /// outbound wire concept to carry that event today (autonomy level has + /// no host caller yet). Any FUTURE host exposure of this setter (e.g. a + /// debug/admin command) MUST also send the equivalent outbound event - + /// do not port only the field write. + /// + public bool TrySetAutonomyLevel(uint level) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (level > FullAutonomyLevel) + return false; + Volatile.Write(ref _autonomyLevel, level); + return true; + } + public RuntimeCharacterOwnershipSnapshot CaptureOwnership() { int favoriteCount = 0; @@ -144,7 +196,8 @@ public sealed class RuntimeCharacterState : IDisposable && MovementSkills.LastPkAttackTimestamp is null && _runSkillBase == -1 && _jumpSkillBase == -1 - && _movementSkillAugmentations == default); + && _movementSkillAugmentations == default, + AutonomyLevel == FullAutonomyLevel); } /// @@ -355,6 +408,7 @@ public sealed class RuntimeCharacterState : IDisposable _runSkillBase = -1; _jumpSkillBase = -1; _movementSkillAugmentations = default; + Volatile.Write(ref _autonomyLevel, FullAutonomyLevel); Try(MovementSkills.ResetSession, ref failures); if (failures is not null) { @@ -380,6 +434,7 @@ public sealed class RuntimeCharacterState : IDisposable _runSkillBase = -1; _jumpSkillBase = -1; _movementSkillAugmentations = default; + Volatile.Write(ref _autonomyLevel, FullAutonomyLevel); Try(MovementSkills.ResetSession, ref failures); } finally diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 4f6dc5bb..68efb27f 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Numerics; +using AcDream.Content; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -59,6 +60,17 @@ public enum RuntimePlacementProjectionKind Withdraw, Place, Discard, + /// + /// C0-1: the initial-create continuation executor's own FIFO drain has + /// finished for this entity (not itself a SetPosition operation - the + /// residence lease is already released by the time this publishes). + /// Published on the SAME ordered stream every Place/Withdraw/Discard + /// receipt uses so a host learns "this entity's placement committed and + /// its FIFO drained" through the one already-built observer seam, + /// instead of a second stream/queue. Acknowledge-only, like Discard - + /// see AcknowledgeProjection's dedicated branch. + /// + ExecutorCompleted, } public readonly record struct RuntimePortalPlacementAuthority( @@ -432,6 +444,7 @@ internal sealed class RuntimeSetPositionState : IDisposable private ulong _nextCollisionPrefixQuiescenceOperationId; private ulong _nextLostDeadlineSequence; private RuntimeEntityObjectEventStream? _events; + private Action? _executorCompletionAcknowledged; private bool _disposed; private readonly record struct LostDeadlineEntry( @@ -770,6 +783,87 @@ internal sealed class RuntimeSetPositionState : IDisposable } } + /// + /// C0-1: publishes the initial-create continuation executor's own + /// completion (its FIFO drain has finished and its residence lease is + /// already released) on the SAME ordered receipt stream every + /// Place/Withdraw/Discard uses - the pinned contract forbids a second + /// stream/queue. This is NOT an Operation-backed receipt (the executor's + /// own placement operation, if any, already committed and was + /// acknowledged earlier in the drain - see + /// RuntimeInitialCreateContinuationExecutor.ExecuteCore's call site) so + /// the token is built the same way PublishProjection assembles one, but + /// from the CANONICAL record's current authority/version/cell facts + /// (self-consistent: nothing else can move them synchronously between + /// the executor's release and this publish) instead of a now-gone + /// Operation. AcknowledgeProjection's dedicated ExecutorCompleted branch + /// treats it exactly like Discard - acknowledge-only, no operation to + /// resume or commit against. + /// + internal RuntimePlacementProjectionToken PublishExecutorCompletion( + RuntimeEntityRecord record, + Action? beforePublish = null, + RuntimePortalPlacementAuthority portal = default) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key) + return default; + + PhysicsBody? body = record.PhysicsBody; + ulong sequence = checked(++_nextProjectionSequence); + var token = new RuntimePlacementProjectionToken( + sequence, + Revision: 1UL, + key, + record.PositionAuthorityVersion, + record.SpatialAuthorityVersion, + record.PlacementCommitVersion, + _entities.SessionLifetimeVersion, + record.FullCellId, + _physics.ExpectedCollisionGeneration(record.FullCellId), + portal); + var snapshot = new RuntimePlacementProjectionSnapshot( + token, + RuntimePlacementProjectionKind.ExecutorCompleted, + body?.Position ?? Vector3.Zero, + body?.Orientation ?? Quaternion.Identity, + body?.CellPosition.Frame.Origin ?? Vector3.Zero, + body?.InContact ?? false, + body?.OnWalkable ?? false); + _pendingProjection.Add(sequence, snapshot); + // F2: register-before-publish - beforePublish runs while the token is + // already in _pendingProjection but before PublishPlacement's + // synchronous observer dispatch, so a subscriber reading back the + // executor's correlation entry from inside its OWN OnPlacement + // callback always finds it. + beforePublish?.Invoke(token); + PublishPlacement(snapshot); + return token; + } + + /// + /// F2: binds the ONE notification fired when a Kind ExecutorCompleted + /// receipt is acknowledged (mirrors + /// RuntimeInitialCreateResidenceState.BindRetirementNotification's + /// existing one-bound-delegate shape). The executor uses this to reap + /// its own token-to-receipt correlation entry exactly when the receipt + /// it correlates is consumed - never before (a host might still be + /// mid-retry) and never left dangling after (unbounded per-completed- + /// entity retention). + /// + internal void BindExecutorCompletionAcknowledgement( + Action acknowledged) + { + ArgumentNullException.ThrowIfNull(acknowledged); + if (_executorCompletionAcknowledged is not null) + { + throw new InvalidOperationException( + "The executor-completion acknowledgement notification is already bound."); + } + _executorCompletionAcknowledged = acknowledged; + } + internal void ResetSession() { EnsureNotDisposed(); @@ -1132,6 +1226,95 @@ internal sealed class RuntimeSetPositionState : IDisposable return RuntimeSetPositionMoverPreparationStatus.Prepared; } + /// + /// C0-3: chains the exact-Setup mover pipeline end-to-end for an + /// authored placement (initial-Create or any other authored-mover + /// operation) whose route performs SetPosition - PrepareMover / + /// RuntimeSetPositionMoverPreparer.TryBuild / + /// IPreparedCollisionSource.ReadSetupCollision already exist piecewise + /// (inventory gap a); this is the missing wiring, not a behavior change. + /// Reads the CANONICAL Setup table id from the record via + /// - the SAME field + /// already trusts - rather than + /// a caller-supplied id, so this can never be pointed at the wrong + /// Setup. A record with no authored Setup at all (id 0) takes retail's + /// genuine "no Setup" dummy-sphere path + /// () instead of + /// reading anything; a record WITH an id but an unavailable/corrupt + /// asynchronous read yields + /// so the caller retries once the prepared-collision package lands, + /// mirroring 's own + /// doc-comment distinction between "not arrived yet" and "resolved + /// absent". Dormant: internal, no production caller - a residence + /// lease's own Placement/Route.OperationKind/ + /// Route.SetPositionFlags are exactly the token/kind/flags this + /// takes. + /// + internal RuntimeSetPositionMoverPreparationStatus + TryPrepareAndSubmitAuthoredPlacement( + RuntimeEntityRecord record, + in RuntimeEntityPlacementToken token, + RuntimeSetPositionOperationKind operationKind, + PhysicsSetPositionFlags flags, + IPreparedCollisionSource collisionSource, + double gameTime, + out RuntimeSetPositionOutcome outcome, + PhysicsPlacementClass placementClass = PhysicsPlacementClass.Ordinary, + RuntimePortalPlacementAuthority portal = default, + Vector3 line = default, + float scatterRadiusX = 0f, + float scatterRadiusY = 0f, + uint scatterAttempts = 0u, + float shadowWorldOffsetX = 0f, + float shadowWorldOffsetY = 0f) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(collisionSource); + outcome = default; + + uint setupTableId = CanonicalSetupTableId(record); + RuntimeSetPositionMoverSetup setup; + if (setupTableId == 0u) + { + setup = RuntimeSetPositionMoverSetup.ResolvedAbsent; + } + else + { + PreparedCollisionReadResult read = + collisionSource.ReadSetupCollision(setupTableId); + if (read.Status != PreparedAssetReadStatus.Loaded + || read.Data is null) + { + return RuntimeSetPositionMoverPreparationStatus + .RetrySetupUnavailable; + } + setup = RuntimeSetPositionMoverSetup.Resolved( + setupTableId, read.Data); + } + + var preparation = new RuntimeSetPositionMoverPreparation( + setup, + operationKind, + gameTime, + placementClass, + flags, + line, + scatterRadiusX, + scatterRadiusY, + scatterAttempts, + shadowWorldOffsetX, + shadowWorldOffsetY, + portal); + RuntimeSetPositionMoverPreparationStatus status = PrepareMover( + token, preparation, out RuntimeSetPositionCommand command); + if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) + return status; + + outcome = SubmitPreparedPlacement(token, command); + return RuntimeSetPositionMoverPreparationStatus.Prepared; + } + internal bool IsExactPreparedPlacementCurrent( RuntimeEntityRecord record, in RuntimeEntityPlacementToken token, @@ -2313,10 +2496,24 @@ internal sealed class RuntimeSetPositionState : IDisposable { return false; } - if (pending.Kind is RuntimePlacementProjectionKind.Discard) + if (pending.Kind is RuntimePlacementProjectionKind.Discard + or RuntimePlacementProjectionKind.ExecutorCompleted) { + // C0-1: an ExecutorCompleted receipt is never Operation-backed + // (see PublishExecutorCompletion) - there is nothing to resume or + // commit against, exactly like Discard. _pendingProjection.Remove(token.Sequence); RetireQuiescenceProjectionSequence(token.Sequence); + if (pending.Kind is RuntimePlacementProjectionKind.ExecutorCompleted) + { + // F2: notify the executor so it can reap its own + // token-to-receipt correlation entry now - not before (a + // host might still be mid-retry against this exact + // unacknowledged receipt) and not left dangling after. + _executorCompletionAcknowledged?.Invoke( + token.Entity, + token.Sequence); + } return true; } if (!_operations.TryGetValue( diff --git a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs index 7c6e252f..1d293203 100644 --- a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs @@ -155,6 +155,44 @@ public sealed class RuntimePlacementPresentationSinkTests Assert.Equal(priorVisible, record.IsSpatiallyVisible); } + [Fact] + public void ExecutorCompleted_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone() + { + // F1: mirrors Discard_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone + // exactly - proves ExecutorCompleted is acknowledged unconditionally + // (never wedges the FIFO on a record-lookup failure) and never + // mutates presentation state, even under a completely bogus token. + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + WorldEntity entity = record.WorldEntity!; + RuntimePlacementProjectionSnapshot completion = Placement( + fixture, + record, + RuntimePlacementProjectionKind.ExecutorCompleted, + new Vector3(900f), + Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1f)) with + { + Token = Placement(fixture, record, + RuntimePlacementProjectionKind.Place, + Vector3.Zero, + Quaternion.Identity).Token with + { + SessionLifetimeVersion = ulong.MaxValue, + PositionAuthorityVersion = ulong.MaxValue, + ExactCellId = 0xDEAD0001u, + }, + }; + Vector3 priorPosition = entity.Position; + Quaternion priorRotation = entity.Rotation; + bool priorVisible = record.IsSpatiallyVisible; + + Assert.True(fixture.Sink.TryApply(in completion)); + + Assert.Equal(priorPosition, entity.Position); + Assert.Equal(priorRotation, entity.Rotation); + Assert.Equal(priorVisible, record.IsSpatiallyVisible); + } + [Fact] public void Place_RejectsStaleCanonicalVersionsWithoutChangingSidecar() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 131b4b6e..7e4c2886 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -479,6 +479,56 @@ public sealed class HeadlessSessionHostTests Assert.True(projection.TryApply(in discard)); } + [Fact] + public void ExecutorCompletedReceiptIsAcknowledgeOnlyRegardlessOfRecordValidity() + { + // F1: mirrors PlacementReceiptUsesExactIncarnationAndDiscardIsAckOnly's + // stale-token half - proves ExecutorCompleted is acknowledged + // unconditionally (never gated by the record-lookup/portal-shape + // checks Place/Withdraw depend on), so a genuinely stale/mismatched + // token can never wedge the FIFO behind it. + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntity(Spawn(0x50000006u)) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + record, + record.CreateIntegrationVersion, + record.Snapshot, + replaceGeneration: false)); + var sink = new HeadlessRuntimePlacementProjectionSink(runtime); + RuntimePlacementProjectionSnapshot completion = Placement( + runtime, + record, + RuntimePlacementProjectionKind.ExecutorCompleted, + Vector3.One, + Quaternion.Identity); + RuntimePlacementProjectionSnapshot stale = completion with + { + Token = completion.Token with + { + Entity = completion.Token.Entity with + { + Incarnation = unchecked((ushort)( + completion.Token.Entity.Incarnation + 1)), + }, + SessionLifetimeVersion = ulong.MaxValue, + }, + }; + + Assert.True(sink.TryApply(in completion)); + Assert.True(sink.TryApply(in stale)); + } + [Fact] public void SessionEventRouteOwnsOneObserverAndUnsubscribesBeforeNetworkDetach() { diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs index ab1b8a9b..fb81a88c 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs @@ -4223,6 +4223,480 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); } + // --------------------------------------------------------------- + // F. C0-1: executor-completion bridge / C0-2: live-input binding + // --------------------------------------------------------------- + + [Fact] + public void ExecutorCompletion_PublishesOnTheSamePlacementStreamCorrelatedWithTheFullReceipt() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 400UL); + const uint guid = 0x70024000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.SubscribePlacement( + new PlacementObserver(delta => observed.Add(delta.Placement))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + RuntimePlacementProjectionSnapshot completion = Assert.Single(observed); + Assert.Equal( + RuntimePlacementProjectionKind.ExecutorCompleted, + completion.Kind); + Assert.Equal(canonical.Key, completion.Token.Entity); + Assert.True(lifetime.InitialCreateExecution.TryGetCompletionReceipt( + completion.Token, + out RuntimeInitialCreateExecutionReceipt correlated)); + Assert.Equal(receipt, correlated); + + // Acknowledge-only, exact-head, same as every other Kind. + Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + completion.Token)); + Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount); + } + + [Fact] + public void ExecutorCompletion_ObservedOnlyAfterAnyContinuationPlacementInFifoOrder() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 401UL); + const uint guid = 0x70024001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + // A teleport-advanced Position continuation performs its OWN + // authored SetPosition - the continuation placement C0-1's contract + // says already flows through the channel unchanged. + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 1, + forcePositionSequence: 0, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, true, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.SubscribePlacement( + new PlacementObserver(delta => observed.Add(delta.Placement))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [ + RuntimePlacementProjectionKind.Place, + RuntimePlacementProjectionKind.ExecutorCompleted, + ], + observed.Select(static s => s.Kind)); + Assert.Equal(canonical.Key, observed[0].Token.Entity); + Assert.True(lifetime.InitialCreateExecution.TryGetCompletionReceipt( + observed[1].Token, + out RuntimeInitialCreateExecutionReceipt correlated)); + Assert.Equal(receipt, correlated); + // The continuation Place receipt was already acknowledged by + // RunToCompletion's own CompletePendingContinuationPlacement helper + // before Execute ever reached Completed - only the fresh + // ExecutorCompleted receipt is still outstanding. + Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observed[1].Token)); + } + + [Fact] + public void ExecutorCompletion_ReceiptIsReadableFromWithinTheSameSynchronousOnPlacementDispatch() + { + // F2: registration must happen BEFORE PublishPlacement's synchronous + // observer dispatch - a subscriber reading the correlation back from + // inside its OWN OnPlacement callback must already find it, not only + // after RunToCompletion returns. + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 410UL); + const uint guid = 0x70024010u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + RuntimeInitialCreateExecutionReceipt? observedFromInsideDispatch = null; + using IDisposable subscription = lifetime.Events.SubscribePlacement( + new PlacementObserver(delta => + { + if (delta.Placement.Kind + is not RuntimePlacementProjectionKind.ExecutorCompleted) + { + return; + } + Assert.True(lifetime.InitialCreateExecution.TryGetCompletionReceipt( + delta.Placement.Token, + out RuntimeInitialCreateExecutionReceipt receipt)); + observedFromInsideDispatch = receipt; + })); + + RuntimeInitialCreateExecutionReceipt receiptReturned = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.NotNull(observedFromInsideDispatch); + Assert.Equal(receiptReturned, observedFromInsideDispatch!.Value); + } + + [Fact] + public void ExecutorCompletion_ConvergenceLedgerCountsAnUnacknowledgedReceiptAsOutstandingDebtUntilAcknowledged() + { + // F2: PendingCompletionReceiptCount mirrors + // RuntimeSetPositionOwnershipSnapshot.PendingProjectionAcknowledgementCount's + // existing "unacknowledged receipt is outstanding debt" shape for + // the SAME underlying receipt stream - non-zero while unacknowledged, + // reaped to zero exactly on acknowledge (never before, never left + // dangling after). + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 411UL); + const uint guid = 0x70024011u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + Assert.Equal( + 0, + lifetime.CaptureOwnership().PendingCompletionReceiptCount); + + RunToCompletion(lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + 1, + lifetime.CaptureOwnership().PendingCompletionReceiptCount); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot completion)); + Assert.Equal( + RuntimePlacementProjectionKind.ExecutorCompleted, + completion.Kind); + + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + completion.Token)); + + Assert.Equal( + 0, + lifetime.CaptureOwnership().PendingCompletionReceiptCount); + Assert.False(lifetime.InitialCreateExecution.TryGetCompletionReceipt( + completion.Token, + out _)); + } + + [Fact] + public void ExecutorCompletion_CorrelationEntryIsReapedByDiscardProgress() + { + // F2: DiscardProgress (reached via ForgetInitialCreateResidence in + // production) must reap this correlation cache too - it is exactly + // the kind of executor-introduced state that method already owns + // cleaning up. The drain already removed _progress[key] before + // publishing the completion (see ExecuteCore's Released case), so + // this proves DiscardProgress reaps _completionReceipts + // UNCONDITIONALLY, not only when _progress still tracks the key. + // Left deliberately UNACKNOWLEDGED in _pendingProjection (a single- + // entity lifetime, so there is no exact-head contention to worry + // about) - proving DiscardProgress reaps the correlation cache + // independently of the normal acknowledge path. + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 413UL); + const uint guid = 0x70024013u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + RunToCompletion(lifetime, canonical, lease.Token, NoContact); + Assert.Equal( + 1, + lifetime.CaptureOwnership().PendingCompletionReceiptCount); + + lifetime.InitialCreateExecution.DiscardProgress(canonical.Key!.Value); + + Assert.Equal( + 0, + lifetime.CaptureOwnership().PendingCompletionReceiptCount); + } + + [Fact] + public void ExecutorCompletion_CorrelationEntryIsReapedByDiscardAll() + { + // F2: a full session clear (DiscardAll, reached via + // RuntimeInitialCreateResidenceState.Clear's call site) must never + // carry this correlation cache across a reset. + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 414UL); + const uint guid = 0x70024014u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + RunToCompletion(lifetime, canonical, lease.Token, NoContact); + Assert.Equal( + 1, + lifetime.CaptureOwnership().PendingCompletionReceiptCount); + + lifetime.InitialCreateExecution.DiscardAll(); + + Assert.Equal( + 0, + lifetime.CaptureOwnership().PendingCompletionReceiptCount); + } + + [Fact] + public void BindLiveInputs_DrivesClassificationFromTheBoundSourcesInsteadOfTheCallerStruct() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 402UL); + const uint guid = 0x70024002u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, + forcePositionSequence: 0, positionX: 15f, isGrounded: true); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + bool usePositionFromServer = true; + // Position 15 units from a local player parked far away (100 units) + // is irrelevant here (PlayerDistance only matters for the + // Remote/Projectile near/far branch, not LocalPlayer's own + // interpolate gate) - it exists purely to prove the DISTANCE source + // is read at all. + lifetime.InitialCreateExecution.BindLiveInputs( + () => usePositionFromServer, + () => new Vector3(115f, 20f, 7f)); + + // The caller-supplied struct says UsePositionFromServer:false - if + // the bound source is actually driving classification, retail's + // "UsePositionFromServer && wire-contact" local-ordinary gate must + // still interpolate. + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.Interpolate, + positionAction.PositionDisposition); + + // C0-1: Completing the first entity's drain also published its own + // ExecutorCompleted receipt on the SAME exact-head stream - it must + // be acknowledged before a SECOND entity's own placement receipt can + // ever become the head. + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot firstCompletion)); + Assert.Equal( + RuntimePlacementProjectionKind.ExecutorCompleted, + firstCompletion.Kind); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + firstCompletion.Token)); + + // Flip the bound source off and confirm the SAME struct now takes + // the non-interpolating branch - proves it is read live, not cached + // at bind time. + usePositionFromServer = false; + const uint secondGuid = 0x70024003u; + RuntimeEntityRecord second = lifetime + .RegisterEntityWithInitialResidence(Spawn(secondGuid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + second, + out RuntimeInitialCreateResidenceLease secondLease)); + AttachDormantBody(lifetime, second); + CompleteInitialPlacement(lifetime, secondLease); + WorldSession.EntityPositionUpdate secondUpdate = PositionUpdate( + secondGuid, positionSequence: 2, teleportSequence: 0, + forcePositionSequence: 0, positionX: 16f, isGrounded: true); + Assert.True(lifetime.TryApplyPosition( + secondUpdate, isLocalPlayer: true, null, null, false, null, + out PositionTimestampDisposition secondDisposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, secondDisposition); + RuntimeInitialCreateExecutionReceipt secondReceipt = RunToCompletion( + lifetime, second, secondLease.Token, NoContact); + RuntimeInitialCreateExecutedAction secondPositionAction = Assert.Single( + secondReceipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.NoPositionOperation, + secondPositionAction.PositionDisposition); + } + + [Fact] + public void BindLiveInputs_ThrowsOnASecondBindAndUnboundExecutorsUseTheCallerStructUnchanged() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + lifetime.InitialCreateExecution.BindLiveInputs( + static () => true, static () => Vector3.Zero); + Assert.Throws(() => + lifetime.InitialCreateExecution.BindLiveInputs( + static () => false, static () => Vector3.Zero)); + + // A SEPARATE, never-bound lifetime still honors the caller-supplied + // struct verbatim - the existing bare-lifetime test contract is + // unchanged by this slice. + using RuntimeEntityObjectLifetime unbound = EngineLifetime(); + Bind(unbound, 403UL); + const uint guid = 0x70024004u; + RuntimeEntityRecord canonical = unbound + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(unbound.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(unbound, canonical); + CompleteInitialPlacement(unbound, lease); + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, + forcePositionSequence: 0, positionX: 15f, isGrounded: true); + Assert.True(unbound.TryApplyPosition( + update, isLocalPlayer: true, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + unbound, canonical, lease.Token, NoContact); + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + // NoContact (UsePositionFromServer:false) -> not interpolated. + Assert.Equal( + RuntimeAuthoritativePositionDisposition.NoPositionOperation, + positionAction.PositionDisposition); + } + + [Fact] + public void BindLiveInputs_PlayerDistanceIsReadFromANonNullBoundSourceAndFallsBackToTheCallerStructWhenNull() + { + // F3: proves BOTH directions of the nullable local-player-position + // source. Entity 1: the bound source returns a REAL near position - + // the caller struct claims a FAR distance (200f), so if the bound + // source is genuinely read (not ignored), the entity's own near + // distance must win and classify Interpolate. Entity 2: the SAME + // bound source now returns null (e.g. the login-window drain before + // RuntimeLocalPlayerMovementState.Controller exists) - it must fall + // back to the caller struct's FAR distance exactly like an unbound + // source would, never fabricate Vector3.Zero (which would compute a + // small, misleadingly-near distance to the entity's own position and + // wrongly classify Interpolate instead of the far SetPositionSimple + // hard-snap). + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 421UL); + Vector3? boundPosition = new Vector3(30f, 20f, 7f); + lifetime.InitialCreateExecution.BindLiveInputs( + static () => false, + () => boundPosition); + + const uint nearGuid = 0x70024021u; + RuntimeEntityRecord near = lifetime + .RegisterEntityWithInitialResidence(Spawn(nearGuid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + near, out RuntimeInitialCreateResidenceLease nearLease)); + AttachDormantBody(lifetime, near); + CompleteInitialPlacement(lifetime, nearLease); + WorldSession.EntityPositionUpdate nearUpdate = PositionUpdate( + nearGuid, positionSequence: 2, teleportSequence: 0, + forcePositionSequence: 0, positionX: 25f, isGrounded: true); + Assert.True(lifetime.TryApplyPosition( + nearUpdate, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition nearDisposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, nearDisposition); + var farStruct = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 200f); + + RuntimeInitialCreateExecutionReceipt nearReceipt = RunToCompletion( + lifetime, near, nearLease.Token, farStruct); + + RuntimeInitialCreateExecutedAction nearAction = Assert.Single( + nearReceipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.Interpolate, + nearAction.PositionDisposition); + + // C0-1: the completed near entity published its own ExecutorCompleted + // receipt on the SAME exact-head stream - acknowledge it before the + // far entity's own Place receipt can ever become the head. + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot nearCompletion)); + Assert.Equal( + RuntimePlacementProjectionKind.ExecutorCompleted, + nearCompletion.Kind); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + nearCompletion.Token)); + + boundPosition = null; + const uint farGuid = 0x70024022u; + RuntimeEntityRecord far = lifetime + .RegisterEntityWithInitialResidence(Spawn(farGuid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + far, out RuntimeInitialCreateResidenceLease farLease)); + AttachDormantBody(lifetime, far); + CompleteInitialPlacement(lifetime, farLease); + WorldSession.EntityPositionUpdate farUpdate = PositionUpdate( + farGuid, positionSequence: 2, teleportSequence: 0, + forcePositionSequence: 0, positionX: 25f, isGrounded: true); + Assert.True(lifetime.TryApplyPosition( + farUpdate, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition farDisposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, farDisposition); + + RuntimeInitialCreateExecutionStatus farStatus = lifetime + .InitialCreateExecution.Execute( + far, farLease.Token, farStruct, out _); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, + farStatus); + RuntimeEntityKey farKey = far.Key!.Value; + Assert.True(lifetime.InitialCreateExecution.TryGetPendingContinuationRoute( + farKey, out RuntimeAuthoritativePositionRoute farRoute)); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + farRoute.Disposition); + Assert.True(farRoute.StopInterpolating); + } + // --------------------------------------------------------------- // Harness // --------------------------------------------------------------- @@ -4461,4 +4935,12 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests { } } + + private sealed class PlacementObserver( + Action onPlacement) + : IRuntimePlacementObserver + { + public void OnPlacement(in RuntimePlacementDelta delta) => + onPlacement(delta); + } } diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs index 046333a4..57ed44e8 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs @@ -2379,6 +2379,82 @@ public sealed class RuntimeInitialCreateResidenceStateTests Assert.Single(retained.Continuations).Kind); } + // --------------------------------------------------------------- + // C0-4: TryCommitParent/CommitWithdrawal cancellation asymmetries + // --------------------------------------------------------------- + + [Fact] + public void TryCommitParent_CancelsActiveInitialResidenceAndItsPendingPlacement() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + Bind(lifetime, 200UL); + const uint guid = 0x70004001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, setupId: null), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + // Unparented -> the classifier's route performs SetPosition, so + // Own() has already begun the lease's own placement operation. + // Submit it (a live Place receipt now sits unacknowledged) so + // cancellation has an ACTUAL outstanding receipt to discard, not + // merely an unpublished AwaitingPreparation operation. + Assert.True(lease.Placement.IsValid); + AttachDormantBody(lifetime, canonical); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + lease.Placement, + Prepare(lifetime, lease, RuntimeSetPositionMoverSetup.ResolvedAbsent)); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount); + + var discards = new List(); + using IDisposable subscription = lifetime.Events.SubscribePlacement( + new PlacementObserver(delta => + { + if (delta.Placement.Kind is RuntimePlacementProjectionKind.Discard) + discards.Add(delta.Placement); + })); + + var relation = new ParentAttachmentRelation( + ParentGuid: 0x70004100u, + ChildGuid: guid, + ParentLocation: 1u, + PlacementId: 1u, + ParentInstanceSequence: 1, + ChildPositionSequence: 1); + Assert.True(lifetime.TryCommitParent(relation, null, out _)); + + Assert.Equal(0, lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + // The old Place receipt is REPLACED by a Discard at the same + // sequence, not removed outright - still awaiting host ack. + Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount); + Assert.False(lifetime.Physics.SetPosition.IsPlacementCurrent( + lease.Placement)); + RuntimePlacementProjectionSnapshot discard = Assert.Single(discards); + Assert.Equal(canonical.Key, discard.Token.Entity); + Assert.Equal(outcome.Projection.Sequence, discard.Token.Sequence); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + discard.Token)); + Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount); + } + private static RuntimeSetPositionCommand Prepare( RuntimeEntityObjectLifetime lifetime, in RuntimeInitialCreateResidenceLease lease, diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index eee36fca..9179d1e9 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -340,6 +340,51 @@ public sealed class RuntimeCharacterStateTests Assert.False(state.CaptureOwnership().MovementSkillsAreReset); } + // --------------------------------------------------------------- + // C0-2: retail CommandInterpreter::autonomy_level/UsePositionFromServer + // --------------------------------------------------------------- + + [Fact] + public void AutonomyLevel_DefaultsToFullAndMirrorsRetailUsePositionFromServer() + { + using var state = new RuntimeCharacterState(); + + Assert.Equal(RuntimeCharacterState.FullAutonomyLevel, state.AutonomyLevel); + Assert.False(state.UsePositionFromServer); + Assert.True(state.CaptureOwnership().AutonomyIsDefault); + + Assert.True(state.TrySetAutonomyLevel(0u)); + Assert.Equal(0u, state.AutonomyLevel); + Assert.True(state.UsePositionFromServer); + Assert.False(state.CaptureOwnership().AutonomyIsDefault); + + Assert.True(state.TrySetAutonomyLevel(1u)); + Assert.True(state.UsePositionFromServer); + + // Retail's SetAutonomyLevel rejects anything above 2; the level and + // the derived UsePositionFromServer gate stay exactly as they were. + Assert.False(state.TrySetAutonomyLevel(3u)); + Assert.Equal(1u, state.AutonomyLevel); + + Assert.True(state.TrySetAutonomyLevel(RuntimeCharacterState.FullAutonomyLevel)); + Assert.False(state.UsePositionFromServer); + Assert.True(state.CaptureOwnership().AutonomyIsDefault); + } + + [Fact] + public void ResetSession_RestoresAutonomyLevelToFull() + { + using var state = new RuntimeCharacterState(); + Assert.True(state.TrySetAutonomyLevel(0u)); + Assert.True(state.UsePositionFromServer); + + state.ResetSession(); + + Assert.Equal(RuntimeCharacterState.FullAutonomyLevel, state.AutonomyLevel); + Assert.False(state.UsePositionFromServer); + Assert.True(state.CaptureOwnership().AutonomyIsDefault); + } + private static ActiveEnchantmentRecord MakeVitae(uint spellId, float val) => new( spellId, LayerId: 0u, Duration: -1f, CasterGuid: 0u, diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index c3c34ab2..7b685664 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; using System.Numerics; +using AcDream.Content; +using AcDream.Content.Pak; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -2247,6 +2249,263 @@ public sealed class RuntimeSetPositionStateTests placed.Token)); } + // --------------------------------------------------------------- + // C0-3: exact-Setup mover chain end-to-end + // --------------------------------------------------------------- + + [Fact] + public void TryPrepareAndSubmitAuthoredPlacement_ChainsSetupReadThroughPrepareMoverToSubmit() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001101u, 1); + AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(token.IsValid); + + // Spawn's own default SetupTableId (0x02000001u) - the exact + // CanonicalSetupTableId CapturePreparationAuthority already trusts; + // the chain must read THIS id, not a caller-supplied one. + ImmutableArray spheres = + [ + new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 0.4f), + new FlatCollisionSphere(new Vector3(0f, 0f, 1.2f), 0.4f), + ]; + var source = new FakeCollisionSource( + 0x02000001u, + new FlatSetupCollision( + ImmutableArray.Empty, + spheres, + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.35f)); + + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.TryPrepareAndSubmitAuthoredPlacement( + record, + token, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide, + source, + gameTime: 10d, + out RuntimeSetPositionOutcome outcome)); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(1, source.ReadCount); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out int sphereCount)); + Assert.Equal(2, sphereCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + } + + [Fact] + public void TryPrepareAndSubmitAuthoredPlacement_YieldsRetryOnAMissingSetupReadWithoutMutatingStage() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001102u, 1); + AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(token.IsValid); + + var source = new FakeCollisionSource( + 0x02000001u, + setup: null, + status: PreparedAssetReadStatus.Missing); + + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable, + lifetime.Physics.SetPosition.TryPrepareAndSubmitAuthoredPlacement( + record, + token, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide, + source, + gameTime: 10d, + out RuntimeSetPositionOutcome outcome)); + + Assert.Equal(default, outcome); + Assert.Equal(1, source.ReadCount); + // Never manufactured a fallback while the read is in flight - the + // token can still be prepared once the asset lands. + Assert.True(lifetime.Physics.SetPosition.IsPlacementCurrent(token)); + Assert.False(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out _)); + } + + // --------------------------------------------------------------- + // C0-1: executor completion on the SAME ordered placement stream + // --------------------------------------------------------------- + + [Fact] + public void PublishExecutorCompletion_PublishesAcknowledgeOnlyReceiptAndConverges() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001201u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = + lifetime.Events.SubscribePlacement(observer); + + RuntimePlacementProjectionToken token = + lifetime.Physics.SetPosition.PublishExecutorCompletion(record); + + Assert.True(token.IsValid); + Assert.Equal(record.Key, token.Entity); + RuntimePlacementProjectionSnapshot published = + Assert.Single(observer.Deltas).Placement; + Assert.Equal(RuntimePlacementProjectionKind.ExecutorCompleted, + published.Kind); + Assert.Equal(token, published.Token); + Assert.Equal(body.Position, published.WorldPosition); + Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount); + + // Acknowledge-only, exactly like Discard - no operation to resume. + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(token)); + Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount); + // A stale re-acknowledge of the already-consumed receipt fails. + Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection(token)); + } + + [Fact] + public void PublishExecutorCompletion_RespectsExactHeadOrderingAcrossEntities() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord first = CreateRecord(lifetime, 0x70001202u, 1); + AttachBody(lifetime, first, SourceCell); + RuntimeEntityRecord second = CreateRecord(lifetime, 0x70001203u, 1); + AttachBody(lifetime, second, SourceCell); + + RuntimePlacementProjectionToken firstToken = + lifetime.Physics.SetPosition.PublishExecutorCompletion(first); + RuntimePlacementProjectionToken secondToken = + lifetime.Physics.SetPosition.PublishExecutorCompletion(second); + + Assert.Equal(2, lifetime.Physics.SetPosition.PendingProjectionCount); + // The exact head (first) must acknowledge before the second, matching + // the ordered-stream contract every other Kind already honors. + Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( + secondToken)); + Assert.Equal(2, lifetime.Physics.SetPosition.PendingProjectionCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + firstToken)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + secondToken)); + Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount); + } + + // --------------------------------------------------------------- + // C0-4: TryCommitParent/CommitWithdrawal cancellation asymmetries + // (the ordinary Physics.SetPosition.Forget half, isolated from any + // initial-create residence) + // --------------------------------------------------------------- + + [Fact] + public void TryCommitParent_CancelsASeparateActiveOrdinaryPendingPlacement() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + const uint guid = 0x70001301u; + RuntimeEntityRecord record = CreateRecord(lifetime, guid, 1); + AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(12f, 18f, 7f)))); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount); + + var discards = new List(); + using IDisposable subscription = lifetime.Events.SubscribePlacement( + new PlacementObserver(delta => + { + if (delta.Placement.Kind is RuntimePlacementProjectionKind.Discard) + discards.Add(delta.Placement); + })); + + var relation = new ParentAttachmentRelation( + ParentGuid: 0x70001400u, + ChildGuid: guid, + ParentLocation: 1u, + PlacementId: 1u, + ParentInstanceSequence: 1, + ChildPositionSequence: 1); + Assert.True(lifetime.TryCommitParent(relation, null, out _)); + + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + // The old Place receipt is REPLACED by a Discard at the same + // sequence, not removed outright - it still awaits host + // acknowledgement, exactly like every other cancelled-in-flight + // receipt. + Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount); + RuntimePlacementProjectionSnapshot discard = Assert.Single(discards); + Assert.Equal(record.Key, discard.Token.Entity); + Assert.Equal(outcome.Projection.Sequence, discard.Token.Sequence); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + discard.Token)); + Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount); + } + + [Fact] + public void CommitWithdrawal_CancelsAnActiveOrdinaryPendingPlacementSymmetricallyWithPickup() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + const uint guid = 0x70001302u; + RuntimeEntityRecord record = CreateRecord(lifetime, guid, 1); + AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(12f, 18f, 7f)))); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount); + + var discards = new List(); + using IDisposable subscription = lifetime.Events.SubscribePlacement( + new PlacementObserver(delta => + { + if (delta.Placement.Kind is RuntimePlacementProjectionKind.Discard) + discards.Add(delta.Placement); + })); + + Assert.True(lifetime.CommitWithdrawal(record)); + + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount); + RuntimePlacementProjectionSnapshot discard = Assert.Single(discards); + Assert.Equal(record.Key, discard.Token.Entity); + Assert.Equal(outcome.Projection.Sequence, discard.Token.Sequence); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + discard.Token)); + Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount); + } + private static void VerifyPositionChannelCancellation( CancellationChannel channel) { @@ -2618,6 +2877,73 @@ public sealed class RuntimeSetPositionStateTests } } + /// + /// C0-3 test double: a minimal + /// serving exactly one Setup id (matching CanonicalSetupTableId's + /// field), so + /// can prove ReadSetupCollision -> PrepareMover -> + /// SubmitPreparedPlacement actually chains, not merely that each + /// step works in isolation (the existing preparer tests' coverage). + /// + private sealed class FakeCollisionSource( + uint expectedSetupTableId, + FlatSetupCollision? setup, + PreparedAssetReadStatus status = PreparedAssetReadStatus.Loaded) + : IPreparedCollisionSource + { + internal int ReadCount { get; private set; } + + public PreparedAssetPresence ProbeCollision( + PakAssetType type, uint sourceFileId) => + PreparedAssetPresence.Available; + + public PreparedCollisionReadResult + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by C0-3."); + + public PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + ReadCount++; + Assert.Equal(expectedSetupTableId, sourceFileId); + return status switch + { + PreparedAssetReadStatus.Loaded when setup is not null => + PreparedCollisionReadResult.Loaded( + setup), + PreparedAssetReadStatus.Corrupt => + PreparedCollisionReadResult.Corrupt, + _ => PreparedCollisionReadResult.Missing, + }; + } + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by C0-3."); + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by C0-3."); + + public PreparedCollisionSourceStats CollisionStats => + new(ReadCount, ReadCount, ReadCount, 0, 0); + + public void Dispose() + { + } + } + private sealed class CollisionReportObserver : IRuntimeCollisionReportObserver { From ae2963930757f0430bc15af2276a4ca24572593a Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 05:22:51 +0200 Subject: [PATCH 51/73] docs(physics): record cutover slice C0 completion C0 landed at 67f63e85 with dual review PASS; the plan now records its delivered seam (acknowledge-only ExecutorCompleted receipts through the one placement stream, retail-exact live-input derivation, the chained authored-mover preparation, the cancellation-symmetry hardening) and the three C3 prerequisites its reviews surfaced: the internal-only completion receipt surface, the per-Execute distance-freshness deferral, and the SendAutonomyLevelEvent obligation on any future autonomy-level host exposure. Next slice: C1, the atomic controller/body publication. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-02-placement-cutover.md | 33 ++++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 6ececdbe..12da3be9 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -47,12 +47,33 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. ## Slices -- **C0 — Runtime bridge + live inputs (dormant).** Publish executor - adoption/completion through `Placements` as the receipt stream hosts - consume; derive `UsePositionFromServer`/`PlayerDistance` inside Runtime - from its own owners (no host-supplied gameplay inputs); wire the - exact-Setup mover chain end-to-end for the initial-Create placement; fix - the route-7 cancellation asymmetries. Still no production caller. +- **C0 — Runtime bridge + live inputs — COMPLETE at `67f63e85` + (2026-08-02, dual reviews PASS).** The executor publishes an + acknowledge-only `ExecutorCompleted` receipt through the one placement + stream (registered before dispatch; correlation reaped on + acknowledgement/discard/clear; `PendingCompletionReceiptCount` in + `IsConverged`); all three production sinks acknowledge-and-ignore the + kind via early returns proven behavior-preserving for every other kind + (sanctioned seam completion — provably inert, no production publisher); + `UsePositionFromServer` derives retail-exactly from + `RuntimeCharacterState.AutonomyLevel != 2` and `PlayerDistance` from the + live movement controller with null-safe fallback to the caller struct; + `TryPrepareAndSubmitAuthoredPlacement` chains the prepared-collision + Setup read through `PrepareMover` to submission with zero validation + changes; `TryCommitParent`/`CommitWithdrawal` gained the sibling + cancellation flow (the `LeaveWorld` omission in `TryCommitParent` is + retail-REQUIRED per `set_parent` 0x00515A90:283832-283833's single gated + `leave_world`). Not fully dormant by design: the two cancellation fixes + change live Runtime paths production already calls; everything else has + no production caller. + **C3 prerequisites recorded from C0's reviews:** (a) the completion + receipt/trace surface is internal-only — C3 must define the public host + consumption shape when it wires the hosts; (b) `PlayerDistance` is + resolved once per `Execute` entry, not per continuation — a multi-Position + FIFO classifies later entries against entry-time distance (documented + deferral; refine at C3/C4 if the connected gates show it matters); + (c) any future host exposure of `TrySetAutonomyLevel` must carry retail's + `SendAutonomyLevelEvent` (699550). - **C1 — atomic controller/body publication (prerequisite C).** One Runtime-owned transaction publishing the exact same canonical body to graphical and no-window controllers, prepared off-canonical, committed From 6460596b56cd72a2c6d96e757b33da879a805b6d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 05:33:29 +0200 Subject: [PATCH 52/73] docs(physics): C1 satisfied by the existing publication mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C1 body-writer research found the atomic controller/body transaction already built and tested: RuntimeLocalPlayerPhysicsPublicationState plus the dormant local-activation family implement the sanctioned off-canonical-prepare + validated-atomic-commit shape end-to-end, with zero production callers. The committed writer map records the six canonical body writers, the two host escape hatches (the public Controller setter both hosts write directly; App's object-clock facade bypasses), the headless prepared-collision fragility, and both hosts' construction divergences. C1 therefore collapses into C3's route-1 flip — the remaining work is production wiring, not mechanism design — and C2 (the placement allocation budget) becomes the next slice. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-02-placement-cutover.md | 24 +- .../2026-08-02-canonical-body-writer-map.md | 681 ++++++++++++++++++ 2 files changed, 699 insertions(+), 6 deletions(-) create mode 100644 docs/research/2026-08-02-canonical-body-writer-map.md diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 12da3be9..648d0232 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -74,12 +74,24 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. deferral; refine at C3/C4 if the connected gates show it matters); (c) any future host exposure of `TrySetAutonomyLevel` must carry retail's `SendAutonomyLevelEvent` (699550). -- **C1 — atomic controller/body publication (prerequisite C).** One - Runtime-owned transaction publishing the exact same canonical body to - graphical and no-window controllers, prepared off-canonical, committed - atomically, respected by every body writer/binding/clock-epoch/teardown - path. The rejected snapshot-lease design stays rejected. Adversarial-gate - heavy (the campaign handoff's full list). +- **C1 — atomic controller/body publication — SATISFIED BY EXISTING + MECHANISM (research finding 2026-08-02, plan amended same session).** + `RuntimeLocalPlayerPhysicsPublicationState` (1,033 lines) plus the + ~15-method dormant local-activation family on `RuntimeSetPositionState` + already implement the full sanctioned option-2 transaction: + off-canonical preparation against a scratch quantum clock and a sealed + candidate controller, one validated atomic Commit, and a staged + Evaluate/Commit/FinalizeActivation chain re-validated against + PhysicsOwnershipEpoch/ObjectClockEpoch/ControllerOwnershipEpoch/session + identity at every entry — with zero production callers. See + [`2026-08-02-canonical-body-writer-map.md`](../research/2026-08-02-canonical-body-writer-map.md) + (6 canonical body writers; the two host escape hatches; both hosts' + divergences). The remaining work — routing both hosts' local-player + construction through the publication lifecycle, sealing the public + `RuntimeLocalPlayerMovementState.Controller` setter, retiring App's + direct object-clock bypasses, and containing headless's uncaught + prepared-collision `InvalidDataException` — IS the C3 route-1 flip and + moves there. No separate C1 commit. - **C2 — placement allocation budget.** Pool or eliminate the operation/ projection envelope allocations on the accepted placement path (root cause, not a raised cap), or obtain explicit user approval for a measured diff --git a/docs/research/2026-08-02-canonical-body-writer-map.md b/docs/research/2026-08-02-canonical-body-writer-map.md new file mode 100644 index 00000000..f49dce02 --- /dev/null +++ b/docs/research/2026-08-02-canonical-body-writer-map.md @@ -0,0 +1,681 @@ +# C1 body/controller-publication writer map (2026-08-02) + +Repo: `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch `codex/port-claude-agents`, +HEAD `ae296393`. READ-ONLY research; this file is the only write target. + +Context read: `docs/plans/2026-08-02-placement-cutover.md` (slice C1), +`docs/research/2026-07-31-remaining-physics-campaign-handoff.md` (rejected-prototype +section, lines 143-168; prerequisite C, lines 203-221), and +`docs/research/2026-08-02-cutover-route-inventory.md` route 1 + prerequisite-C +section (lines 174-220) + route 8 (headless). + +--- + +## 1. Every writer of `RuntimeEntityRecord.PhysicsBody` + +`PhysicsBody` is `public PhysicsBody? PhysicsBody { get; private set; }` +(`src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs:72`). The ONLY mutator is +the internal method `SetPhysicsBody(PhysicsBody? body)` +(`RuntimeEntityRecord.cs:176-182`): +``` +internal void SetPhysicsBody(PhysicsBody? body) +{ + if (ReferenceEquals(PhysicsBody, body)) return; + PhysicsBody = body; + PhysicsOwnershipEpoch++; // <-- the ONLY place PhysicsOwnershipEpoch is bumped +} +``` +So every "writer" is a caller of `.SetPhysicsBody(...)` (all 6 call sites, confirmed +by full-repo grep, zero others): + +1. **`RuntimeEntityDirectory.cs:359`** — inside + `GetOrCreatePhysicsBody(RuntimeEntityRecord record, Func factory)` + (need exact surrounding signature — read below). Public/internal API used by + the route-1 "SECOND, narrower body-construction duplicate authority" for + non-player static-animating physics objects + (`DatLiveEntityProjectionMaterializer.cs:1003-1016`, per the route inventory). + Guard: only sets if record has no body yet (idempotent-create pattern) — see + full read below for exact guard. +2. **`RuntimeEntityObjectLifetime.cs:766`** — `Entities.SetPhysicsBody(canonical, null)` + inside a teardown method (need to confirm exact method — likely delete/retire + path, paired with `Entities.SetPhysicsBodyAcquisitionInProgress(canonical, false)` + at line 767 in the SAME method). Clears body on deletion/teardown. +3. **`RuntimeLocalPlayerPhysicsPublicationState.cs:405`** — `candidate.Record.SetPhysicsBody(candidate.Body)` + inside `Commit(token, out activationToken)` (lines 373-411). **THIS IS THE + DORMANT OPTION-2 MECHANISM** — see section 4 below. Guarded by `IsCurrent(candidate)` + (epoch/session/identity/null-state re-check, lines 891-922) immediately before, + and by `_physics.SetPosition.PrepareDormantLocalActivationOwnership(...)` called + first (line 394) as the "seal the exact SetPosition owner before the + irreversible no-fail suffix" step — i.e. this call site DOES chain into + PrepareDormantLocalActivationOwnership per task 4's target. +4. **`RuntimeLocalPlayerPhysicsPublicationState.cs:1025`** — `_entities.SetPhysicsBody(activation.Record, null)` + inside `DiscardActivation()` (994-1032), the rollback/teardown path for the + SAME dormant mechanism — only fires if `_entities.IsCurrent(activation.Record)` + AND `ReferenceEquals(activation.Record.PhysicsBody, activation.Body)` (i.e. + never clobbers a body some OTHER newer owner already installed — the exact + anti-pattern the rejected prototype failed on). +5. **`RuntimePhysicsState.cs:1558`** — `Entities.SetPhysicsBody(record, candidateBody)` + — need full read; this is inside the remote/projectile body-binding family + (see section 3). +6. **`RuntimePhysicsState.cs:1666`** — `Entities.SetPhysicsBody(record, candidate)` + — need full read; this is the OTHER binding site, guarded by + `PhysicsBodyAcquisitionInProgress` (set true at :1645, cleared at :1676/1678). + +**Writer count: 6 call sites, across 3 files** (`RuntimeEntityDirectory.cs` x1, +`RuntimeEntityObjectLifetime.cs` x1, `RuntimeLocalPlayerPhysicsPublicationState.cs` x2, +`RuntimePhysicsState.cs` x2). + +## Consumers of `PhysicsOwnershipEpoch` + +Only bumped in one place (`RuntimeEntityRecord.SetPhysicsBody`, above). Consumers +(all in `RuntimeLocalPlayerPhysicsPublicationState.cs`) treat it as a +compare-and-reject epoch stamped into every token/activation struct: +- `RuntimeLocalPlayerPhysicsPublicationToken.PhysicsOwnershipEpoch` (field, :53) + captured at `Prepare` time (:314). +- `RuntimeLocalPlayerPhysicsActivationToken.PhysicsOwnershipEpoch` (:70) captured + as `token.PhysicsOwnershipEpoch + 1UL` (:328) — i.e. the activation token + encodes "the epoch AFTER my own commit bumps it", so `IsActivationCurrent` + (931-962) and `IsActivationOwnershipEnvelopeCurrent` (717-745) comparing + `activation.Record.PhysicsOwnershipEpoch == activation.Token.PhysicsOwnershipEpoch` + will FAIL (reject) the instant any OTHER writer (remote/projectile bind, GC + clear, non-player static body creation via `RuntimeEntityDirectory` — none of + which should ever touch a local-player record, but the check is defense-in-depth) + touches the same record's PhysicsBody between prepare and commit. +- `IsCurrent(candidate)` (891-922, pre-Commit re-check) also compares + `candidate.Record.PhysicsOwnershipEpoch == candidate.Token.PhysicsOwnershipEpoch` + (unincremented — i.e. "nobody touched the body between Prepare and Commit"). + +**This IS the reentrancy defense the rejected prototype lacked** — see section 6. + +## 2. The two production local-player controller constructions, end to end + +### Graphical: `PlayerModeController.BuildControllerAndCamera` +`src/AcDream.App/Input/PlayerModeController.cs:244-525`. Constructor list +(52-74) shows it is injected with `RuntimeLocalPlayerMovementState controllerSlot` +(the SAME slot type headless writes) — confirms the route-inventory's "open +question" (2026-08-02-cutover-route-inventory.md:204-207): **App DOES write +`_controllerSlot.Controller = controller` directly, at line 486.** Not a +mystery/asymmetry — both hosts write the exact same public setter. + +Steps, in order: +1. `_approachCompletions.BeginControllerLifetime()` (250) — App-only approach + lifecycle token. +2. Capture rollback snapshots: `_camera.CaptureState()` (255), + `_shadow.Capture()` (256) — presentation-only. +3. `new PlayerMovementController(_physics, playerRecord.ObjectClock, PlayerMovementConstructionOptions.From(_skills.Snapshot))` + (259-262) — **uses the PUBLIC constructor**, whose default publication + lifecycle is `StandalonePublished` (`PlayerMovementController.cs:617-626`), + NOT `CandidatePreparing`/`CreatePublicationCandidate`. This is the key + divergence from the dormant mechanism (section 4): this controller never + enters the `CandidatePreparing -> CandidateSealed -> RuntimeOwnedDormant -> + RuntimePublished` lifecycle at all. +4. Builds `MoveToManager`/`EntityPhysicsHost` closures over captured locals + (267-346) — presentation-adjacent glue, host-specific. +5. `EntityPhysicsHostComposition.SelectStableHostWithoutRebind` (347-350) — + canonical-state read (checks `LiveEntityRecord.PhysicsHost`). +6. `RuntimeMovementSkillProjection.ApplyTo(_skills, controller)` (366-368). +7. `ApplyStepHeights(controller, playerEntity, playerGuid)` (375) — **reads + `DatReaderWriter.DBObjs.Setup` directly** (per headless's own comment + contrasting itself, `HeadlessSessionWorldProjection.cs:685-689`) — NOT + through the prepared-collision/`IPreparedCollisionSource` seam headless + uses. Divergence #1. +8. `_controllerSlot.BeginMotionPreparation(controller, drainPriorAnimationQueue)` + (404-407) — the ONE existing narrow "preparation lease" concept already in + `RuntimeLocalPlayerMovementState` (separate from the dormant physics + publication state) that lets a synchronous PartArray/type-5 completion + reach the candidate `MotionInterpreter` before publish. +9. **Duplicate authority** — `_physics.Resolve(...)` (409-413) then + `_physics.ResolvePlacement(...)` (422-430) — direct canonical-state-free + collision resolve, entirely outside `RuntimeSetPositionState`. +10. `controller.PreparePositionForCommit(...)` (434-437), + `controller.SetBodyOrientation(...)` (438). +11. Camera construction + `_camera.EnterChaseMode(...)` (440-447) — + presentation-only, but happens BEFORE the final canonical commit (445-447 + precede line 482-484) — i.e. camera activation today is NOT gated on a + Runtime placement acknowledgement. +12. Re-check host stability (449-458) — throws if the host changed during + camera activation (defensive, but ad hoc — not an epoch/token check, a + bespoke `ReferenceEquals` re-read). +13. Shadow sync (`_shadow.SyncPose(...)`, 460-466). +14. `EntityPhysicsHostComposition.InstallOrRebind(...)` (472-475) + another + `ReferenceEquals` stability re-check (476-480). +15. **Duplicate authority — final commit** (482-484): + `playerEntity.SetPosition(initial.Position); playerEntity.ParentCellId = + initial.CellId; controller.CommitPreparedPosition();` — direct writes to + the App-side `WorldEntity`/render sidecar AND `controller`'s own internal + frame, bypassing any Runtime `Place` receipt or `RuntimeEntityRecord` + write. **`RuntimeEntityRecord.PhysicsBody`/`PhysicsOwnershipEpoch` are + NEVER touched anywhere in this method** — `controller.PhysicsBody` (the + `_body` field created in step 3) stays a private field of the + `StandalonePublished` controller; nothing calls + `Entities.SetPhysicsBody(playerRecord, controller.PhysicsBody)`. This + means TODAY the canonical `RuntimeEntityRecord.PhysicsBody` slot for the + graphical local player is **never populated at all** by this path — a + previously-unstated confirmation that `SubmitPreparedPlacement`'s + `operation.Record.PhysicsBody is not { } body` requirement (section 3) + would REJECT any ordinary (non-initial) SetPosition submitted for the + graphical local player today, because no writer ever puts a body on that + record. (Route 2's "ForcePosition" duplicate authority, + `LocalForcePositionTransaction`, works around this by mutating + `PlayerMovementController`'s own body directly via `BlipPosition`, never + touching `RuntimeEntityRecord.PhysicsBody` either — internally consistent + with each other, both equally disconnected from the canonical record.) +16. Slot commits (485-492): `_hostSlot.Host`, `_controllerSlot.Controller = + controller` (the public, unguarded setter — bumps `ControllerOwnershipEpoch` + unconditionally, see section 4), `_chase.Legacy/Retail`, `_mode.IsPlayerMode + = true`. +17. `catch`: rolls back camera + shadow only (494-518); does NOT roll back + steps 15-16 because those are the LAST lines before `lifetimeCommitted = + true` — structurally "hope nothing after this throws" rather than an + explicit no-fail invariant. + +**Canonical-state mutations in this method: NONE on `RuntimeEntityRecord`** +(no `SetPhysicsBody`, no `SetFullCell`, no object-clock call) — everything +mutated is App-local (`WorldEntity`, `PlayerMovementController`'s private +body, `RuntimeLocalPlayerMovementState.Controller`, +`LocalPlayerPhysicsHostSlot`, camera, shadow). The ONLY canonical-record +writes for the local player's initial placement happen earlier in the +hydration pipeline (`LiveEntityRuntime.MaterializeLiveEntity`/ +`RebucketLiveEntity`, route 1 hops 9-11) — entirely disjoint from this method. + +### Headless: `HeadlessSessionWorldProjection.CreateController` + `SynchronizeLocalPlayer` +`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:566-655` +(read in full). + +`SynchronizeLocalPlayer` (566-615): +1. Guards on `record.ServerGuid == _runtime.PlayerIdentity.ServerGuid` and a + present `Snapshot.Position` (568-573). +2. `_collision.CenterOn(position.LandblockId)` (575) — headless collision- + neighborhood readiness, no graphical analog. +3. `_runtime.MovementOwner.Controller ?? CreateController(record)` (576-578) + — lazy-construct-once via the SAME public `Controller` getter/setter + `RuntimeLocalPlayerMovementState` exposes; no reentrancy guard against two + concurrent calls both observing `null` (single-threaded host loop makes + this safe in practice today, not structurally). +4. `_runtime.EntityObjects.Physics.Engine.Resolve(...)` (589-594) then + `.ResolvePlacement(...)` (595-605) — **the exact same duplicate-authority + shape as graphical step 9**, hardcoded `DefaultRadius`/`DefaultHeight` + constants (visible in the call, actual values not read here) instead of + `_motionBindings.GetSetupCylinder`. +5. `controller.SetPosition(...)` + `controller.SetBodyOrientation(...)` + (610-614) — **duplicate final commit**, headless's version of graphical + step 15. Also never touches `RuntimeEntityRecord.PhysicsBody`. + +`CreateController` (639-655): +1. `new PlayerMovementController(_runtime.EntityObjects.Physics.Engine, record.ObjectClock, PlayerMovementConstructionOptions.From(_runtime.CharacterOwner.MovementSkills.Snapshot))` + (642-646) — **same PUBLIC constructor / `StandalonePublished` lifecycle** + as graphical step 3. +2. `ApplySetupStepHeights(record, controller)` (649, body at 657-691) — + **reads via `_preparedCollision.ReadSetupCollision(setupId)`** (668-679), + the prepared-asset seam, NOT raw DAT — divergence #1 mirrored (headless + uses the "correct"/prerequisite-B-aligned source; graphical does not). + **Throws `InvalidDataException`** if the read status isn't `Loaded` + (672-675) — propagates uncaught up through `SynchronizeLocalPlayer` -> + `ProjectSpawn`/`ProjectPosition` -> the wire-dispatch call chain. This IS + gate 10 (late headless prepared-collision failure) manifesting today as an + unhandled exception, not a retry. +3. `RuntimeMovementSkillProjection.ApplyTo(_runtime.CharacterOwner.MovementSkills, controller)` + (650-652). +4. `_runtime.MovementOwner.Controller = controller;` (653) — **the exact + same public setter graphical step 16 uses.** + +**No headless equivalent of graphical steps 1 (approach lifetime), 8 (motion +preparation lease), 11-14 (camera + host-stability re-checks), 17 (camera/ +shadow rollback)** — headless has no camera/shadow/approach concept at all +(confirmed, matches the route inventory's "No headless equivalent of +graphical hops 12-14/19"). + +### Top divergences between the two hosts (summary) +1. **Setup/collision data source**: App reads raw DAT (`ApplyStepHeights` via + `_dats`/`_datLock`); headless reads the prepared/baked asset + (`ApplySetupStepHeights` via `IPreparedCollisionSource`). Same target + values, different pipeline — a real fidelity risk if the two ever diverge + (baking staleness). +2. **Default cylinder fallback**: App falls back to `0.48f`/`1.835f` inline + (`PlayerModeController.cs:416-420`) when `GetSetupCylinder` returns + `< 0.05f` radius; headless uses named `DefaultRadius`/`DefaultHeight` + constants at the `ResolvePlacement` call site (:599-600) — same intended + values, defined in two places. +3. **Failure handling**: App's `BuildControllerAndCamera` has an explicit + try/catch/rollback for camera+shadow; headless's `CreateController`/ + `ApplySetupStepHeights` has NO surrounding try/catch — a prepared-collision + read failure is a raw unhandled exception today. +4. **Presentation surface**: App additionally owns approach-completion + lifetime, motion-preparation lease, chase camera, shadow sync — none of + which headless has or needs. +5. **Neither host touches `RuntimeEntityRecord.PhysicsBody`, `PhysicsOwnershipEpoch`, + or any `RuntimeSetPositionState` API** — both are 100% off to the side of + the canonical record, confirmed by exhaustive grep (section 1's 6 writer + call sites do not include either `PlayerModeController.cs` or + `HeadlessSessionWorldProjection.cs`). + +--- + +## 3. Every other body binding/consumer + +- **Remote dead-reckoning** (`RuntimeRemotePhysicsUpdater.cs` — flagged + protected/dirty, read-only, NOT modified): grep confirms it only READS + `record.PhysicsBody` via `ReferenceEquals(record.PhysicsBody, remote.Body)` + currency checks (line 817) — it does not call `SetPhysicsBody`. The actual + writer for remote motion is `RuntimePhysicsState.SetRemoteMotion` + (`RuntimePhysicsState.cs:1446-1570`, full read) — throws + `InvalidOperationException` on: binding-already-in-progress (1460-1464), + body-would-be-replaced when a body already exists and doesn't match + (1487-1492), losing an existing remote-placement contract (1493-1498), or + post-callback ownership drift detected via a captured + `sessionVersion`/`expectedBody`/`expectedRuntime` triple re-checked after + the bind callback (1539-1549, "changed ownership during remote-motion + binding"). Calls `Entities.SetPhysicsBody(record, candidateBody)` (:1558) + ONLY when `expectedBody is null` (first bind) via `InitializeNewPhysicsBody` + (:1556) — i.e. this is throw-on-conflict exclusivity (Option-1 flavor), not + epoch/token gating. For a LOCAL PLAYER record this path should never fire + (remote motion is for non-local entities) but the guard is defense-in-depth + and IS one of the explicit gate checks + (`!activation.Record.RemoteMotionBindingInProgress`/`RemoteMotion is null`) + the dormant local-publication mechanism re-validates at every stage + (section 4). +- **Projectile binding**: `RuntimeProjectilePhysicsUpdater.cs` similarly only + READS `record.PhysicsBody` (lines 447, 459, `ReferenceEquals` currency + checks). The writer is `RuntimePhysicsState.BindProjectile` + (`RuntimePhysicsState.cs:1309-1382`, full read) — same throw-on-conflict + shape: binding-in-progress (1343-1347), body-mismatch on rebind + (1330-1337), must-already-own-canonical-body-before-binding + (1348-1352, "projectile must borrow its canonical physics body" — i.e. + UNLIKE remote motion, `BindProjectile` requires `record.PhysicsBody` to + ALREADY be non-null and matching BEFORE it will bind — it never calls + `InitializeNewPhysicsBody`/`SetPhysicsBody` itself for a first-time body; + something else (route 5's `ProjectileController.TryBind`, + `ProjectileController.cs:176-265` per the route inventory) must construct + the body ad hoc first via a DIFFERENT path than `GetOrCreatePhysicsBody` + — worth flagging: **this is a 7th, App-side, ad hoc body-construction site + not funneled through any of the 6 canonical writer methods** — App's + `ProjectileController.TryBind` constructs a body and must be setting it onto + the record through some other route (not confirmed by this pass; App-side + `ProjectileController.cs` was not read in full — flag as open item, but it + is explicitly OUT of C1's local-player scope per the campaign handoff's + gate list item "remote and projectile binding/update" being about + *interaction with* the local-player transaction, not projectile's own + authority). +- **`RuntimeSetPositionState.SubmitPreparedPlacement`** (`RuntimeSetPositionState.cs:2224-2274`, + full read): requires `operation.Record.PhysicsBody is not { } body` to + already be true (line 2254) — i.e. EVERY non-initial-construction + SetPosition submission (ForcePosition, portal, remote Position, projectile + correction) requires a body to already exist on the record, confirming the + 6 writer sites in section 1 are the exhaustive set of "who can put the + FIRST body on a record." For the local player specifically, only + `RuntimeLocalPlayerPhysicsPublicationState.Commit` (section 4) does this + today (dormant, unwired); in PRODUCTION, no writer ever populates + `RuntimeEntityRecord.PhysicsBody` for either host's local player (section + 2 finding) — meaning `SubmitPreparedPlacement` would reject a local-player + submission in production today, which is consistent with the route + inventory's finding that Route 2 (ForcePosition) and Route 3 (portal) both + bypass `RuntimeSetPositionState` entirely via their own duplicate + authorities instead. +- **`RuntimeSetPositionState.PrepareDormantLocalActivationOwnership`**: see + section 4 — the ONE place the local-player-specific dormant body attach + happens; requires a pre-opened `Operation` in stage `AwaitingPreparation` + from `TryBeginExclusiveAuthoredPlacement`. +- **Object-clock epoch transitions** + (`RuntimeEntityRecord.SuspendObjectClock`/`ResetObjectClockForEnterWorld`, + both `internal`, bump `ObjectClockEpoch`): full call-site grep found BOTH + the expected `RuntimeEntityDirectory` wrapper call sites (which run + `EnsureKnown(record)` first, `RuntimeEntityDirectory.cs:311-321`) AND + **direct unwrapped calls from `src/AcDream.App/World/LiveEntityRuntime.cs` + at lines 879, 891, 897, 1258, 3030, 3033** — App calls + `record.SuspendObjectClock()`/`record.ResetObjectClockForEnterWorld(...)` + straight on the `RuntimeEntityRecord` (accessible because these are + `internal` and `AcDream.App` has `InternalsVisibleTo`), bypassing the + `RuntimeEntityDirectory` facade's `EnsureKnown` check entirely. This is + inside `LiveEntityRuntime`'s `RebucketLiveEntity`-family code (comment + references `prepare_to_enter_world`/retail `update_object`'s parent + early-out — matches the already-known route-1/prerequisite-D + `RebucketLiveEntity` duplicate authority). **Previously-unstated + implication for C1**: the SAME record whose `ObjectClockEpoch` the dormant + publication mechanism gates on can have its epoch bumped by this + direct-call path DURING the window between + `RuntimeLocalPlayerPhysicsPublicationState.Prepare` and `.Commit()`/ + `.CommitActivation()` if `RebucketLiveEntity` runs concurrently for the + SAME entity (e.g. a second CreateObject/Position causing a re-rebucket + mid-construction) — the epoch check (`IsCurrent`/`IsActivationOwnershipEnvelopeCurrent` + comparing `record.ObjectClockEpoch == token.ObjectClockEpoch`) WOULD catch + and reject this correctly (fail-safe), but it confirms the gate is load- + bearing against a REAL, already-existing production writer, not a + hypothetical. +- **Deletion/teardown**: `RuntimeEntityObjectLifetime.TryAcceptDelete` + (`RuntimeEntityObjectLifetime.cs:1555+`) calls `Entities.TryDelete` then + `Entities.RemoveActive(active)` (1587) — this IMMEDIATELY flips + `Entities.IsCurrent(record)` to `false` for that record (removes it from + the active-by-guid table), which is the single check + `CanPrepare`/`IsCurrent`/`IsActivationCurrent`/every gate in section 4 + depends on — so a delete landing at any point rejects the in-flight + publication transaction on its NEXT check. Full body clear happens later + in `RuntimeEntityObjectLifetime.CompleteProjectionRetirement` + (:745-771, called from `RetireCanonicalOnly`/the graphical teardown-ack + path): `Entities.SetPhysicsBody(canonical, null)` (:766) after + `Physics.SetPosition.Forget(canonical, releasePreparedMover: true)` (:753, + cancels any in-flight ordinary placement) and + `ForgetInitialCreateResidence(canonical)` (:751, cancels any in-flight + residence lease) — i.e. deletion cancels BOTH placement-lease families + before clearing the body, consistent with prerequisite E's "quiesce before + demote" discipline (though for landblock collision, not entity teardown — + the pattern rhymes). +- **`RuntimePhysicsState` per-frame body access**: `RuntimeOrdinaryPhysicsUpdater.cs`, + `RuntimeRemotePhysicsUpdater.cs`, `RuntimeProjectilePhysicsUpdater.cs` each + gate their per-tick work on `record.PhysicsBody is not { } body` / + `ReferenceEquals(record.PhysicsBody, body)` currency checks (grep-confirmed, + e.g. `RuntimeOrdinaryPhysicsUpdater.cs:68,284`) — read-only w.r.t. the + `PhysicsBody` reference itself (they mutate the BODY's internal fields + every tick, which is expected/normal simulation, not an ownership-slot + write). No workset iterates and calls `SetPhysicsBody`. `RuntimePhysicsState.cs` + itself has only two visible "workset" mentions (`ClearSpatialWorksets` at + :1951, a doc-comment at :1307) — the ordinary/remote/projectile worksets + live in their respective `RuntimeXPhysicsUpdater` files, out of this pass's + read budget beyond the grep-confirmed read-only currency pattern above. + +--- + +## 4. `RuntimeSetPositionState.PrepareDormantLocalActivationOwnership` — nucleus or dead end? + +**Definition** (`RuntimeSetPositionState.cs:1026-1052`, full read): +```csharp +internal void PrepareDormantLocalActivationOwnership( + RuntimeEntityRecord record, PhysicsBody body, + in RuntimeEntityPlacementToken token) +{ + ... + if (!token.IsValid + || record.Key != token.Entity + || !_operations.TryGetValue(token.Entity, out Operation? operation) + || operation.Token != token + || operation.Stage is not RuntimeEntityPlacementStage.AwaitingPreparation + || !ReferenceEquals(operation.Record, record) + || record.PhysicsBody is not null // <- record must have NO body yet + || !IsCurrent(operation) + || body.InWorld + || (body.TransientState & TransientStateFlags.Active) != 0) + { + throw new InvalidOperationException( + "Dormant local activation must bind to the exact current placement owner."); + } + operation.Body = body; + operation.DormantLocalActivation = true; +} +``` +It THROWS (does not return a status) on any invariant violation — by design a +"this should be structurally impossible if the caller validated first" +assertion, not a retryable rejection. It requires a PRE-EXISTING placement +`Operation` already opened via `TryBeginExclusiveAuthoredPlacement` +(`RuntimeSetPositionState.cs:1004-1024`) in stage `AwaitingPreparation` — i.e. +it is NOT a standalone entry point; it is ONE STEP inside a larger chain that +also needs prerequisite B's mover-preparation authority +(`IsExactPreparedPlacementCurrent`, `RuntimeSetPositionState.cs:1318-1340`) +satisfied for the SAME token/command before +`RuntimeLocalPlayerPhysicsPublicationState.CanPrepare` will even call it. + +**What it was built for**: it is called from exactly ONE place in the whole +repo — `RuntimeLocalPlayerPhysicsPublicationState.Commit` +(`RuntimeLocalPlayerPhysicsPublicationState.cs:394-397`), as the "seal the +exact SetPosition owner before the irreversible no-fail suffix" step, +immediately before `candidate.Controller.CommitRuntimeOwnership(...)` and +`candidate.Record.SetPhysicsBody(candidate.Body)`. It exists purely to make +the PLACEMENT OPERATION (owned by `RuntimeSetPositionState`) and the BODY +(owned by `RuntimeEntityRecord`) become mutually aware atomically, so that +the SAME operation can later be walked through the full retail SetPosition +staged commit (ground phase -> collision dispatch -> response -> final +commit) via `TryEvaluateDormantLocalActivation` -> +`TryPrepareDormantLocalActivationCommit` -> +`TryApplyDormantLocalActivationCommit` -> +`TryPrepareDormantLocalActivationFinalCommit` -> +`TryApplyDormantLocalActivationFinalCommit` +(`RuntimeSetPositionState.cs:2025-2077`, full read of the final-commit +method) — the LAST of which is where `_entities.SetFullCell`, +`_entities.AdvancePlacementCommit`, `body.InWorld = true`, +`_entities.SetPhysicsHost`, `controller.CommitRuntimeActivationFrame()`, +`_physics.Engine.UpdatePlayerCurrCell`, `_physics.AcknowledgeSpatialProjection`, +`_entities.ResetObjectClockForEnterWorld` (object-clock epoch bump, task 3), +and `controller.ActivateRuntimePublication()` (controller goes LIVE) ALL +happen in one synchronous, no-branch-for-failure block (:2025-2077), gated +immediately before by `IsDormantLocalActivationPrephaseCurrent`/re-validated +epoch checks. **This is genuinely the full retail SetPosition commit, +already ported, already wired to the same body/controller the dormant +publication candidate built.** + +**Verdict: NUCLEUS, not a dead end** — but it is only ONE LOAD-BEARING STEP +inside a much larger, ALREADY-COMPLETE mechanism: +`RuntimeLocalPlayerPhysicsPublicationState` (1033 lines, +`src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs`) ++ its ~15 `RuntimeSetPositionState` dormant-activation methods. Constructed +once at `GameRuntime.cs:261` and exposed via +`RuntimeLocalPlayerMovementState.PhysicsPublication` (:59-61, itself +`internal`, throws if unbound). **Confirmed by exhaustive grep: ZERO +production callers in `src/AcDream.App/` or `src/AcDream.Headless/`** — the +only callers anywhere are `tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs`. +This is, functionally, **Option 2 from the rejected-prototype note ("Off- +canonical preparation followed by one validated atomic Runtime commit that +publishes the prepared controller/body relationship without copying stale +state over newer authority") already built end to end** — complete with: +- a `Prepare`/`Commit`/`Discard` triad for the BODY/CONTROLLER pair + (analogous to, and reusing, the SAME token-epoch pattern as + `RuntimeSetPositionState`'s ordinary placement operations); +- a SEPARATE `EvaluateActivation`/`CommitActivation`/`DiscardActivation` triad + for actually driving the body through retail SetPosition's staged commit + once the body/controller pair is sealed; +- re-validation of `PhysicsOwnershipEpoch`, `ObjectClockEpoch`, + `ControllerOwnershipEpoch`, `SessionLifetimeVersion`, identity + `ServerGuid`+`Revision`, and null/in-progress state for RemoteMotion/ + Projectile/PhysicsHost/DeleteAcceptedForTeardown at EVERY external entry + point (`CanPrepare`, `IsCurrent`, `IsActivationCurrent`, + `IsActivationOwnershipEnvelopeCurrent`, + `IsCommittedActivationSuffixCurrent`) — this IS the reentrancy defense the + rejected snapshot-lease prototype explicitly lacked (see section 6). + +**What is genuinely missing (the real C1 work), given this mechanism already +exists:** +1. Nobody calls `TryBeginExclusiveAuthoredPlacement` + prerequisite B's + `PrepareMover`/`ReadSetupCollision` chain + `PhysicsPublication.Prepare`/ + `Commit`/`EvaluateActivation`/`CommitActivation` from either host — this IS + the wiring gap, exactly like every other route in the cutover. +2. **The public `RuntimeLocalPlayerMovementState.Controller` setter + (`RuntimeLocalPlayerMovementState.cs:37-50`) remains a live, unguarded + escape hatch** — both `PlayerModeController.BuildControllerAndCamera:486` + and `HeadlessSessionWorldProjection.CreateController:653` write it + directly today, and NOTHING stops either host from continuing to do so + even after C1 wires the dormant mechanism, unless that direct-write path + is deleted/sealed off (e.g. made `internal` to only + `RuntimeLocalPlayerPhysicsPublicationState`/`CommitRuntimeOwnedController`). + The setter has NO epoch/token check on write (`CanCommitRuntimeOwnedController` + is a SEPARATE, unused-by-the-setter validation method) — it will happily + accept a second unguarded assignment even while a dormant activation is + in flight, silently retiring whatever the dormant mechanism just + published (`_controller?.RetireRuntimePublication()` at :45, which is a + real no-op for anything not currently `RuntimeOwnedDormant`/ + `RuntimePublished` — see section 6 gate 7). **This is the single most + important pre-existing defect C1 must close: the "exclusive" adjective in + prerequisite C's "one Runtime-owned exclusive/versioned...transaction" + is not yet true while this direct setter remains reachable from hosts.** +3. Neither `new PlayerMovementController(physics, objectClock, options)` + (public ctor, `StandalonePublished`) call site in the two hosts has been + swapped for `PlayerMovementController.CreatePublicationCandidate` — until + that swap happens, controllers built by either host never enter the + `CandidatePreparing/CandidateSealed/RuntimeOwnedDormant/RuntimePublished` + lifecycle the dormant mechanism's gates all key off of. + +--- + +## 5. `PlayerMovementController` construction requirements + +Constructor needs (from both direct-ctor call sites AND +`CreatePublicationCandidate`, `PlayerMovementController.cs:617-690`): +- `PhysicsEngine physics` (shared engine reference, both hosts pass their own + `RuntimePhysicsState`/`_runtime.EntityObjects.Physics.Engine`). +- `RetailObjectQuantumClock? objectClock` — App passes `playerRecord.ObjectClock` + (the CANONICAL record's clock, `RuntimeEntityRecord.ObjectClock` at + `RuntimeEntityRecord.cs:62`, always non-null per its field initializer); + headless passes `record.ObjectClock` identically. The dormant mechanism's + `CreatePublicationCandidate` instead passes a THROWAWAY + `new RetailObjectQuantumClock()` (`PlayerMovementController.cs:687-688`) at + construction time and only swaps in the REAL + `candidate.Record.ObjectClock` later, inside `Commit`, via + `controller.CommitRuntimeOwnership(candidate.Record.ObjectClock)` + (`RuntimeLocalPlayerPhysicsPublicationState.cs:403-404` -> + `PlayerMovementController.cs:774-786`) — i.e. the dormant candidate is + built against a SCRATCH clock so construction can never observe or mutate + the canonical record's real clock before the atomic commit swaps it in. + This is exactly the "off-canonical preparation" half of Option 2. +- `PlayerMovementConstructionOptions` (RunSkill/JumpSkill) — both hosts build + via `PlayerMovementConstructionOptions.From()`; the dormant mechanism's `Prepare` also takes this as a caller- + supplied parameter (`Prepare(..., PlayerMovementConstructionOptions + options, ...)`, :191) — no divergence in shape, only in WHERE the skill + snapshot is read from (App's local `_skills` field vs. headless's + `_runtime.CharacterOwner.MovementSkills` vs. the dormant mechanism taking + it as a caller parameter either way). + +What construction MUTATES (beyond the private `_body`): `LocalEntityId`, +`StepUpHeight`/`StepDownHeight` (Setup-derived), `SphereList` (Setup-derived, +prerequisite B territory), `ObjectScale`, initial position/orientation via +`PreparePositionForCommit`/`SetBodyOrientation`, physics state via +`ApplyPhysicsState`, `MoveToFactory`/`PositionManager` +(`MovementManager`/`MotionInterpreter` wiring). ALL of this is exactly what +`RuntimeLocalPlayerPhysicsPublicationState.Prepare` +(`RuntimeLocalPlayerPhysicsPublicationState.cs:187-355`) already does against +its private `CreatePublicationCandidate`-built controller, reading +`command.Physics.StepUpHeight/StepDownHeight/Spheres/Scale/Position/CellId/ +CellLocalPosition/Orientation` from the CALLER-SUPPLIED +`RuntimeSetPositionCommand` (i.e. the command already carries everything +prerequisite B's mover-preparation chain produces) rather than reaching into +DAT/prepared-collision itself. + +**What an "off-canonical preparation followed by one validated atomic commit" +must DEFER** (confirmed by the dormant mechanism's own design, section 4): +- The record's REAL `ObjectClock` (use a scratch clock during prep). +- `RuntimeEntityRecord.PhysicsBody`/`PhysicsOwnershipEpoch` (never touch the + canonical record during prep; only `SetPhysicsBody` inside `Commit`, and + only after `PrepareDormantLocalActivationOwnership` succeeds). +- `RuntimeLocalPlayerMovementState.Controller`/`ControllerOwnershipEpoch` + (only via `CommitRuntimeOwnedController`, never the public setter, during + prep). +- `body.InWorld`/`TransientState.Active` (explicitly forced false during prep, + `Prepare`, :292-293) — the body must not be simulatable until the LATER + activation commit flips it (`TryApplyDormantLocalActivationFinalCommit`, + `body.InWorld = true` at :2056). +- World-residence/host/shadow/camera publication (all deferred to the + activation phase / presentation-observer layer, never inside `Prepare`). + +--- + +## 6. Adversarial gate list — exact code paths that would race TODAY + +(Campaign handoff's list, `docs/research/2026-07-31-remaining-physics-campaign-handoff.md:210-221`.) +For each: what the ALREADY-BUILT dormant mechanism does (if wired) vs. what +the CURRENT production direct-construction paths do (today, unwired). + +1. **Nested construction** — Dormant: `CanPrepare` requires `_activation is + null` AND `record.PhysicsBody is null` AND `_movement.Controller is null` + (`RuntimeLocalPlayerPhysicsPublicationState.cs:862-889`) — a second + `Prepare` while one is in flight is REJECTED structurally. Today: NEITHER + `BuildControllerAndCamera` NOR `CreateController` has any such guard — + `CreateController`'s `_runtime.MovementOwner.Controller ?? CreateController(record)` + (`HeadlessSessionWorldProjection.cs:576-578`) is a bare null-coalesce, not + an atomic test-and-set; only single-threaded host-loop scheduling + prevents an actual race today. +2. **Reentrant SetPosition** — Dormant: every gate re-checks + `PhysicsOwnershipEpoch`/`record.PositionAuthorityVersion` currency. + Today: `BuildControllerAndCamera`'s final mutation + (`playerEntity.SetPosition`/`ParentCellId`/`CommitPreparedPosition`, + PlayerModeController.cs:482-484) has zero epoch check — a same-thread + reentrant call (e.g. from a nested wire dispatch) would silently + clobber with no detection. +3. **Remote and projectile binding/update** — Dormant: `CanPrepare`/ + `IsCurrent`/`IsActivationCurrent` all check `record.RemoteMotion is null`, + `record.Projectile is null`, `!RemoteMotionBindingInProgress`, + `!ProjectileBindingInProgress` (defense-in-depth; should never legitimately + fire for a local-player record). Today: no such check exists in either + host's direct construction path. +4. **Deletion and same-GUID new incarnation** — Dormant: `_entities.IsCurrent(record)` + checked at every gate; `TryAcceptDelete` -> `RemoveActive` flips this + immediately (section 3). Today: `BuildControllerAndCamera`/`CreateController` + take a fixed `RuntimeEntityRecord`/`WorldEntity` parameter with NO + re-validation against current canonical identity at the final commit. +5. **Projection-owner replacement** — Dormant: `_entities.SessionLifetimeVersion + == token.SessionGenerationAuthority` checked throughout. Today: no + generation check in either direct path. +6. **Object-clock epoch change** — Dormant: `ObjectClockEpoch` compared at + every gate (section 3/4). Today: no check; AND there is a REAL, live + concurrent writer already in production — + `LiveEntityRuntime.cs:879/891/897/1258/3030/3033`'s direct + `record.SuspendObjectClock()`/`ResetObjectClockForEnterWorld(...)` calls + inside the `RebucketLiveEntity` family (section 3) — this is not a + hypothetical gate, it is a currently-active call path on the SAME record + type. +7. **Reset and disposal** — Dormant: `ResetSession()`/`Dispose()` on + `RuntimeLocalPlayerMovementState` explicitly cascade into + `_physicsPublication?.ResetSession()`/`Dispose()` + (`RuntimeLocalPlayerMovementState.cs:244-295`), which tear down + candidate/activation state via `ReferenceEquals`-gated clears (never + clobbering a newer owner, `DiscardActivation`, + `RuntimeLocalPlayerPhysicsPublicationState.cs:1002-1032`). Today's direct- + construction controllers are built via the PUBLIC constructor + (`StandalonePublished` lifecycle) — **`RetireRuntimePublication()` + (`PlayerMovementController.cs:837-846`) only transitions + `RuntimeOwnedDormant`/`RuntimePublished` state; it is a NO-OP for + `StandalonePublished` controllers** — a previously-unstated finding: TODAY, + `ResetSession()`/`Dispose()`/replacing `.Controller` on either host's + directly-built controller produces NO explicit lifecycle transition at + all; the controller is simply dropped/GC'd. Not a visible bug today + (nothing reads `IsRuntimePublished` for these), but it means today's + controllers are invisible to the exact teardown bookkeeping C1's target + mechanism relies on. +8. **Commit and rollback after replacement** — Dormant: ALL validation + happens before the single canonical mutation + (`PrepareDormantLocalActivationOwnership`, which itself throws leaving + state untouched on failure); everything after is documented as + "callback-free, non-allocating, and cannot fail" + (`RuntimeLocalPlayerPhysicsPublicationState.cs:391-393`) — no rollback- + after-newer-authority path exists BECAUSE nothing after that point can + fail by construction. Today: `BuildControllerAndCamera`'s try/catch rolls + back camera+shadow only; the final `playerEntity.SetPosition`/ + `ParentCellId`/`CommitPreparedPosition` triad (482-484) has nothing after + it that can throw, so it's accidentally safe today, not structurally + guaranteed. +9. **Late graphical camera/shadow/host failure** — Today: `BuildControllerAndCamera` + DOES handle this (explicit `_camera.RestoreState`/`_shadow.Restore` in the + catch block, 494-518) — this is the ONE gate the CURRENT graphical path + already handles reasonably. The dormant Runtime-side mechanism has NO + camera/shadow concept (presentation-independent by design) — C1 must + layer this handling in the PRESENTATION/observer phase (post-Runtime- + commit), matching prerequisite D's rule that a host exception must not + roll Runtime back, only retry the FIFO head. +10. **Late headless prepared-collision failure** — Today: + `ApplySetupStepHeights` (`HeadlessSessionWorldProjection.cs:657-691`) + throws a raw, uncaught `InvalidDataException` (672-675) if the prepared + Setup collision isn't `Loaded` — this propagates up through + `CreateController` -> `SynchronizeLocalPlayer` -> `ProjectSpawn`/ + `ProjectPosition` with NO try/catch anywhere in between (grep-confirmed + no surrounding try/catch in `HeadlessSessionWorldProjection.cs`'s these + methods) — a genuinely unhandled-exception risk in production headless + TODAY, not just a hypothetical C1 gate. + +--- + +## Summary for the C1 contract + +- **Writer count**: 6 confirmed call sites of `RuntimeEntityRecord.SetPhysicsBody` + across 3 files (`RuntimeEntityDirectory.cs:359`, + `RuntimeEntityObjectLifetime.cs:766`, + `RuntimeLocalPlayerPhysicsPublicationState.cs:405,1025`, + `RuntimePhysicsState.cs:1558,1666`) — plus a probable 7th App-side ad hoc + projectile body-construction site not yet traced to a canonical writer + (flagged, out of local-player scope). +- The dormant `RuntimeLocalPlayerPhysicsPublicationState` + + `RuntimeSetPositionState`'s ~15 dormant-activation methods already + implement essentially the COMPLETE Option 2 transaction (off-canonical + prepare against a scratch clock/sealed candidate controller, single + validated atomic commit, full retail-staged SetPosition activation) with + epoch/token/generation/identity re-validation at every external entry + point — it has ZERO production callers in either host. +- The single largest remaining defect even AFTER wiring: the public + `RuntimeLocalPlayerMovementState.Controller` setter is an unguarded escape + hatch both hosts currently use directly; it must be sealed (made + unreachable from hosts, or itself epoch-gated) for the word "exclusive" in + prerequisite C to be true. From 63c601ff4d4cb345c1011a3c5b4e8755684ccd24 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 07:43:11 +0200 Subject: [PATCH 53/73] perf(runtime): halve accepted-placement allocations via pooled operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cutover slice C2: the dormant placement path's per-operation cost was the recorded activation blocker for routing frame-frequency traffic through the canonical SetPosition owner (1,880 B/op measured at 4B2, cap 2,048). Root-cause removal, not a raised cap: the per-operation envelope is now pooled (bounded 64, reset-at-rent, InPool double-retire guard, cleared on session reset/dispose and surfaced as a diagnostic ownership count), the two engine-callback closures became one cached delegate over an explicit context stack, and the pending-projection head read no longer boxes the sorted enumerator. Measured 2,032 -> 944 B/op; the regression gate tightens to 1,536. The residual floor is documented at the gate: ~520 B inside Core's PhysicsEngine.SetPosition (outside this slice's scope) and ~208 B of sorted-tree node per pending receipt. Pooling demanded — and received — the full staleness-discipline rework: every frame holding an operation across a reentrancy point now captures its never-reissued token and revalidates via fresh lookup (IsCurrentByToken / token-shaped CancelCore), because a recycled instance reinstalled at the same key makes every reference-identity check a tautology. All ~26 sites audited (15 remain reference-based with per-site no-reentrancy proofs); CommitCanonical's post-callback reads are hoisted stack locals mirroring retail's savedTransientState pattern (handle_all_collisions bits, pseudo-C 283952), its bookkeeping writes are token-gated, and the settle path stays deliberately identity- agnostic because retail's SetPositionInternal runs its physical settle unconditionally even for displaced operations. Reviewed: retail-conformance PASS + architecture/adversarial PASS after two fix rounds (the ground-edge recycle window, the pool's cross-reset retention, the class-wide tautology, a self-found snapshot-reference iteration hazard). Runtime 927/927; complete Release solution 10,722 passed / 4 intentional skips; budget test green at the tightened gate. Co-Authored-By: Claude Fable 5 --- .../Physics/RuntimeSetPositionState.cs | 1137 ++++++++++++++--- .../Physics/RuntimeSetPositionStateTests.cs | 416 +++++- 2 files changed, 1362 insertions(+), 191 deletions(-) diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 68efb27f..7a285b24 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Numerics; using AcDream.Content; using AcDream.Core.Items; @@ -265,7 +266,8 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot( int PlacementCompletionWatchCount, int AcknowledgedPlacementCompletionCount, int CollisionPrefixQuiescenceCount, - int PendingQuiescenceProjectionCount) + int PendingQuiescenceProjectionCount, + int PooledOperationCount) { internal bool IndexesConsistent => LostDeadlineCount == LostDeadlineNodeCount @@ -275,6 +277,16 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot( && UnboundDeferredCellCount == UnboundDeferredCellOrderCount && MoverPreparationAuthorityCount <= ActiveOperationCount; + /// + /// F2: deliberately EXCLUDED from . + /// is retained, idle Operation + /// capacity in the C2 object pool - legitimate to hold mid-session (that + /// is the entire point of pooling), so it must not make an otherwise + /// fully-drained, healthy session read as "not converged". Reset/dispose + /// tests assert it separately drops to zero + /// (OperationPoolClearsOnResetSession / + /// OperationPoolClearsOnDispose) instead of folding it into this gate. + /// internal bool IsConverged => ActiveOperationCount == 0 && AwaitingPreparationCount == 0 && DeferredCellCount == 0 @@ -334,19 +346,33 @@ internal sealed class RuntimeSetPositionState : IDisposable bool Prepared, RuntimeSetPositionCommand PreparedCommand); + /// + /// C2: settable (not init/required) so + /// / can recycle instances via + /// instead of allocating a fresh + /// object on every accepted placement. This was the single largest + /// contributor to the C2 allocation budget finding (see + /// docs/research/2026-07-31-canonical-set-position.md). Every + /// construction site (BeginAcceptedPlacementCore, + /// CreateWithdrawalOperation, ParkDeferred) still sets + /// every field it always set before; the only behavior change is that a + /// field left unset by a given site now comes from an explicit reset + /// instead of the CLR's implicit new-object default - the two are + /// identical in value. + /// private sealed class Operation { - internal required RuntimeEntityRecord Record { get; init; } + internal RuntimeEntityRecord Record { get; set; } = null!; internal PhysicsBody? Body { get; set; } - internal required RuntimeEntityPlacementToken Token { get; init; } - internal required RuntimeEntityKey Key { get; init; } - internal required ulong PositionAuthorityVersion { get; init; } - internal required ulong SessionLifetimeVersion { get; init; } - internal required ulong SourceSpatialAuthorityVersion { get; init; } - internal required ulong SourceVelocityAuthorityVersion { get; set; } - internal required bool PreviousContact { get; set; } - internal required bool PreviousOnWalkable { get; set; } - internal required RuntimeSetPositionCommand Command { get; set; } + internal RuntimeEntityPlacementToken Token { get; set; } + internal RuntimeEntityKey Key { get; set; } + internal ulong PositionAuthorityVersion { get; set; } + internal ulong SessionLifetimeVersion { get; set; } + internal ulong SourceSpatialAuthorityVersion { get; set; } + internal ulong SourceVelocityAuthorityVersion { get; set; } + internal bool PreviousContact { get; set; } + internal bool PreviousOnWalkable { get; set; } + internal RuntimeSetPositionCommand Command { get; set; } internal PhysicsSetPositionResult Result { get; set; } internal ulong SpatialAuthorityVersion { get; set; } internal ulong PlacementCommitVersion { get; set; } @@ -359,8 +385,8 @@ internal sealed class RuntimeSetPositionState : IDisposable internal ulong ProjectionSequence { get; set; } internal bool WakeableLostCell { get; set; } internal RuntimeEntityPlacementStage Stage { get; set; } - internal RuntimeSetPositionOperationKind Kind { get; init; } - internal RuntimePortalPlacementAuthority Portal { get; init; } + internal RuntimeSetPositionOperationKind Kind { get; set; } + internal RuntimePortalPlacementAuthority Portal { get; set; } internal bool RequiresPreparation { get; set; } internal bool Expired { get; set; } internal List? LostFamilyKeys { get; set; } @@ -372,6 +398,69 @@ internal sealed class RuntimeSetPositionState : IDisposable get; set; } + + /// + /// F3: pool-membership guard, not operation data. Set true by + /// right before pushing; cleared + /// here (called from right after + /// popping). Lets RetireOperationToPool detect and throw on a + /// double-retire - two retire calls for the same instance without an + /// intervening rent would otherwise silently duplicate it in the + /// pool stack. + /// + internal bool InPool { get; set; } + + /// + /// C2/F4: the ONLY place every field is set to its inert default - + /// this is the completeness net that `required`/`init` used to + /// provide before pooling needed plain settable properties. Called + /// exactly once when an Operation is retired to the pool + /// (), before it can be handed + /// back out by . is + /// left null! only for the instant the instance sits in the pool - + /// every rent site immediately overwrites it before any other + /// member is read. A reflection-based test + /// (OperationResetAllFieldsToDefaultTouchesEveryDeclaredField) pins + /// that this method assigns every declared instance field of this + /// class by name - a newly added field fails that test until both + /// the reset and the test's expected-field list are updated. + /// + internal void ResetAllFieldsToDefault() + { + Record = null!; + Body = null; + Token = default; + Key = default; + PositionAuthorityVersion = 0UL; + SessionLifetimeVersion = 0UL; + SourceSpatialAuthorityVersion = 0UL; + SourceVelocityAuthorityVersion = 0UL; + PreviousContact = false; + PreviousOnWalkable = false; + Command = default; + Result = default; + SpatialAuthorityVersion = 0UL; + PlacementCommitVersion = 0UL; + ExactCellId = 0u; + CollisionGeneration = 0UL; + CollisionPrefix = 0u; + WithdrawalAcknowledged = false; + CollisionGenerationReady = false; + CollisionQuiescenceHeld = false; + ProjectionSequence = 0UL; + WakeableLostCell = false; + Stage = default; + Kind = default; + Portal = default; + RequiresPreparation = false; + Expired = false; + LostFamilyKeys = null; + InheritedLostDeadline = false; + EnteringWorldFromCelllessResidence = false; + DormantLocalActivation = false; + PreparedCommandAwaitingWithdrawalAck = null; + InPool = false; + } } private sealed class CollisionPrefixQuiescence @@ -393,9 +482,24 @@ internal sealed class RuntimeSetPositionState : IDisposable internal bool ReleaseGenerationReady { get; set; } } + /// + /// F1: captures the operation's PositionAuthorityVersion/ + /// SpatialAuthorityVersion as PLAIN VALUES at construction time instead + /// of holding an reference and reading its + /// fields lazily inside . IsCurrent can run + /// DURING the ground-edge HitGround/LeaveGround callbacks this guard is + /// built for - a synchronous cancel-then-begin (or begin-twice) chain + /// for the SAME entity can retire this exact Operation instance to the + /// pool and rent it right back out (LIFO) for a different logical + /// operation before this guard is asked whether it is still current. A + /// live Operation reference would then silently read the WRONG + /// operation's authority versions; these captured scalars cannot be + /// repurposed out from under it. + /// private sealed class ContactCommitGuard( RuntimeSetPositionState owner, - Operation operation, + ulong positionAuthorityVersion, + ulong spatialAuthorityVersion, RuntimeEntityRecord record, PhysicsBody body, ulong placementCommitVersion, @@ -403,7 +507,8 @@ internal sealed class RuntimeSetPositionState : IDisposable { internal bool IsCurrent() => owner.IsCanonicalPlacementCommitCurrent( - operation, + positionAuthorityVersion, + spatialAuthorityVersion, record, body, placementCommitVersion, @@ -452,12 +557,163 @@ internal sealed class RuntimeSetPositionState : IDisposable double Deadline, ulong Sequence); + /// + /// C2: everything reads + /// to invoke + /// on behalf of the in-flight PhysicsEngine.SetPosition call. A + /// plain per-call lambda closing over the live + /// (and, at one call site, a local canonicalCommand) allocated a + /// fresh display class every accepted placement; this struct is pushed by + /// value onto instead so the ONE + /// cached delegate below never needs a new closure. A stack (not a single + /// field) survives any theoretical nested/re-entrant SetPosition call at + /// the PhysicsEngine layer - TransitionScratchArena.ActiveDepth/ + /// Capacity implies nesting is possible there even though + /// itself + /// never calls back into this class. + /// + private readonly record struct CollisionCallbackContext( + RuntimeEntityRecord Record, + ulong PositionAuthorityVersion, + ulong SpatialAuthorityVersion, + ulong VelocityAuthorityVersion, + double GameTime, + bool PreviousContact, + bool PreviousOnWalkable); + + private readonly Stack _collisionCallbackContexts + = new(4); + private readonly Func + _handleSetPositionCollisionsCallback; + + // C2: retired Operation instances wait here for reuse by + // BeginAcceptedPlacementCore instead of a fresh `new Operation` every + // accepted placement. Bounded so a pathological retirement/rent + // imbalance (e.g. many entities despawning with none spawning) cannot + // grow this into an unbounded retained cache - beyond the cap we simply + // let the retired instance become garbage, exactly like before pooling + // existed. + private const int MaxPooledOperations = 64; + private readonly Stack _operationPool = new(); + internal RuntimeSetPositionState( RuntimePhysicsState physics, RuntimeEntityDirectory entities) { _physics = physics ?? throw new ArgumentNullException(nameof(physics)); _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _handleSetPositionCollisionsCallback = + HandleSetPositionCollisionsCallback; + } + + /// + /// C2: the single cached delegate every accepted-placement SetPosition + /// call passes to PhysicsEngine.SetPosition in place of a fresh + /// per-call closure. Reads the innermost pushed + /// rather than capturing state + /// directly, so this delegate instance (created once in the + /// constructor) is reused for the lifetime of this owner. + /// + private bool HandleSetPositionCollisionsCallback( + PhysicsSetPositionCollisionReport report) + { + CollisionCallbackContext context = _collisionCallbackContexts.Peek(); + return _physics.HandleSetPositionCollisions( + context.Record, + context.PositionAuthorityVersion, + context.SpatialAuthorityVersion, + context.VelocityAuthorityVersion, + context.GameTime, + context.PreviousContact, + context.PreviousOnWalkable, + report); + } + + /// + /// C2: returns a pooled instance reset to inert defaults right here (NOT + /// at retirement - see for why that + /// ordering matters), or allocates a fresh instance exactly as + /// BeginAcceptedPlacementCore did before pooling existed. Callers + /// MUST set every field they previously set via object-initializer + /// syntax - a rented instance's unset fields are the SAME defaults a + /// brand-new instance would have (see + /// ), never leftover + /// state from a prior use. + /// + private Operation RentOperation() + { + if (_operationPool.Count == 0) + return new Operation(); + Operation pooled = _operationPool.Pop(); + pooled.ResetAllFieldsToDefault(); + return pooled; + } + + /// + /// C2: the only place an Operation is retired to the pool - called the + /// instant one is removed from for good. + /// Deliberately does NOT reset the instance here (that happens in + /// instead, right before reuse): a reentrant + /// callback chain can retire the operation the OUTER frame is still + /// executing inside of and needs to keep reading. + /// + /// Round 3 correction: an earlier revision of this comment claimed + /// IsCanonicalPlacementCommitCurrent additionally compared the + /// operation's Token to detect exactly this recycling. That claim was + /// wrong and the check it described was reverted - retail's + /// SetPositionInternal settle is UNCONDITIONAL, so an in-flight + /// ground-edge commit for an entity a reentrant cancel-then-begin has + /// since displaced must still complete its physical settle (contact + /// transition, collision reports, shadow sync) - see + /// ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation. A + /// Token/identity gate on the settle path itself would incorrectly abort + /// that commit the moment the entity is displaced. + /// + /// The ACTUAL safety mechanism, class-wide, is captured-token-vs- + /// fresh-lookup at every frame that holds an Operation reference across + /// a reentrancy point (a synchronous publish, a collision-report + /// dispatch that can reach an arbitrary + /// IRuntimeCollisionReportObserver, or a ground-edge HitGround/ + /// LeaveGround callback): capture operation.Token (globally + /// unique - checked(++_nextOperationId) is never reissued) into a + /// local BEFORE the reentrancy point, then after it re-resolve via a + /// fresh _operations.TryGetValue and compare + /// current.Token == capturedToken - never + /// ReferenceEquals/IsCurrent(Operation) against the + /// original reference, which becomes a tautology once pooling can hand + /// that SAME physical instance back out for a different logical + /// operation. This identity gate belongs at publication/cancellation/ + /// ownership decisions (see the token comparisons in + /// BeginAcceptedPlacementCore, SubmitPreparedPlacementCore, + /// RetryDeferred, and CommitCanonical's own bookkeeping- + /// write gate), never inside the settle/currency layer itself. + /// + /// F3 reorders BeginAcceptedPlacementCore to rent only AFTER this method + /// retires the displaced operation - every field this class still needs + /// from a displaced operation is captured into a local before that + /// retire point (never read from the possibly-the-same, freshly-reset + /// instance after), so it is safe, and more efficient, for a single- + /// entity churn cycle to hand the SAME instance right back out as the + /// next operation (LIFO). guards the one + /// invariant that ordering depends on: this method must never be called + /// twice for the same instance without an intervening + /// in between - that would silently + /// duplicate the instance in , so it throws + /// instead. + /// + private void RetireOperationToPool(Operation operation) + { + if (operation.InPool) + { + throw new InvalidOperationException( + "Operation was already retired to the pool - a double-retire " + + "without an intervening rent would duplicate it in the pool " + + "stack."); + } + if (_operationPool.Count >= MaxPooledOperations) + return; + operation.InPool = true; + _operationPool.Push(operation); } internal RuntimeSetPositionOwnershipSnapshot CaptureOwnership() @@ -501,7 +757,8 @@ internal sealed class RuntimeSetPositionState : IDisposable _placementCompletionWatches.Count, _acknowledgedPlacementCompletions.Count, _collisionPrefixQuiescence.Count, - pendingQuiescenceProjections); + pendingQuiescenceProjections, + _operationPool.Count); } internal int PendingProjectionCount => _pendingProjection.Count; @@ -922,6 +1179,8 @@ internal sealed class RuntimeSetPositionState : IDisposable in RuntimeEntityPlacementToken token) { EnsureNotDisposed(); + // Round 3 audit: safe - fresh lookup + Token check on the SAME line + // immediately precede IsCurrent, nothing reentrant in between. return token.IsValid && _operations.TryGetValue(token.Entity, out Operation? operation) && operation.Token == token @@ -998,7 +1257,11 @@ internal sealed class RuntimeSetPositionState : IDisposable { return default; } - return CancelCore(operation); + // Round 3: `token` is this call's own parameter, verified fresh + // against `_operations` immediately above with nothing reentrant in + // between - passing it straight through is equivalent to (and safer + // than) re-deriving it from `operation`. + return CancelCore(token.Entity, token); } internal RuntimeEntityPlacementToken TryBeginExclusiveAuthoredPlacement( @@ -1031,6 +1294,8 @@ internal sealed class RuntimeSetPositionState : IDisposable EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(body); + // Round 3 audit: safe - fresh lookup + Token check earlier in this + // SAME guard clause precede IsCurrent, nothing reentrant in between. if (!token.IsValid || record.Key != token.Entity || !_operations.TryGetValue(token.Entity, out Operation? operation) @@ -1088,28 +1353,19 @@ internal sealed class RuntimeSetPositionState : IDisposable captureMoverPreparationAuthority ? RuntimeEntityPlacementPreparationKind.AuthoredMover : RuntimeEntityPlacementPreparationKind.LegacyDirect); - var replacement = new Operation - { - Record = record, - Token = token, - Key = key, - PositionAuthorityVersion = expectedPositionAuthorityVersion, - SessionLifetimeVersion = _entities.SessionLifetimeVersion, - SourceSpatialAuthorityVersion = record.SpatialAuthorityVersion, - SourceVelocityAuthorityVersion = record.VelocityAuthorityVersion, - PreviousContact = record.PhysicsBody?.InContact ?? false, - PreviousOnWalkable = record.PhysicsBody?.OnWalkable ?? false, - Command = default, - Result = default, - SpatialAuthorityVersion = record.SpatialAuthorityVersion, - PlacementCommitVersion = record.PlacementCommitVersion, - Stage = RuntimeEntityPlacementStage.AwaitingPreparation, - Kind = kind, - Portal = portal, - }; List? inheritedLostFamily = null; RuntimePlacementProjectionSnapshot? inheritedWithdrawal = null; bool inheritedWithdrawalAcknowledged = false; + // F3: captured into locals (rather than re-read from `displaced` + // after CancelCoreDeferred below) because `replacement` is + // deliberately rented AFTER that retire - see the no-self-aliasing + // note below and RetireOperationToPool's doc comment. Reading every + // scalar this method still needs before the retire point keeps this + // correct regardless of which physical instance `replacement` ends + // up being. + PhysicsSetPositionResult inheritedResult = default; + uint inheritedExactCellId = 0u; + ulong inheritedCollisionGeneration = 0UL; if (_operations.TryGetValue(key, out Operation? displaced) && (displaced.WakeableLostCell || displaced.InheritedLostDeadline)) @@ -1118,6 +1374,9 @@ internal sealed class RuntimeSetPositionState : IDisposable displaced.LostFamilyKeys = null; inheritedWithdrawalAcknowledged = displaced.WithdrawalAcknowledged; + inheritedResult = displaced.Result; + inheritedExactCellId = displaced.ExactCellId; + inheritedCollisionGeneration = displaced.CollisionGeneration; if (displaced.ProjectionSequence != 0UL && _pendingProjection.TryGetValue( displaced.ProjectionSequence, @@ -1134,6 +1393,36 @@ internal sealed class RuntimeSetPositionState : IDisposable cancelLostFamily: false, preserveLostFamily: inheritedLostFamily is not null, out RuntimePlacementProjectionSnapshot? discard); + + // F3: rent only AFTER the retire above (not before, as the C2 + // landing originally had it). Every field this method needs from a + // displaced operation was already captured into the locals above, + // so it is both SAFE and efficient for a single-entity churn cycle + // to hand that exact instance right back out here (the pool is + // LIFO) instead of allocating or drawing a different pooled + // instance - a fresh RentOperation() call always starts from + // `ResetAllFieldsToDefault`'s inert state regardless of which + // instance it returns. + Operation replacement = RentOperation(); + replacement.Record = record; + replacement.Token = token; + replacement.Key = key; + replacement.PositionAuthorityVersion = expectedPositionAuthorityVersion; + replacement.SessionLifetimeVersion = _entities.SessionLifetimeVersion; + replacement.SourceSpatialAuthorityVersion = + record.SpatialAuthorityVersion; + replacement.SourceVelocityAuthorityVersion = + record.VelocityAuthorityVersion; + replacement.PreviousContact = record.PhysicsBody?.InContact ?? false; + replacement.PreviousOnWalkable = + record.PhysicsBody?.OnWalkable ?? false; + replacement.Command = default; + replacement.Result = default; + replacement.SpatialAuthorityVersion = record.SpatialAuthorityVersion; + replacement.PlacementCommitVersion = record.PlacementCommitVersion; + replacement.Stage = RuntimeEntityPlacementStage.AwaitingPreparation; + replacement.Kind = kind; + replacement.Portal = portal; replacement.LostFamilyKeys = inheritedLostFamily; replacement.InheritedLostDeadline = inheritedLostFamily is not null; replacement.WithdrawalAcknowledged = inheritedWithdrawalAcknowledged; @@ -1141,10 +1430,9 @@ internal sealed class RuntimeSetPositionState : IDisposable { replacement.ProjectionSequence = retainedWithdrawal.Token.Sequence; - replacement.Result = displaced!.Result; - replacement.ExactCellId = displaced.ExactCellId; - replacement.CollisionGeneration = - displaced.CollisionGeneration; + replacement.Result = inheritedResult; + replacement.ExactCellId = inheritedExactCellId; + replacement.CollisionGeneration = inheritedCollisionGeneration; } _operations[key] = replacement; if (captureMoverPreparationAuthority) @@ -1156,7 +1444,26 @@ internal sealed class RuntimeSetPositionState : IDisposable } if (discard is { } cancelled) PublishPlacement(cancelled); - return IsCurrent(replacement) ? token : default; + // F3: deliberately does NOT use `IsCurrent(replacement)` here. + // `PublishPlacement` above can synchronously notify a subscriber + // that reentrantly calls BeginAcceptedPlacement for the SAME entity + // (see ReentrantBeginDuringDiscardCannotBeOverwrittenByOuterBegin) - + // that reentrant call retires `replacement` and, per this method's + // rent-after-retire ordering, can rent it right back out (LIFO) for + // the INNER operation. `replacement` (the physical object) would + // then read as the inner operation's Key/Token, and comparing it + // against itself via `_operations.TryGetValue(replacement.Key, ...) + // && ReferenceEquals(current, replacement)` is a tautology - it + // would report "still current" even though THIS (outer) Begin + // invocation was clearly superseded. `token` was captured fresh at + // the top of this call, before any reentrancy could touch it, so + // comparing it against whatever now actually owns the key correctly + // answers "is my own invocation still canonical" regardless of + // what happened to `replacement` in between. + return _operations.TryGetValue(key, out Operation? currentOperation) + && currentOperation.Token == token + ? token + : default; } private bool HasRetainedCompletion(RuntimeEntityKey key) @@ -1177,6 +1484,8 @@ internal sealed class RuntimeSetPositionState : IDisposable { EnsureNotDisposed(); command = default; + // Round 3 audit: safe - fresh lookup + Token check earlier in this + // SAME guard clause precede IsCurrent, nothing reentrant in between. if (!token.IsValid || !_operations.TryGetValue(token.Entity, out Operation? operation) || operation.Token != token @@ -1322,6 +1631,9 @@ internal sealed class RuntimeSetPositionState : IDisposable { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(record); + // Round 3 audit: safe - fresh lookup + Token check earlier in this + // SAME return expression precede IsCurrent, nothing reentrant in + // between. return token.IsValid && token.Entity == record.Key && _operations.TryGetValue(token.Entity, out Operation? operation) @@ -1396,6 +1708,13 @@ internal sealed class RuntimeSetPositionState : IDisposable ulong objectTableBindingAuthority = _physics.ObjectTableBindingAuthority; ulong objectTableAuthority = objectTable?.MutationRevision ?? 0UL; + // Round 3 audit: safe - `handleCollisions: null` means this call + // never invokes HandleSetPositionCollisions/HandleReports/ + // ReportEnvironment (no collision-report observer can be reached), + // and this dormant-activation path never calls CommitCanonical (so + // no ground-edge HitGround/LeaveGround dispatch either) - there is + // no reentrancy point between `operation` being obtained above and + // the ReferenceEquals check below. PhysicsSetPositionResult result = _physics.Engine.SetPosition( canonicalRequest, handleCollisions: null); @@ -1836,6 +2155,12 @@ internal sealed class RuntimeSetPositionState : IDisposable SetPositionCollisionBatchDispatchResult dispatch = _physics .CollisionReports.DispatchSetPositionBatchResult(receipt.Collision); bool reported = dispatch.Reported; + // Round 3 audit: safe - fresh lookup + Token.OperationId check + // earlier in this SAME guard clause precede IsCurrent, nothing + // reentrant in between. This dormant-activation family has zero + // production callers and never routes through CommitCanonical's + // ground-edge callback or the live collision-report dispatch that + // can reach an arbitrary observer. if (receipt.Status is RuntimeDormantSetPositionCommitStatus.RejectedPlacement && _operations.TryGetValue(receipt.Entity, out Operation? operation) @@ -1860,6 +2185,10 @@ internal sealed class RuntimeSetPositionState : IDisposable PhysicsBody body, in RuntimeDormantSetPositionCommitReceipt receipt) { + // Round 3 audit: safe - fresh lookup + Token.OperationId check + // earlier in this SAME return expression precede IsCurrent, nothing + // reentrant in between; this dormant family has zero production + // callers. return receipt.Status is RuntimeDormantSetPositionCommitStatus .AwaitingFinalShadowPreparation && _operations.TryGetValue(receipt.Entity, out Operation? operation) @@ -1898,6 +2227,10 @@ internal sealed class RuntimeSetPositionState : IDisposable if (receipt.Status is RuntimeDormantSetPositionCommitStatus .AwaitingFinalShadowPreparation) return IsDormantLocalActivationPrephaseCurrent(record, body, receipt); + // Round 3 audit: safe - fresh lookup + Token.OperationId check + // earlier in this SAME return expression precede IsCurrent, nothing + // reentrant in between; this dormant family has zero production + // callers. return receipt.Status is RuntimeDormantSetPositionCommitStatus .RejectedPlacement && _operations.TryGetValue(receipt.Entity, out Operation? operation) @@ -1938,6 +2271,15 @@ internal sealed class RuntimeSetPositionState : IDisposable PhysicsBody body, in RuntimeDormantSetPositionCommitReceipt receipt) { + // Round 3 audit: safe - fresh lookup + Token.OperationId check + // immediately precede IsCurrent, nothing reentrant in between; this + // dormant family has zero production callers. Nothing between here + // and the final `IsCurrent(operation)` below invokes anything + // reentrant either (IsVelocityCurrent/HandleAllCollisions/ + // CommitStationaryBits are pure PhysicsObjUpdate calls on `body`, + // and the nested IsDormantLocalActivationPrephaseCurrent call is + // itself a fresh-lookup check), so the SAME verified `operation` + // reference remains valid through the final check. if (!_operations.TryGetValue(receipt.Entity, out Operation? operation) || operation.Token.OperationId != receipt.OperationId || !IsCurrent(operation) @@ -2115,7 +2457,10 @@ internal sealed class RuntimeSetPositionState : IDisposable if (_operations.TryGetValue(receipt.Entity, out Operation? operation) && operation.Token.OperationId == receipt.OperationId) { - _ = CancelCore(operation); + // Round 3: `operation.Token` is read from the fresh lookup + // immediately above, with nothing reentrant in between - safe + // to pass straight through as the captured token. + _ = CancelCore(receipt.Entity, operation.Token); } } @@ -2131,7 +2476,10 @@ internal sealed class RuntimeSetPositionState : IDisposable { return; } - _ = CancelCore(operation); + // Round 3: `token` is this call's own parameter, verified fresh + // against `_operations` immediately above with nothing reentrant in + // between. + _ = CancelCore(token.Entity, token); } internal bool IsDormantLocalActivationCommitCurrent( @@ -2139,6 +2487,9 @@ internal sealed class RuntimeSetPositionState : IDisposable PhysicsBody body, in RuntimeDormantSetPositionCommitReceipt receipt) { + // Round 3 audit: safe - fresh lookup + Stage/ProjectionSequence + // check earlier in this SAME guard clause precede IsCurrent, + // nothing reentrant in between. if (!receipt.IsCommitted || !receipt.Projection.Token.IsValid || record.Key != receipt.Projection.Token.Entity @@ -2246,6 +2597,11 @@ internal sealed class RuntimeSetPositionState : IDisposable token.Entity, out exactAuthority) && exactAuthority.OperationId == token.OperationId; + // Round 3 audit: safe - `ownsToken` (fresh lookup + Token check) + // was just computed above with nothing reentrant in between; this + // is the function's own entry validation, before any of its + // internal reentrancy points (the SetPosition/CommitCanonical calls + // further down, which ARE converted to IsCurrentByToken). if (!ownsToken || operation is null || operation.Stage @@ -2399,19 +2755,34 @@ internal sealed class RuntimeSetPositionState : IDisposable quiescence.Token.LandblockPrefix); } - PhysicsSetPositionResult result = - _physics.Engine.SetPosition( + PhysicsSetPositionResult result; + _collisionCallbackContexts.Push(new CollisionCallbackContext( + operation.Record, + operation.PositionAuthorityVersion, + operation.SourceSpatialAuthorityVersion, + operation.SourceVelocityAuthorityVersion, + canonicalCommand.GameTime, + operation.PreviousContact, + operation.PreviousOnWalkable)); + try + { + result = _physics.Engine.SetPosition( canonicalRequest, - report => _physics.HandleSetPositionCollisions( - operation.Record, - operation.PositionAuthorityVersion, - operation.SourceSpatialAuthorityVersion, - operation.SourceVelocityAuthorityVersion, - canonicalCommand.GameTime, - operation.PreviousContact, - operation.PreviousOnWalkable, - report)); - if (!IsCurrent(operation)) + _handleSetPositionCollisionsCallback); + } + finally + { + _collisionCallbackContexts.Pop(); + } + // Round 3: `_physics.Engine.SetPosition` above can reenter this class + // - its collision-report callback can reach an arbitrary + // IRuntimeCollisionReportObserver subscriber that calls back into + // Begin/Cancel for this (or any) entity, which can retire-then-rent + // (LIFO) this exact `operation` instance for a different logical + // operation. `token` (the function parameter, captured before any + // of this ran) proves identity by value instead of trusting + // `operation`'s live fields. + if (!IsCurrentByToken(token.Entity, token, out operation)) return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); if (result.IsSuccessful && TryGetBlockingQuiescence( @@ -2442,7 +2813,10 @@ internal sealed class RuntimeSetPositionState : IDisposable return Outcome(RuntimeSetPositionStatus.Rejected, result, default); } - if (!IsCurrent(operation)) + // Round 3: same reentrancy hazard as the check right after the + // SetPosition call above - re-verify by token rather than trusting + // `operation` across the collision-report dispatch. + if (!IsCurrentByToken(token.Entity, token, out operation)) return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); operation.RequiresPreparation = false; operation.ExactCellId = result.CellId; @@ -2453,7 +2827,29 @@ internal sealed class RuntimeSetPositionState : IDisposable if (!CommitCanonical(operation, result)) { - PublishCancellation(CancelCore(operation)); + // Round 3: CommitCanonical's own ground-edge callback and + // collision-report dispatch can reenter this class, so + // `operation` may already be stale here even though it was + // just re-verified before the call. `token` (this function's + // own parameter, untouched since entry) is the safe capture. + PublishCancellation(CancelCore(token.Entity, token)); + return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); + } + + // F1: CommitCanonical can succeed (fully applying the physical + // settle - contact transition, collision reports, shadow sync) for + // an operation a reentrant ground-edge callback has since displaced + // - retail lets that physical commit land regardless (see + // ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation). + // But THIS caller's own operation is no longer canonical, so it must + // still report Cancelled rather than publish a Place projection + // nothing will ever acknowledge again. `token` (the parameter, + // captured before any of this ran) is compared against a fresh + // lookup instead of trusting `operation`'s live fields, which may + // already reflect whatever displaced it. + if (!_operations.TryGetValue(token.Entity, out Operation? stillOwns) + || stillOwns.Token != token) + { return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); } @@ -2469,6 +2865,38 @@ internal sealed class RuntimeSetPositionState : IDisposable projection); } + /// + /// C2: zero-allocation replacement for the LINQ + /// _pendingProjection.First() pattern used at every call site + /// below. Enumerable.First<TSource> takes an + /// IEnumerable<TSource> parameter, so calling it on a + /// dispatches through the + /// interface-typed IEnumerable<KeyValuePair<TKey, + /// TValue>>.GetEnumerator(), which BOXES the dictionary's + /// normally-struct Enumerator - this was the entire measured C2 + /// acknowledgement-path residual (120 B/op). A plain foreach on + /// the concrete field type + /// resolves to its public non-interface, struct-returning + /// GetEnumerator() instead and never boxes. Every call site below + /// already checks _pendingProjection.Count != 0 immediately + /// before calling this (short-circuiting `||`/`&&`), exactly + /// mirroring the precondition LINQ's First() relied on - the + /// throw path is unreachable in current usage, kept only so a future + /// caller that skips the guard fails loudly instead of silently, same as + /// LINQ's own contract would have. + /// + private KeyValuePair + FirstPendingProjection() + { + foreach (KeyValuePair entry + in _pendingProjection) + { + return entry; + } + throw new InvalidOperationException( + "FirstPendingProjection requires at least one pending entry."); + } + internal bool TryPeekProjection( out RuntimePlacementProjectionSnapshot projection) { @@ -2478,7 +2906,7 @@ internal sealed class RuntimeSetPositionState : IDisposable projection = default; return false; } - projection = _pendingProjection.First().Value; + projection = FirstPendingProjection().Value; return true; } @@ -2488,7 +2916,7 @@ internal sealed class RuntimeSetPositionState : IDisposable EnsureNotDisposed(); if (!token.IsValid || _pendingProjection.Count == 0 - || _pendingProjection.First().Key != token.Sequence + || FirstPendingProjection().Key != token.Sequence || !_pendingProjection.TryGetValue( token.Sequence, out RuntimePlacementProjectionSnapshot pending) @@ -2516,6 +2944,9 @@ internal sealed class RuntimeSetPositionState : IDisposable } return true; } + // Round 3 audit: safe - fresh lookup + ProjectionSequence check + // immediately precede IsCurrent, nothing reentrant in between (this + // is AcknowledgeProjection's own entry validation). if (!_operations.TryGetValue( token.Entity, out Operation? operation) @@ -2540,13 +2971,19 @@ internal sealed class RuntimeSetPositionState : IDisposable } operation.Stage = RuntimeEntityPlacementStage.AwaitingCommitAcknowledgement; _moverPreparationAuthorities.Remove(operation.Key); - return _operations.Remove(operation.Key); + bool removed = _operations.Remove(operation.Key); + if (removed) + RetireOperationToPool(operation); + return removed; } if (!operation.WakeableLostCell && !operation.InheritedLostDeadline) { _moverPreparationAuthorities.Remove(operation.Key); - return _operations.Remove(operation.Key); + bool removed = _operations.Remove(operation.Key); + if (removed) + RetireOperationToPool(operation); + return removed; } operation.WithdrawalAcknowledged = true; @@ -2649,9 +3086,21 @@ internal sealed class RuntimeSetPositionState : IDisposable } var operation = CreateWithdrawalOperation(record, key); _operations[key] = operation; + // Round 3: `operation.Token` is captured HERE, before + // PublishPlacement below can synchronously reenter this class - a + // subscriber reacting to the withdrawal-of-the-old-operation + // notification can call Begin/Cancel for this SAME entity, which + // (per RetireOperationToPool's doc comment) can retire-then-rent + // (LIFO) this exact `operation` instance for a brand-new logical + // operation before the checks below run. Comparing + // `ReferenceEquals`/`IsCurrent(operation)` against the stale + // reference afterward would be a tautology - it would report "still + // current" and then publish a Withdraw projection carrying the + // NEWER operation's state under the OLD operation's identity. + RuntimeEntityPlacementToken capturedToken = operation.Token; if (discard is { } cancelledOld) PublishPlacement(cancelledOld); - if (!IsCurrent(operation)) + if (!IsCurrentByToken(key, capturedToken, out operation)) return true; _ = PublishProjection( operation, @@ -2821,33 +3270,39 @@ internal sealed class RuntimeSetPositionState : IDisposable RuntimeSetPositionOperationKind.RemoteAuthoritative, body.LastUpdateTime, record.VelocityAuthorityVersion); - var operation = new Operation - { - Record = record, - Body = body, - Token = new RuntimeEntityPlacementToken( - _entities.SessionLifetimeVersion, - key, - record.PositionAuthorityVersion, - checked(++_nextOperationId), - RuntimeEntityPlacementPreparationKind.AuthoredMover), - Key = key, - PositionAuthorityVersion = record.PositionAuthorityVersion, - SessionLifetimeVersion = _entities.SessionLifetimeVersion, - SourceSpatialAuthorityVersion = record.SpatialAuthorityVersion, - SourceVelocityAuthorityVersion = record.VelocityAuthorityVersion, - PreviousContact = body.InContact, - PreviousOnWalkable = body.OnWalkable, - Command = command, - Result = result, - SpatialAuthorityVersion = record.SpatialAuthorityVersion, - PlacementCommitVersion = record.PlacementCommitVersion, - ExactCellId = cellId, - Stage = RuntimeEntityPlacementStage.AwaitingPreparation, - Kind = RuntimeSetPositionOperationKind.RemoteAuthoritative, - Portal = default, - RequiresPreparation = !hasPrepared, - }; + // F4: routed through RentOperation() (rather than a fresh + // `new Operation { ... }`) so every construction flows one path + // - see ResetAllFieldsToDefault's completeness-net doc comment. + // No key/displaced-operation collision is possible here: the + // loop above already skips this record when + // `_operations.ContainsKey(key)`. + Operation operation = RentOperation(); + operation.Record = record; + operation.Body = body; + operation.Token = new RuntimeEntityPlacementToken( + _entities.SessionLifetimeVersion, + key, + record.PositionAuthorityVersion, + checked(++_nextOperationId), + RuntimeEntityPlacementPreparationKind.AuthoredMover); + operation.Key = key; + operation.PositionAuthorityVersion = record.PositionAuthorityVersion; + operation.SessionLifetimeVersion = _entities.SessionLifetimeVersion; + operation.SourceSpatialAuthorityVersion = + record.SpatialAuthorityVersion; + operation.SourceVelocityAuthorityVersion = + record.VelocityAuthorityVersion; + operation.PreviousContact = body.InContact; + operation.PreviousOnWalkable = body.OnWalkable; + operation.Command = command; + operation.Result = result; + operation.SpatialAuthorityVersion = record.SpatialAuthorityVersion; + operation.PlacementCommitVersion = record.PlacementCommitVersion; + operation.ExactCellId = cellId; + operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation; + operation.Kind = RuntimeSetPositionOperationKind.RemoteAuthoritative; + operation.Portal = default; + operation.RequiresPreparation = !hasPrepared; _operations.Add(key, operation); _moverPreparationAuthorities[key] = CapturePreparationAuthority( operation, @@ -2970,7 +3425,7 @@ internal sealed class RuntimeSetPositionState : IDisposable private bool HasPendingProjectionThrough(ulong barrierSequence) => barrierSequence != 0UL && _pendingProjection.Count != 0 - && _pendingProjection.First().Key <= barrierSequence; + && FirstPendingProjection().Key <= barrierSequence; private bool HasCollisionDispatchDebt() { @@ -3153,8 +3608,27 @@ internal sealed class RuntimeSetPositionState : IDisposable bool ready, bool releaseUnavailable = false) { - foreach (Operation operation in _operations.Values.ToArray()) + // Round 3: snapshot (Key, Token) PAIRS, not Operation references. + // This loop's own RetryDeferred call below can reenter this class + // (CommitCanonical's ground-edge callbacks, or an arbitrary + // collision-report observer reentrantly calling Begin/Cancel) and + // retire-then-rent (LIFO) a LATER array entry's physical instance + // for a brand-new operation before this loop ever reaches it - a + // stale `Operation` reference captured once at the top (the + // pre-round-3 `.ToArray()` shape) would then silently read/mutate + // the wrong logical operation. Re-resolving by captured Token at + // the top of every iteration (before touching any field) detects + // that instead. + var snapshot = + new List<(RuntimeEntityKey Key, RuntimeEntityPlacementToken Token)>( + _operations.Count); + foreach (Operation existing in _operations.Values) + snapshot.Add((existing.Key, existing.Token)); + foreach ((RuntimeEntityKey key, RuntimeEntityPlacementToken capturedToken) + in snapshot) { + if (!IsCurrentByToken(key, capturedToken, out Operation? operation)) + continue; bool unavailableAfterReadyCommit = ready && operation.CollisionQuiescenceHeld && operation.CollisionGeneration == 0UL @@ -3333,6 +3807,18 @@ internal sealed class RuntimeSetPositionState : IDisposable continue; } RemoveDeferredBucket(cell); + // Round 3 audit: safe - `exact` snapshots KEYS (RuntimeEntityKey + // values), never Operation references, so nothing here can go + // stale the way a snapshotted-reference loop + // (RebindQuiescedDeferredOperations, before its round-3 fix) + // could. Every iteration re-resolves `operation` via a fresh + // `_operations.TryGetValue` and re-validates the exact + // WakeableLostCell/ExactCellId/CollisionPrefix/ + // CollisionGeneration shape before acting - a reentrant + // cancel-then-begin from an earlier iteration's RetryDeferred + // call (itself converted to IsCurrentByToken) is either not + // found at all or correctly rejected by this shape check for + // any later iteration touching the same or a different entity. foreach (RuntimeEntityKey entity in exact) { if (!_operations.TryGetValue( @@ -3379,6 +3865,16 @@ internal sealed class RuntimeSetPositionState : IDisposable _pendingProjection.Clear(); _expiredLostCells.Clear(); _expiredLostCellNodes.Clear(); + // F2: the C2 object pool retains full previous-generation entity + // graphs (Record -> Snapshot/PhysicsBody/clock/host references) + // through every pooled instance until it is rented and reset. Both + // callers of this method (ResetSession and Dispose) end the session + // those graphs belonged to, so nothing can still be mid-rent across + // this clear - unlike RetireOperationToPool/RentOperation, which + // must worry about a reentrant frame still executing inside a + // ground-edge callback, a session clear cannot be reentered from + // inside itself. + _operationPool.Clear(); } private RuntimeSetPositionOutcome ParkDeferred( @@ -3472,6 +3968,21 @@ internal sealed class RuntimeSetPositionState : IDisposable projection); } + /// + /// Round 3 audit: the entry check + /// below is safe (not converted to ) + /// because every one of this method's 4 call sites passes an + /// reference obtained via a fresh + /// _operations lookup (or an call) + /// with NOTHING reentrant executed between that lookup and this call - + /// AcknowledgeProjection and SubmitPreparedPlacementCore + /// call it immediately after their own entry validation, the + /// exact-cell-ready loop re-resolves by key every iteration and rejects + /// a repurposed operation via its WakeableLostCell/ExactCellId/ + /// CollisionPrefix/CollisionGeneration shape, and + /// RebindQuiescedDeferredOperations was converted in round 3 to + /// call immediately before this method. + /// private void RetryDeferred(Operation operation) { // The local-player activation lease owns its dormant body/controller @@ -3494,6 +4005,14 @@ internal sealed class RuntimeSetPositionState : IDisposable if (!IsDeferredWakePreparationCurrent(operation)) return; + // F1: hoisted before the ground-edge-callback-bearing + // CommitCanonical call below (same reasoning as + // SubmitPreparedPlacementCore's `token` parameter) - lets the + // post-CommitCanonical check confirm THIS operation is still + // canonical without trusting `operation`'s live fields, which a + // reentrant cancel-then-begin during the callback may have already + // repurposed. + RuntimeEntityPlacementToken operationToken = operation.Token; RuntimeCollisionPrefixQuiescenceToken restoringQuiescence = default; if (TryGetBlockingQuiescence( operation.Command.Physics, @@ -3531,23 +4050,49 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.Command.GameTime), }; RebindPreparedCommand(operation); - PhysicsSetPositionResult result = IsStructurallyValid( - operation.Command.Physics) - ? _physics.Engine.SetPosition( - operation.Command.Physics, - report => _physics.HandleSetPositionCollisions( - operation.Record, - operation.PositionAuthorityVersion, - operation.SpatialAuthorityVersion, - operation.SourceVelocityAuthorityVersion, - operation.Command.GameTime, - operation.PreviousContact, - operation.PreviousOnWalkable, - report)) - : InvalidResult(operation.Command.Physics); + PhysicsSetPositionResult result; + if (IsStructurallyValid(operation.Command.Physics)) + { + _collisionCallbackContexts.Push(new CollisionCallbackContext( + operation.Record, + operation.PositionAuthorityVersion, + operation.SpatialAuthorityVersion, + operation.SourceVelocityAuthorityVersion, + operation.Command.GameTime, + operation.PreviousContact, + operation.PreviousOnWalkable)); + try + { + result = _physics.Engine.SetPosition( + operation.Command.Physics, + _handleSetPositionCollisionsCallback); + } + finally + { + _collisionCallbackContexts.Pop(); + } + } + else + { + result = InvalidResult(operation.Command.Physics); + } operation.CollisionGenerationReady = false; - if (!IsCurrent(operation)) + // Round 3: same reentrancy hazard as SubmitPreparedPlacementCore's + // post-SetPosition checks - the collision-report callback above can + // reach an arbitrary observer that calls back into Begin/Cancel and + // retires-then-rents (LIFO) this exact instance. `operationToken` + // was hoisted before the SetPosition call. (`operation` is a plain + // non-nullable parameter here, not a nullable local like + // SubmitPreparedPlacementCore's - route through a temporary so the + // NotNullWhen-proven reference can be assigned back to it.) + if (!IsCurrentByToken( + operationToken.Entity, + operationToken, + out Operation? refreshed)) + { return; + } + operation = refreshed; if (result.IsSuccessful && TryGetBlockingQuiescence( result, @@ -3605,12 +4150,47 @@ internal sealed class RuntimeSetPositionState : IDisposable } return; } - if (!IsCurrent(operation)) + // Round 3: nothing reentrant runs between here and the previous + // token check (only quiescence/deferred bookkeeping branches, all + // of which return before reaching this point) - re-verifying by + // token again anyway keeps this in lockstep with the same pattern + // used everywhere else in this method, and `operation` is already + // the freshly-verified reference from that check. + if (!IsCurrentByToken( + operationToken.Entity, + operationToken, + out Operation? stillCurrent)) + { return; + } + operation = stillCurrent; _preparedMovers[operation.Key] = operation.Command.Physics; if (!CommitCanonical(operation, result)) { - PublishCancellation(CancelCore(operation)); + // Round 3: CommitCanonical's own ground-edge callback and + // collision-report dispatch can reenter this class, so + // `operation` may already be stale here even though it was + // just re-verified before the call. + PublishCancellation(CancelCore(operationToken.Entity, operationToken)); + return; + } + + // F1: CommitCanonical can succeed (fully applying the physical + // settle) for an operation a reentrant ground-edge callback has + // since displaced - retail lets that physical commit land + // regardless (see + // ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation). + // But if THIS operation is no longer canonical, publishing a Place + // projection for it is wrong - nothing will ever acknowledge it + // again, and `operation`'s own fields may already reflect whatever + // displaced it. `operationToken` (hoisted before the callback) is + // compared against a fresh lookup rather than trusting `operation` + // itself. + if (!_operations.TryGetValue( + operationToken.Entity, + out Operation? stillOwns) + || stillOwns.Token != operationToken) + { return; } @@ -3626,10 +4206,49 @@ internal sealed class RuntimeSetPositionState : IDisposable Operation operation, in PhysicsSetPositionResult result) { + // Round 3 audit: safe - this is the function's own entry + // validation; every caller (SubmitPreparedPlacementCore, + // RetryDeferred) passes an `operation` obtained via + // IsCurrentByToken/IsCurrent immediately before calling + // CommitCanonical, with nothing reentrant in between. if (!result.IsCommitted || !IsCurrent(operation)) return false; RuntimeEntityRecord record = operation.Record; PhysicsBody body = operation.Body!; + // F1: every operation-derived scalar this method still needs AFTER + // invoking the ground-edge HitGround/LeaveGround callbacks below is + // captured into a local HERE, before those callbacks run - retail's + // own savedTransientState pattern (pseudo-C 283952 stacks the exact + // same bits before handle_all_collisions). A synchronous ground-edge + // chain that cancels then begins (or begins twice) for the SAME + // entity can retire this exact `operation` instance to the pool and + // rent it right back out (LIFO) for a DIFFERENT logical operation - + // reading `operation`'s live fields after that point would silently + // observe the wrong operation's state. The SETTLE below (contact + // transition, collision reports, shadow sync, and the currency + // checks gating them) deliberately does NOT hoist/compare the + // operation's Token: retail intentionally lets an in-flight + // ground-edge commit for a DISPLACED operation still complete (see + // IsCanonicalPlacementCommitCurrent's doc comment and + // ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation). + // `operationToken` is hoisted anyway - not for the settle, but for + // the Runtime-owned BOOKKEEPING writes at the very end of this + // method (Round 3 addendum A1), which must land on the operation + // THIS frame actually started with, never a nested cancel-then- + // begin's freshly-begun operation that happens to share the key and + // pass the record-state checks (a bare Begin never advances + // PlacementCommitVersion, so the settle-layer checks cannot detect + // that repurposing - only Token identity can). + RuntimeEntityPlacementToken operationToken = operation.Token; + RuntimeEntityKey operationKey = operation.Key; + ulong positionAuthorityVersion = operation.PositionAuthorityVersion; + ulong sourceVelocityAuthorityVersion = + operation.SourceVelocityAuthorityVersion; + double commandGameTime = operation.Command.GameTime; + bool previousContact = operation.PreviousContact; + bool previousOnWalkable = operation.PreviousOnWalkable; + float shadowWorldOffsetX = operation.Command.ShadowWorldOffsetX; + float shadowWorldOffsetY = operation.Command.ShadowWorldOffsetY; body.Orientation = result.Orientation; body.SnapToCell( result.CellId, @@ -3638,7 +4257,7 @@ internal sealed class RuntimeSetPositionState : IDisposable bool isStatic = (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0; if (operation.EnteringWorldFromCelllessResidence) { - body.LastUpdateTime = operation.Command.GameTime; + body.LastUpdateTime = commandGameTime; _entities.ResetObjectClockForEnterWorld(record, isStatic); } if (operation.EnteringWorldFromCelllessResidence && !isStatic) @@ -3664,6 +4283,7 @@ internal sealed class RuntimeSetPositionState : IDisposable (result.CellId & 0xFFFF0000u) | 0xFFFFu); } operation.SpatialAuthorityVersion = record.SpatialAuthorityVersion; + ulong spatialAuthorityVersion = record.SpatialAuthorityVersion; _entities.AdvancePlacementCommit(record); operation.PlacementCommitVersion = record.PlacementCommitVersion; ulong canonicalCommitVersion = record.PlacementCommitVersion; @@ -3681,7 +4301,8 @@ internal sealed class RuntimeSetPositionState : IDisposable System.Collections.Immutable.ImmutableArray collidedObjectIds = result.CollidedObjectIds; if (!IsCanonicalPlacementCommitCurrent( - operation, + positionAuthorityVersion, + spatialAuthorityVersion, record, body, canonicalCommitVersion, @@ -3695,13 +4316,14 @@ internal sealed class RuntimeSetPositionState : IDisposable body, result.InContact, result.OnWalkable, - operation.PreviousOnWalkable); + previousOnWalkable); } else { var guard = new ContactCommitGuard( this, - operation, + positionAuthorityVersion, + spatialAuthorityVersion, record, body, canonicalCommitVersion, @@ -3710,14 +4332,15 @@ internal sealed class RuntimeSetPositionState : IDisposable body, result.InContact, result.OnWalkable, - operation.PreviousOnWalkable, + previousOnWalkable, remote.HitGround, remote.LeaveGround, guard.IsCurrent); } if (!contactCommitted || !IsCanonicalPlacementCommitCurrent( - operation, + positionAuthorityVersion, + spatialAuthorityVersion, record, body, canonicalCommitVersion, @@ -3729,17 +4352,18 @@ internal sealed class RuntimeSetPositionState : IDisposable bool reportingCurrent = !IsCollisionReportingEligible(record, body) || _physics.HandleSetPositionCollisionReports( record, - operation.PositionAuthorityVersion, - operation.SpatialAuthorityVersion, - operation.Command.GameTime, - operation.PreviousContact, - operation.PreviousOnWalkable, + positionAuthorityVersion, + spatialAuthorityVersion, + commandGameTime, + previousContact, + previousOnWalkable, collidedWithEnvironment, collidedObjectIds, out _); if (!reportingCurrent || !IsCanonicalPlacementCommitCurrent( - operation, + positionAuthorityVersion, + spatialAuthorityVersion, record, body, canonicalCommitVersion, @@ -3747,14 +4371,14 @@ internal sealed class RuntimeSetPositionState : IDisposable requireSpatialRoot: false)) return false; body.FramesStationaryFall = result.FramesStationaryFall; - if (IsVelocityCurrent(operation)) + if (IsVelocityCurrent(sourceVelocityAuthorityVersion, record)) { PhysicsObjUpdate.HandleAllCollisions( body, result.CollisionNormalValid, result.CollisionNormal, - operation.PreviousContact, - operation.PreviousOnWalkable, + previousContact, + previousOnWalkable, body.OnWalkable); } body.TransientState &= ~(TransientStateFlags.StationaryFall @@ -3770,7 +4394,8 @@ internal sealed class RuntimeSetPositionState : IDisposable if (remote is not null) remote.Airborne = !body.OnWalkable; if (!IsCanonicalPlacementCommitCurrent( - operation, + positionAuthorityVersion, + spatialAuthorityVersion, record, body, canonicalCommitVersion, @@ -3779,33 +4404,72 @@ internal sealed class RuntimeSetPositionState : IDisposable return false; _physics.Engine.ShadowObjects.CommitSetPosition( - operation.Key.LocalEntityId, + operationKey.LocalEntityId, result.Position, result.Orientation, result.CellId, - operation.Command.ShadowWorldOffsetX, - operation.Command.ShadowWorldOffsetY, + shadowWorldOffsetX, + shadowWorldOffsetY, result.ShadowAction, result.CrossCellIds); _physics.AcknowledgeSpatialProjection(record, spatial: true); - operation.ExactCellId = result.CellId; - operation.Result = result; - operation.WakeableLostCell = false; - operation.EnteringWorldFromCelllessResidence = false; - CancelLostFamilyDeadlines(operation); - return IsCurrent(operation) - && IsCanonicalPlacementCommitCurrent( - operation, - record, - body, - canonicalCommitVersion, - committedCellId, - requireSpatialRoot: true); + // A1 (round 3 addendum): unlike the settle above, these are + // Runtime's OWN bookkeeping for which logical operation this settle + // belongs to - a nested cancel-then-begin (reachable via either the + // ground-edge callbacks above OR an arbitrary collision-report + // observer reentrantly calling Begin/Cancel from inside + // HandleSetPositionCollisionReports) can recycle `operation` for a + // brand-new operation that stages at AwaitingPreparation without + // ever advancing PlacementCommitVersion - invisible to the + // record-state checks above. Re-resolve by the token captured + // before any callback ran and only write if it is still the exact + // instance; skipping the writes (rather than failing the whole + // commit) matches retail's already-unconditional physical settle - + // only the ownership bookkeeping is conditional. + if (_operations.TryGetValue(operationKey, out Operation? currentOperation) + && currentOperation.Token == operationToken) + { + currentOperation.ExactCellId = result.CellId; + currentOperation.Result = result; + currentOperation.WakeableLostCell = false; + currentOperation.EnteringWorldFromCelllessResidence = false; + CancelLostFamilyDeadlines(currentOperation); + } + + return IsCanonicalPlacementCommitCurrent( + positionAuthorityVersion, + spatialAuthorityVersion, + record, + body, + canonicalCommitVersion, + committedCellId, + requireSpatialRoot: true); } + /// + /// F1: takes the operation's authority versions as explicit VALUES + /// (captured by the caller before invoking the ground-edge HitGround/ + /// LeaveGround callbacks) rather than reading them live off an + /// reference that a reentrant cancel-then-begin + /// (or begin-twice) chain may have already retired and repurposed for a + /// DIFFERENT logical operation. Deliberately does NOT also compare the + /// operation's Token/identity: retail intentionally lets an in-flight + /// ground-edge commit for a DISPLACED operation still complete (its + /// physical contact-transition/shadow-sync settle even though a newer + /// operation now owns future placement authority for this entity - see + /// ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation). + /// Adding an identity check here would incorrectly abort that commit the + /// moment the entity is displaced, which is exactly the behavior that + /// test pins as wrong. The self-aliasing hazard a Token comparison would + /// otherwise guard against is real, but belongs at the call sites that + /// need "is this SPECIFIC Begin invocation still canonical" (see the + /// local-token comparison at the end of + /// BeginAcceptedPlacementCore), not here. + /// private bool IsCanonicalPlacementCommitCurrent( - Operation operation, + ulong positionAuthorityVersion, + ulong spatialAuthorityVersion, RuntimeEntityRecord record, PhysicsBody body, ulong placementCommitVersion, @@ -3813,10 +4477,8 @@ internal sealed class RuntimeSetPositionState : IDisposable bool requireSpatialRoot) => _entities.IsCurrent(record) && ReferenceEquals(record.PhysicsBody, body) - && record.PositionAuthorityVersion - == operation.PositionAuthorityVersion - && record.SpatialAuthorityVersion - == operation.SpatialAuthorityVersion + && record.PositionAuthorityVersion == positionAuthorityVersion + && record.SpatialAuthorityVersion == spatialAuthorityVersion && record.PlacementCommitVersion == placementCommitVersion && record.FullCellId == fullCellId && (!requireSpatialRoot || _physics.IsSpatialRoot(record)); @@ -3928,46 +4590,75 @@ internal sealed class RuntimeSetPositionState : IDisposable body.CellPosition.Frame.Origin, CrossCellIds: ImmutableArray.Empty, CollidedObjectIds: ImmutableArray.Empty); - return new Operation - { - Record = record, - Body = body, - Token = new RuntimeEntityPlacementToken( - _entities.SessionLifetimeVersion, - key, - record.PositionAuthorityVersion, - checked(++_nextOperationId), - RuntimeEntityPlacementPreparationKind.LegacyDirect), - Key = key, - PositionAuthorityVersion = record.PositionAuthorityVersion, - SessionLifetimeVersion = _entities.SessionLifetimeVersion, - SourceSpatialAuthorityVersion = record.SpatialAuthorityVersion, - SourceVelocityAuthorityVersion = record.VelocityAuthorityVersion, - PreviousContact = body.InContact, - PreviousOnWalkable = body.OnWalkable, - Command = new RuntimeSetPositionCommand( - physics, - RuntimeSetPositionOperationKind.RemoteAuthoritative, - body.LastUpdateTime, - record.VelocityAuthorityVersion, - ShadowWorldOffsetX: 0f, - ShadowWorldOffsetY: 0f), - Result = result, - SpatialAuthorityVersion = record.SpatialAuthorityVersion, - PlacementCommitVersion = record.PlacementCommitVersion, - ExactCellId = result.CellId, - WakeableLostCell = false, - Stage = RuntimeEntityPlacementStage - .AwaitingWithdrawalAcknowledgement, - Kind = RuntimeSetPositionOperationKind.RemoteAuthoritative, - Portal = default, - }; + // F4: routed through RentOperation() (rather than a fresh + // `new Operation { ... }`) so every construction flows one path - + // see ResetAllFieldsToDefault's completeness-net doc comment. The + // sole caller (Cancel) always runs CancelCoreDeferred against this + // same key first, so there is no displaced-operation state left to + // read here. + Operation operation = RentOperation(); + operation.Record = record; + operation.Body = body; + operation.Token = new RuntimeEntityPlacementToken( + _entities.SessionLifetimeVersion, + key, + record.PositionAuthorityVersion, + checked(++_nextOperationId), + RuntimeEntityPlacementPreparationKind.LegacyDirect); + operation.Key = key; + operation.PositionAuthorityVersion = record.PositionAuthorityVersion; + operation.SessionLifetimeVersion = _entities.SessionLifetimeVersion; + operation.SourceSpatialAuthorityVersion = record.SpatialAuthorityVersion; + operation.SourceVelocityAuthorityVersion = record.VelocityAuthorityVersion; + operation.PreviousContact = body.InContact; + operation.PreviousOnWalkable = body.OnWalkable; + operation.Command = new RuntimeSetPositionCommand( + physics, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + body.LastUpdateTime, + record.VelocityAuthorityVersion, + ShadowWorldOffsetX: 0f, + ShadowWorldOffsetY: 0f); + operation.Result = result; + operation.SpatialAuthorityVersion = record.SpatialAuthorityVersion; + operation.PlacementCommitVersion = record.PlacementCommitVersion; + operation.ExactCellId = result.CellId; + operation.WakeableLostCell = false; + operation.Stage = RuntimeEntityPlacementStage + .AwaitingWithdrawalAcknowledgement; + operation.Kind = RuntimeSetPositionOperationKind.RemoteAuthoritative; + operation.Portal = default; + return operation; } + /// + /// Reference-identity currency check. SAFE ONLY when no reentrancy + /// point (a synchronous publish, a collision-report dispatch reaching + /// an arbitrary IRuntimeCollisionReportObserver, or a + /// ground-edge HitGround/LeaveGround callback) has intervened between + /// `operation` being obtained/last verified and this call - every + /// call site is audited and either (a) has no such reentrancy point in + /// between (commented at the call site), or (b) has been converted to + /// instead. See + /// 's doc comment for why + /// ReferenceEquals becomes a tautology once pooling can hand + /// the SAME physical instance back out for a different logical + /// operation at the same key. + /// private bool IsCurrent(Operation operation) => _operations.TryGetValue(operation.Key, out Operation? current) && ReferenceEquals(current, operation) - && _entities.SessionLifetimeVersion == operation.SessionLifetimeVersion + && IsOperationStateConsistent(operation); + + /// + /// Round 3: the consistency half of , + /// factored out so can reuse the exact + /// same checks after establishing identity via Token (safe under + /// pooling) instead of ReferenceEquals (unsafe under pooling - + /// see 's doc comment). + /// + private bool IsOperationStateConsistent(Operation operation) => + _entities.SessionLifetimeVersion == operation.SessionLifetimeVersion && _entities.IsCurrent(operation.Record) && operation.Record.Key == operation.Key && (operation.Body is null @@ -3979,6 +4670,40 @@ internal sealed class RuntimeSetPositionState : IDisposable && operation.Record.PlacementCommitVersion == operation.PlacementCommitVersion; + /// + /// Round 3: the class-wide replacement for + /// ReferenceEquals/ at any frame + /// that holds an Operation reference across a reentrancy point (a + /// synchronous publish, a collision-report dispatch that can reach an + /// arbitrary IRuntimeCollisionReportObserver, or a ground-edge + /// HitGround/LeaveGround callback). + /// MUST be read from the operation (or already be the caller's own + /// token parameter) BEFORE that reentrancy point - reading it fresh off + /// a possibly-already-repurposed reference here would be exactly as + /// tautological as the ReferenceEquals check this replaces (see + /// 's doc comment for the full + /// hazard: a reentrant cancel-then-begin can retire-then-rent the SAME + /// physical instance for a DIFFERENT logical operation, LIFO). On + /// success, returns the FRESHLY resolved Operation so the caller + /// continues with a reference proven current at THIS instant, never + /// the possibly-stale one it held before the reentrancy point. + /// + private bool IsCurrentByToken( + RuntimeEntityKey key, + in RuntimeEntityPlacementToken capturedToken, + [NotNullWhen(true)] out Operation? operation) + { + if (_operations.TryGetValue(key, out Operation? current) + && current.Token == capturedToken + && IsOperationStateConsistent(current)) + { + operation = current; + return true; + } + operation = null; + return false; + } + private bool IsExactDormantLocalActivationCurrent( RuntimeEntityRecord record, PhysicsBody body, @@ -3989,6 +4714,9 @@ internal sealed class RuntimeSetPositionState : IDisposable bool allowDeferredLease = false) { operation = null; + // Round 3 audit: safe - fresh lookup + Token check earlier in this + // SAME guard clause (below) precede IsCurrent, nothing reentrant in + // between; this dormant family has zero production callers. if (!token.IsValid || token.Entity != record.Key || token.PreparationKind @@ -4052,9 +4780,21 @@ internal sealed class RuntimeSetPositionState : IDisposable } private bool IsVelocityCurrent(Operation operation) => - operation.SourceVelocityAuthorityVersion == 0UL - || operation.Record.VelocityAuthorityVersion - == operation.SourceVelocityAuthorityVersion; + IsVelocityCurrent( + operation.SourceVelocityAuthorityVersion, + operation.Record); + + /// + /// F1: overload taking the hoisted scalar directly, for callers (like + /// CommitCanonical) that must not re-read a live + /// after a ground-edge callback may have retired + /// and repurposed it. + /// + private static bool IsVelocityCurrent( + ulong sourceVelocityAuthorityVersion, + RuntimeEntityRecord record) => + sourceVelocityAuthorityVersion == 0UL + || record.VelocityAuthorityVersion == sourceVelocityAuthorityVersion; private static MoverPreparationAuthority CapturePreparationAuthority( Operation operation, @@ -4242,20 +4982,43 @@ internal sealed class RuntimeSetPositionState : IDisposable .CancelledAwaitingAcknowledgement; discard = cancelled; } + // C2: `operation` was just removed from `_operations` above - see + // RetireOperationToPool's doc comment for why every caller (this one + // included) is safe to hand it back here, even the discard-pending + // case (AcknowledgeProjection's Discard branch never looks the + // operation back up by key). + RetireOperationToPool(operation); return true; } + /// + /// Round 3: takes the entity key plus a Token CAPTURED BY THE CALLER + /// (before any reentrancy point the caller passed through) instead of + /// an reference. The ReferenceEquals + /// check this replaced was exactly the shape the round 3 architecture + /// review flagged: safe before pooling (an Operation instance was never + /// reused), silently tautological after (a reentrant cancel-then-begin + /// can retire-then-rent the SAME instance for a DIFFERENT logical + /// operation, so `expected` and the freshly-looked-up `current` could be + /// the same reference while representing different operations) - with + /// the worse outcome that this method would then cancel the NEWER + /// operation instead of correctly no-op'ing. See + /// 's doc comment for the full + /// hazard and for the equivalent + /// conversion applied to plain currency checks. + /// private RuntimePlacementCancellationReceipt CancelCore( - Operation expected, + RuntimeEntityKey key, + in RuntimeEntityPlacementToken expectedToken, bool preserveLostFamily = false) { - if (!_operations.TryGetValue(expected.Key, out Operation? current) - || !ReferenceEquals(current, expected)) + if (!_operations.TryGetValue(key, out Operation? current) + || current.Token != expectedToken) { return default; } _ = CancelCoreDeferred( - expected.Key, + key, cancelLostFamily: false, preserveLostFamily, out RuntimePlacementProjectionSnapshot? discard); diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index 7b685664..d1e4f973 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Numerics; +using System.Reflection; using AcDream.Content; using AcDream.Content.Pak; using AcDream.Core.Net; @@ -288,10 +289,28 @@ public sealed class RuntimeSetPositionStateTests } long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - // The dormant 4B1 owner still allocates its operation/projection - // envelope. Pin the measured Release ceiling so 4B2 cannot activate - // the route without making this cost explicit or reducing it. - Assert.InRange(allocated / iterations, 1L, 2_048L); + // C2: root-caused and fixed the dormant 4B1 owner's per-operation + // allocations instead of raising this cap. Measured exactly 944 B/op + // (stable across 5+ repeat runs), down from the pre-fix 2,032 B/op - + // pooling `Operation` instances (BeginAcceptedPlacementCore no + // longer allocates a fresh instance every accepted placement), + // caching the `PhysicsEngine.SetPosition` collision-report delegate + // instead of a per-call closure, and replacing the LINQ + // `_pendingProjection.First()` pattern (which boxed + // SortedDictionary's struct Enumerator through IEnumerable every + // acknowledgement) with a non-boxing foreach. See + // docs/research/2026-07-31-canonical-set-position.md for the C2 + // finding this closes and the accepted residual floor: ~520 B/op + // lives inside AcDream.Core's PhysicsEngine.SetPosition (transition + // init/inner solve/query-footprint materialization - shared physics + // infrastructure, out of this Runtime-only slice's scope) and + // ~208 B/op is SortedDictionary's inherent per-Add tree-node + // allocation for `_pendingProjection` (replacing that ordered + // structure to chase the last ~230 B/op was judged too invasive/ + // risky for the remaining headroom under this cap). 1,536 keeps + // roughly 60% headroom over the measured value for JIT/environment + // variance without re-opening the door to unbounded per-op growth. + Assert.InRange(allocated / iterations, 1L, 1_536L); } [Fact] @@ -1081,6 +1100,243 @@ public sealed class RuntimeSetPositionStateTests Assert.True(lifetime.Physics.SetPosition.CaptureOwnership().IsConverged); } + /// + /// F1 regression: from within the ground-edge HitGround callback, drive + /// an explicit cancel-then-begin for the SAME entity. Because the + /// Operation pool is LIFO, this retires the outer, still-executing + /// operation and immediately rents that EXACT instance back out for the + /// new ("recycled") operation - a strictly more adversarial recycle than + /// ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation's + /// begin-only chain (there, nothing had retired the outer operation to + /// the pool by the time the reentrant Begin ran, so it always drew a + /// different or fresh instance). CommitCanonical must still feed the + /// ORIGINAL (pre-callback) PreviousContact/PreviousOnWalkable into + /// HandleSetPositionCollisionReports, not whatever the recycled instance + /// now holds for the unrelated new operation. This is observable: the + /// environment-collision report only fires at all when + /// (!previousOnWalkable && body.OnWalkable) - the recycled + /// instance's PreviousOnWalkable (captured from the body's ALREADY- + /// landed post-transition state) would read true instead of the + /// original false, silently suppressing the report if the operation's + /// live (potentially repurposed) fields were read instead of hoisted + /// locals. + /// + [Fact] + public void ReentrantCancelThenBeginRecyclesInstanceButCollisionReportUsesPreCallbackValues() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase == TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + SourceCell); + } + return observed; + }; + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001046u, 1); + PhysicsBody body = AttachBody( + lifetime, + record, + SourceCell, + PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions); + var collisionObserver = new CollisionReportObserver(); + using IDisposable collisionSubscription = lifetime.Physics + .CollisionReports.Subscribe(collisionObserver); + + RuntimeEntityPlacementToken recycled = default; + var remote = new ReentrantRemotePlacement(body) + { + CellId = SourceCell, + OnHitGround = () => + { + lifetime.Physics.SetPosition.Cancel( + record, + publishWithdrawal: false); + recycled = lifetime.Physics.SetPosition.BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + }, + }; + lifetime.Entities.SetRemoteMotion(record, remote); + + _ = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(13f, 18f, 7f)))); + + Assert.True(recycled.IsValid); + Assert.True(body.OnWalkable); + RuntimeCollisionReport report = Assert.Single(collisionObserver.Reports); + Assert.Equal(RuntimeCollisionReportKind.EnvironmentCollision, report.Kind); + Assert.False(report.RecipientWasInContact); + } + + /// + /// Round 3 pinned regression: the coordinator's precise reachability + /// trace for the Cancel-path recycle hazard. Cancel(record, bool) + /// creates a withdrawal operation and installs it at this entity's key, + /// then calls PublishPlacement(cancelledOld) to notify observers + /// that the still-pending Place projection has been superseded by a + /// Discard - a synchronous dispatch that can reach an arbitrary + /// subscriber, which here + /// reentrantly calls BeginAcceptedPlacement for the SAME entity. + /// Because the Operation pool is LIFO and + /// BeginAcceptedPlacementCore rents only after retiring, that + /// reentrant Begin retires the withdrawal operation Cancel is + /// mid-way through publishing for and rents the exact same physical + /// instance right back out for a brand-new "inner" operation. Before + /// round 3's captured-token-vs-fresh-lookup conversion, Cancel's + /// post-publish check read IsCurrent(operation) off that live + /// (now-repurposed) reference - a tautology that would have let the + /// outer withdrawal proceed to publish a SECOND (Withdraw) projection + /// stamped with the inner operation's state, corrupting a Begin-only + /// operation that never asked for a pending projection. The fix + /// captures operation.Token into a local BEFORE + /// PublishPlacement runs and re-verifies it via a fresh + /// IsCurrentByToken lookup afterward, so the stale reference is + /// detected and the outer call returns without publishing anything + /// beyond the Discard. + /// + [Fact] + public void ReentrantBeginDuringCancelPublishCannotBeOverwrittenByOuterWithdraw() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001250u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(18f, 18f, 7f)))); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + pending.Status); + + RuntimeEntityPlacementToken inner = default; + var observer = new PlacementObserver(delta => + { + if (delta.Placement.Kind is RuntimePlacementProjectionKind.Discard) + { + inner = lifetime.Physics.SetPosition.BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + } + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + bool removed = lifetime.Physics.SetPosition.Cancel( + record, + publishWithdrawal: true); + + Assert.True(removed); + Assert.True(inner.IsValid); + // Only the Discard (for the pending Place this Cancel superseded) + // should have published - a stale-reference bug would have added a + // second (Withdraw) delta stamped with `inner`'s state. + RuntimePlacementDelta delta = Assert.Single(observer.Deltas); + Assert.Equal(RuntimePlacementProjectionKind.Discard, delta.Placement.Kind); + // `inner` must still be exactly what BeginAcceptedPlacement handed + // back - untouched by the outer Cancel's withdrawal publish. + Assert.True(lifetime.Physics.SetPosition.IsPlacementCurrent(inner)); + RuntimeSetPositionOwnershipSnapshot ownership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(1, ownership.ActiveOperationCount); + Assert.Equal(1, ownership.AwaitingPreparationCount); + } + + /// + /// Round 3 CancelCore-shape regression: from within the ground-edge + /// HitGround callback, recycle the SAME operation instance + /// (cancel-then-begin, LIFO pool) for a brand-new "inner" operation on + /// the same entity, AND advance the record's PlacementCommitVersion a + /// second time (exactly what a completed nested commit would also have + /// produced) so CommitCanonical's post-callback + /// IsCanonicalPlacementCommitCurrent check fails for the OUTER commit. + /// SubmitPreparedPlacementCore's own failure branch then calls + /// PublishCancellation(CancelCore(token.Entity, token)) using + /// `token` - the outer caller's own (now-stale) captured token. Before + /// round 3, CancelCore(Operation expected, ...) compared + /// `ReferenceEquals(current, expected)`; with the physical instance + /// recycled for `inner`, that check would have found the SAME instance + /// at this key and retired/cancelled it out from under the still-active + /// inner operation - the "worse outcome: cancelling the newer + /// operation" the reviewer flagged. The Token-keyed + /// CancelCore(key, expectedToken) must instead see + /// `current.Token != expectedToken` and no-op, leaving `inner` + /// untouched. + /// + [Fact] + public void ReentrantCancelThenBeginDuringCommitFailureLeavesInnerOperationUncancelled() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase == TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + SourceCell); + } + return observed; + }; + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001251u, 1); + PhysicsBody body = AttachBody( + lifetime, + record, + SourceCell, + PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions); + + RuntimeEntityPlacementToken inner = default; + var remote = new ReentrantRemotePlacement(body) + { + CellId = SourceCell, + OnHitGround = () => + { + lifetime.Physics.SetPosition.Cancel( + record, + publishWithdrawal: false); + // Simulate a nested commit completing for `inner` (without + // needing a full nested SetPosition round-trip): advance + // PlacementCommitVersion BEFORE `inner`'s operation is + // created so `inner` snapshots the already-advanced value + // and stays internally self-consistent, while the OUTER + // commit's `canonicalCommitVersion` (captured before this + // callback ran) now mismatches the record. + lifetime.Entities.AdvancePlacementCommit(record); + inner = lifetime.Physics.SetPosition.BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + }, + }; + lifetime.Entities.SetRemoteMotion(record, remote); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(13f, 18f, 7f)))); + + Assert.Equal(RuntimeSetPositionStatus.Cancelled, outcome.Status); + Assert.True(inner.IsValid); + // The bug this pins: a reference-based CancelCore would retire the + // recycled instance (now serving `inner`) out from under it. + // Confirm `inner` is still the live, current operation for this + // entity. + Assert.True(lifetime.Physics.SetPosition.IsPlacementCurrent(inner)); + RuntimeSetPositionOwnershipSnapshot ownership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(1, ownership.ActiveOperationCount); + Assert.Equal(1, ownership.AwaitingPreparationCount); + } + [Fact] public void RetrySnapshotPreservesOrderWhenObserverAcknowledgesTwoPendingTokens() { @@ -2012,6 +2268,158 @@ public sealed class RuntimeSetPositionStateTests Assert.True(lifetime.Physics.CaptureOwnership().IsConverged); } + /// + /// F2 regression: the C2 Operation pool retains full previous-generation + /// entity graphs (Record -> Snapshot/PhysicsBody/clock/host references) + /// through every pooled instance until it is rented and reset - a + /// session reset must not leave that behind uncleared, even though + /// PooledOperationCount is deliberately excluded from + /// (see + /// its doc comment). + /// + [Fact] + public void OperationPoolClearsOnResetSession() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001048u, 1); + _ = AttachBody(lifetime, record, SourceCell); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(11f, 18f, 7f)))); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + + RuntimeSetPositionOwnershipSnapshot beforeReset = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.True(beforeReset.PooledOperationCount >= 1); + + lifetime.Physics.SetPosition.ResetSession(); + + Assert.Equal( + 0, + lifetime.Physics.SetPosition.CaptureOwnership().PooledOperationCount); + } + + /// + /// F2 regression: same guarantee as + /// , via Dispose instead + /// of ResetSession - both callers of ClearOwnedState must clear the + /// pool. + /// + [Fact] + public void OperationPoolClearsOnDispose() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001049u, 1); + _ = AttachBody(lifetime, record, SourceCell); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(11f, 18f, 7f)))); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + + RuntimeSetPositionOwnershipSnapshot beforeDispose = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.True(beforeDispose.PooledOperationCount >= 1); + + lifetime.Dispose(); + + Assert.Equal( + 0, + lifetime.Physics.SetPosition.CaptureOwnership().PooledOperationCount); + } + + /// + /// F4 regression: converting Operation's properties from + /// required { get; init; } to plain { get; set; } (so + /// pooling could recycle instances) dropped the compiler's completeness + /// net - nothing any longer forces every construction site to set every + /// field. This reflection-based test is the runtime replacement: it + /// pins the exact set of backing fields Operation declares (private, so + /// only reflection can reach it from a test) against a hardcoded, + /// maintained list. Adding a new auto-property to Operation changes its + /// backing-field set and fails this test immediately - the failure + /// message is the prompt to update BOTH this list and + /// Operation.ResetAllFieldsToDefault in the same change, exactly + /// mirroring what a missing `required` member assignment used to force + /// at compile time. + /// + [Fact] + public void OperationResetAllFieldsToDefaultTouchesEveryDeclaredField() + { + Type? operationType = typeof(RuntimeSetPositionState).GetNestedType( + "Operation", + BindingFlags.NonPublic); + Assert.NotNull(operationType); + + FieldInfo[] actualFields = operationType!.GetFields( + BindingFlags.Instance + | BindingFlags.NonPublic + | BindingFlags.Public); + string[] actualFieldNames = actualFields + .Select(field => field.Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + // The maintained list: one entry per auto-property Operation + // declares. Kept as property names (not the `k__BackingField` + // form the compiler actually emits) so a reviewer can read it + // directly against the property list in the source file. + string[] expectedPropertyNames = + [ + "Record", + "Body", + "Token", + "Key", + "PositionAuthorityVersion", + "SessionLifetimeVersion", + "SourceSpatialAuthorityVersion", + "SourceVelocityAuthorityVersion", + "PreviousContact", + "PreviousOnWalkable", + "Command", + "Result", + "SpatialAuthorityVersion", + "PlacementCommitVersion", + "ExactCellId", + "CollisionGeneration", + "CollisionPrefix", + "WithdrawalAcknowledged", + "CollisionGenerationReady", + "CollisionQuiescenceHeld", + "ProjectionSequence", + "WakeableLostCell", + "Stage", + "Kind", + "Portal", + "RequiresPreparation", + "Expired", + "LostFamilyKeys", + "InheritedLostDeadline", + "EnteringWorldFromCelllessResidence", + "DormantLocalActivation", + "PreparedCommandAwaitingWithdrawalAck", + "InPool", + ]; + string[] expectedFieldNames = expectedPropertyNames + .Select(name => $"<{name}>k__BackingField") + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(expectedFieldNames, actualFieldNames); + + MethodInfo? resetMethod = operationType.GetMethod( + "ResetAllFieldsToDefault", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(resetMethod); + } + [Fact] public void CommittedParentDoesNotLeakAcrossChildGuidReuse() { From a32aba35d1d945b9d3194a84e70facf74a7d7608 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 07:43:30 +0200 Subject: [PATCH 54/73] docs(physics): record cutover slice C2 completion C2 landed at 63c601ff with dual review PASS after two fix rounds. The plan records the halved allocation result and tightened gate, the class-wide token-based staleness rework the pooling forced, the documented residual floor (Core-side ~520 B/op deferred to the C3 activation gate as a possible C2b), and the two review maintenance notes. Next slice: C3, the spawn-frequency host cutover of routes 1+8. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-02-placement-cutover.md | 26 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 648d0232..625603dc 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -92,10 +92,28 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. direct object-clock bypasses, and containing headless's uncaught prepared-collision `InvalidDataException` — IS the C3 route-1 flip and moves there. No separate C1 commit. -- **C2 — placement allocation budget.** Pool or eliminate the operation/ - projection envelope allocations on the accepted placement path (root - cause, not a raised cap), or obtain explicit user approval for a measured - budget. Re-measure; the regression gate keeps the ceiling. +- **C2 — placement allocation budget — COMPLETE at `63c601ff` + (2026-08-02, dual reviews PASS after two fix rounds).** 2,032 → 944 + B/op via pooled operation envelopes (bounded, reset-at-rent, double- + retire guarded, reset/dispose-cleared, ledger-visible), a cached + collision-callback delegate over an explicit context stack, and a + non-boxing pending-head read; gate tightened to 1,536. The pooling + forced a class-wide staleness rework: captured-token-vs-fresh-lookup at + every reentrancy-spanning frame (26-site audit), hoisted stack locals + for retail's handle_all_collisions bits, token-gated bookkeeping + writes, and a deliberately identity-agnostic settle path (retail's + SetPositionInternal completes unconditionally even for displaced + operations). + **Residual floor (documented at the gate, decision deferred to the C3 + activation gate where the user is in the loop):** ~520 B/op inside + Core's `PhysicsEngine.SetPosition` (transition init / query-footprint + materialization — a potential C2b if C3's connected profile shows it + matters) and ~208 B/op of sorted-tree node per pending receipt. + **Maintenance notes from review (no action):** the no-reentrancy + proofs on the 15 surviving reference-based currency checks are + comment-enforced; `IsCurrent(Operation)` remains available and a new + reentrancy-spanning call site would silently inherit the tautology — + its doc comment warns. - **C3 — spawn-frequency cutover: routes 1 + 8.** Flip graphical AND headless initial Create/login registration to `RegisterEntityWithInitialResidence` + executor + placement receipts From fe02c4f56d365affe5daa5e7cbe0a289b02e0b76 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 08:17:12 +0200 Subject: [PATCH 55/73] feat(runtime): public initial-Create completion surface for hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cutover slice C3-1 (the C3 flip's Runtime prerequisite, landed separately after the flip itself was halted with structural findings — see the plan's C3a/b/c decomposition). Hosts can now read the executor-completion facts they must bind at cutover through one public, generation-gated channel accessor: RuntimePlacementProjectionChannel.TryGetInitialCreateCompletion returns RuntimeInitialCreatePlacementCompletion — the teleport-hook phase, resident cell, replay outcomes, and per-Position route facts (disposition, constrain phase, hook phase, stop-interpolation/zero-velocity/preserve- heading/send-position flags) via public 1:1 mirror enums of the internal classifier vocabulary. The projection is built once at completion, cached in the same reaped entry as the internal receipt (identical acknowledge/ discard/clear lifecycle, ledger-covered), and read allocation-free. Mirror maps enumerate every value explicitly with throwing catch-alls, guarded by a sabotage-verified arity/round-trip reflection test. Doc comments pin the two consumption rules: unparent/placement-frame are already applied to the canonical snapshot (hosts must not re-apply), and array order — not Sequence — is the authoritative Position-fact ordering. Reviewed: architecture PASS + retail-conformance PASS (mirrors verified member-for-member against the retail phase semantics; the route-fact selection confirmed to cover exactly the host-bindable deferrals). Runtime 932/932; complete Release solution 10,727 passed / 4 skips. Co-Authored-By: Claude Fable 5 --- .../Entities/RuntimeEntityObjectLifetime.cs | 9 +- ...untimeInitialCreateContinuationExecutor.cs | 297 +++++++++++++++++- .../RuntimePlacementProjectionChannel.cs | 32 +- ...eInitialCreateContinuationExecutorTests.cs | 259 +++++++++++++++ 4 files changed, 588 insertions(+), 9 deletions(-) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 2571d719..c3a1e293 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -174,7 +174,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.ForgetCompletionReceipt(key, sequence)); Placements = new RuntimePlacementProjectionChannel( Events, - Physics.SetPosition); + Physics.SetPosition, + InitialCreateExecution); } internal RuntimeEntityObjectLifetime( @@ -226,7 +227,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.ForgetCompletionReceipt(key, sequence)); Placements = new RuntimePlacementProjectionChannel( Events, - Physics.SetPosition); + Physics.SetPosition, + InitialCreateExecution); } internal RuntimeEntityObjectLifetime( @@ -278,7 +280,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.ForgetCompletionReceipt(key, sequence)); Placements = new RuntimePlacementProjectionChannel( Events, - Physics.SetPosition); + Physics.SetPosition, + InitialCreateExecution); } public RuntimeEntityDirectory Entities { get; } diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs index d545fe78..4cea0c23 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs @@ -194,6 +194,99 @@ internal readonly record struct RuntimeInitialCreateExecutionReceipt( ImmutableArray Trace, int ReplayedDeferredChildCount); +/// +/// C3-1: public projection of . A +/// separate public enum (rather than widening the internal one's +/// accessibility) keeps the classifier/executor's internal vocabulary free to +/// evolve without becoming a host-facing contract; values map 1:1 today. +/// +public enum RuntimeInitialCreateTeleportHookPhase : byte +{ + None, + BeforePositionOperation, + AfterPositionOperation, + AfterEnterWorld, +} + +/// C3-1: public projection of . +public enum RuntimeInitialCreatePositionDisposition : byte +{ + RejectedAuthority, + RejectedData, + AwaitFreshPosition, + NoPositionOperation, + Interpolate, + SetPosition, + SetPositionSimple, +} + +/// C3-1: public projection of . +public enum RuntimeInitialCreatePositionConstrainPhase : byte +{ + None, + BeforePositionOperation, + AfterPositionOperation, +} + +/// +/// C3-1: one Position continuation's route facts from the executor's trace, +/// projected to a public shape so a host can bind constrain/interpolation +/// presentation (retail's ConstrainTo placement, stop-interpolate, +/// zero-velocity, preserve-heading, send-position-immediately) without +/// reaching into internal Runtime route-classifier types. +/// Review addendum (2026-08-02): the route's UnparentBeforeRouting +/// ("unset_parent") and ApplyPlacementFrameBeforeRouting +/// ("SetPlacementFrame") facts are deliberately NOT projected here - the +/// executor's own merge (ApplyAcceptedPositionSnapshot's +/// clearParent/installPlacementFrame parameters) already +/// applies both directly to the canonical snapshot, synchronously, before +/// the trace entry carrying this fact is even built. A host reading this +/// record must NOT re-apply either one - the facts this type DOES carry +/// (, , +/// , , +/// , ) are +/// exactly the bindings still DEFERRED to the host at presentation time; +/// everything already-applied is intentionally excluded. +/// +public readonly record struct RuntimeInitialCreatePositionRouteFact( + ulong Sequence, + RuntimeInitialCreatePositionDisposition Disposition, + RuntimeInitialCreateTeleportHookPhase HookPhase, + RuntimeInitialCreatePositionConstrainPhase ConstrainPhase, + bool StopInterpolating, + bool ZeroVelocity, + bool PreserveHeading, + bool SendPositionImmediately); + +/// +/// C3-1: the public host consumption shape for one executor drain's +/// completion, reached via +/// +/// using the correlated +/// receipt's own Entity/Sequence identity. Built exactly once, at completion +/// time, and cached alongside the internal receipt it projects (see +/// ) - +/// a host retrying TryGetInitialCreateCompletion across multiple polls +/// never triggers a second allocation. +/// Review addendum (2026-08-02): 's ARRAY +/// ORDER is the authoritative ordering, not +/// alone - Position facts drained from the SAME same-incarnation envelope +/// ( distinguishes +/// them internally, a field this projection does not carry) share one +/// continuation Sequence, so two entries can legitimately have equal +/// Sequence values. +/// walks the executor's trace strictly in construction order (itself the +/// exact FIFO drain order) and appends without reordering or deduplicating, +/// so array index - never a sort or group-by on Sequence - is the +/// only reliable way to recover drain order from this array. +/// +public readonly record struct RuntimeInitialCreatePlacementCompletion( + RuntimeEntityKey Entity, + uint FullCellId, + RuntimeInitialCreateTeleportHookPhase TeleportHookPhase, + ImmutableArray PositionRouteFacts, + int ReplayedDeferredChildCount); + /// /// Applies one entity's completed initial-Create residence: adopts the /// initial placement exactly once, emits the AfterEnterWorld teleport-hook @@ -285,10 +378,16 @@ internal sealed class RuntimeInitialCreateContinuationExecutor /// reentrancy guard), so only the most recent completion for a key is /// ever meaningful; the exact-sequence check in /// rejects a stale lookup against a - /// superseded completion under a reused key. + /// superseded completion under a reused key. C3-1: the tuple's third + /// slot is the SAME receipt already projected once to the public + /// shape (see + /// ) - stored here, not recomputed per + /// read, so a host polling TryGetInitialCreateCompletion across + /// retries never allocates a second time for the same completion. /// private readonly Dictionary + (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt, + RuntimeInitialCreatePlacementCompletion Public)> _completionReceipts = []; private Func? _generation; private Func? _usePositionFromServer; @@ -438,7 +537,8 @@ internal sealed class RuntimeInitialCreateContinuationExecutor { if (_completionReceipts.TryGetValue( token.Entity, - out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt) entry) + out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt, + RuntimeInitialCreatePlacementCompletion Public) entry) && entry.Sequence == token.Sequence) { receipt = entry.Receipt; @@ -448,6 +548,185 @@ internal sealed class RuntimeInitialCreateContinuationExecutor return false; } + /// + /// C3-1: the public host consumption surface for + /// - same exact-sequence + /// correlation rule, but returns the cached public projection instead of + /// the internal receipt/trace. Reached via + /// . + /// + internal bool TryGetCompletion( + in RuntimePlacementProjectionToken token, + out RuntimeInitialCreatePlacementCompletion completion) + { + if (_completionReceipts.TryGetValue( + token.Entity, + out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt, + RuntimeInitialCreatePlacementCompletion Public) entry) + && entry.Sequence == token.Sequence) + { + completion = entry.Public; + return true; + } + completion = default; + return false; + } + + /// + /// Review fix (2026-08-02, architecture pass): every arm is now listed + /// explicitly and the catch-all throws instead of silently folding an + /// unmapped future internal value into None. A value this method + /// cannot map must never reach a host disguised as "nothing to bind" - + /// that would silently drop presentation behavior (e.g. a real + /// teleport-hook phase host code never runs). See + /// 's + /// reflection-based completeness test, which walks every declared + /// value through this exact + /// method and fails if a new internal value is ever added without a + /// matching arm here. + /// + private static RuntimeInitialCreateTeleportHookPhase MapHookPhase( + RuntimeTeleportHookPhase phase) => phase switch + { + RuntimeTeleportHookPhase.None => + RuntimeInitialCreateTeleportHookPhase.None, + RuntimeTeleportHookPhase.BeforePositionOperation => + RuntimeInitialCreateTeleportHookPhase.BeforePositionOperation, + RuntimeTeleportHookPhase.AfterPositionOperation => + RuntimeInitialCreateTeleportHookPhase.AfterPositionOperation, + RuntimeTeleportHookPhase.AfterEnterWorld => + RuntimeInitialCreateTeleportHookPhase.AfterEnterWorld, + _ => throw new ArgumentOutOfRangeException( + nameof(phase), + phase, + $"Unmapped {nameof(RuntimeTeleportHookPhase)} value - add an explicit arm to {nameof(MapHookPhase)} and to the public {nameof(RuntimeInitialCreateTeleportHookPhase)} projection."), + }; + + /// + /// Review fix (2026-08-02): see 's remarks - + /// same explicit-arms-plus-throwing-catch-all discipline. + /// + private static RuntimeInitialCreatePositionDisposition MapDisposition( + RuntimeAuthoritativePositionDisposition disposition) => disposition switch + { + RuntimeAuthoritativePositionDisposition.RejectedAuthority => + RuntimeInitialCreatePositionDisposition.RejectedAuthority, + RuntimeAuthoritativePositionDisposition.RejectedData => + RuntimeInitialCreatePositionDisposition.RejectedData, + RuntimeAuthoritativePositionDisposition.AwaitFreshPosition => + RuntimeInitialCreatePositionDisposition.AwaitFreshPosition, + RuntimeAuthoritativePositionDisposition.NoPositionOperation => + RuntimeInitialCreatePositionDisposition.NoPositionOperation, + RuntimeAuthoritativePositionDisposition.Interpolate => + RuntimeInitialCreatePositionDisposition.Interpolate, + RuntimeAuthoritativePositionDisposition.SetPosition => + RuntimeInitialCreatePositionDisposition.SetPosition, + RuntimeAuthoritativePositionDisposition.SetPositionSimple => + RuntimeInitialCreatePositionDisposition.SetPositionSimple, + _ => throw new ArgumentOutOfRangeException( + nameof(disposition), + disposition, + $"Unmapped {nameof(RuntimeAuthoritativePositionDisposition)} value - add an explicit arm to {nameof(MapDisposition)} and to the public {nameof(RuntimeInitialCreatePositionDisposition)} projection."), + }; + + /// + /// Review fix (2026-08-02): see 's remarks - + /// same explicit-arms-plus-throwing-catch-all discipline. + /// + private static RuntimeInitialCreatePositionConstrainPhase MapConstrainPhase( + RuntimePositionConstrainPhase phase) => phase switch + { + RuntimePositionConstrainPhase.None => + RuntimeInitialCreatePositionConstrainPhase.None, + RuntimePositionConstrainPhase.BeforePositionOperation => + RuntimeInitialCreatePositionConstrainPhase.BeforePositionOperation, + RuntimePositionConstrainPhase.AfterPositionOperation => + RuntimeInitialCreatePositionConstrainPhase.AfterPositionOperation, + _ => throw new ArgumentOutOfRangeException( + nameof(phase), + phase, + $"Unmapped {nameof(RuntimePositionConstrainPhase)} value - add an explicit arm to {nameof(MapConstrainPhase)} and to the public {nameof(RuntimeInitialCreatePositionConstrainPhase)} projection."), + }; + + /// + /// C3-1: projects an internal + /// to the public + /// shape exactly once, at completion time (see the + /// _completionReceipts assignment in ). + /// Only Position-kind trace entries carry route facts meaningful for + /// constrain/interpolation binding; every other action kind + /// (InitialAdoption, TeleportHookRequest, replay, envelope stages, ...) + /// is intentionally excluded from - + /// widening this to every trace entry would require making the whole + /// internal action-kind vocabulary public, which the pinned contract + /// explicitly prefers to avoid. + /// + private static RuntimeInitialCreatePlacementCompletion ProjectCompletion( + in RuntimeInitialCreateExecutionReceipt receipt) + { + ImmutableArray trace = receipt.Trace; + int positionCount = 0; + for (int i = 0; i < trace.Length; i++) + { + if (trace[i].Kind == RuntimeInitialCreateExecutedActionKind.Position) + positionCount++; + } + + ImmutableArray positionFacts; + if (positionCount == 0) + { + positionFacts = ImmutableArray.Empty; + } + else + { + var builder = ImmutableArray.CreateBuilder( + positionCount); + for (int i = 0; i < trace.Length; i++) + { + RuntimeInitialCreateExecutedAction action = trace[i]; + if (action.Kind != RuntimeInitialCreateExecutedActionKind.Position) + continue; + // Review fix (2026-08-02): PositionDisposition is nullable + // on RuntimeInitialCreateExecutedAction because it is only + // meaningful for Position/hook-request entries in general - + // but BuildPositionTrace is the SOLE constructor of + // Kind.Position entries (grep-confirmed, 5 call sites, all + // through BuildPositionTrace) and it always passes + // route.Disposition, a non-nullable enum, into this slot. + // Null is therefore NOT a legitimate state for a Position- + // kind entry specifically - a silent `?? NoPositionOperation` + // fallback here would have hidden a real bug (a future + // Position-trace producer that forgot to set it) behind a + // plausible-looking default. Fail loudly instead. + if (action.PositionDisposition is not { } disposition) + { + throw new InvalidOperationException( + "A Position-kind executor trace entry must always " + + "carry a non-null PositionDisposition - " + + "BuildPositionTrace (the sole producer of Kind.Position " + + "entries) always supplies route.Disposition."); + } + builder.Add(new RuntimeInitialCreatePositionRouteFact( + action.Sequence, + MapDisposition(disposition), + MapHookPhase(action.HookPhase), + MapConstrainPhase(action.ConstrainPhase), + action.StopInterpolating, + action.ZeroVelocity, + action.PreserveHeading, + action.SendPositionImmediately)); + } + positionFacts = builder.MoveToImmutable(); + } + + return new RuntimeInitialCreatePlacementCompletion( + receipt.Entity, + receipt.FullCellId, + MapHookPhase(receipt.TeleportHookPhase), + positionFacts, + receipt.ReplayedDeferredChildCount); + } + /// /// F2: reaps exactly one completion-receipt correlation entry, bound as /// 's @@ -461,7 +740,8 @@ internal sealed class RuntimeInitialCreateContinuationExecutor { if (_completionReceipts.TryGetValue( key, - out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt) entry) + out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt, + RuntimeInitialCreatePlacementCompletion Public) entry) && entry.Sequence == sequence) { _completionReceipts.Remove(key); @@ -787,6 +1067,13 @@ internal sealed class RuntimeInitialCreateContinuationExecutor progress.Trace.ToImmutable(), progress.ReplayedDeferredChildCount); receipt = completedReceipt; + // C3-1: project to the public host-consumption shape + // exactly once here, alongside the internal receipt - + // never recomputed per host read/retry (see + // ProjectCompletion's and _completionReceipts's own doc + // comments). + RuntimeInitialCreatePlacementCompletion publicCompletion = + ProjectCompletion(completedReceipt); _progress.Remove(key); // C0-1: bridge the executor's own completion onto the // SAME ordered placement receipt stream every @@ -806,7 +1093,7 @@ internal sealed class RuntimeInitialCreateContinuationExecutor canonical, beforePublish: token => _completionReceipts[key] = - (token.Sequence, completedReceipt)); + (token.Sequence, completedReceipt, publicCompletion)); return RuntimeInitialCreateExecutionStatus.Completed; } case RuntimeInitialCreateResidenceExecutorReleaseStatus.Revised: diff --git a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs index 807c178d..d1dc3d79 100644 --- a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs +++ b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs @@ -12,16 +12,20 @@ public sealed class RuntimePlacementProjectionChannel { private readonly RuntimeEntityObjectEventStream _events; private readonly RuntimeSetPositionState _setPosition; + private readonly RuntimeInitialCreateContinuationExecutor _initialCreateExecution; private Func _generation = static () => default; private bool _generationBound; internal RuntimePlacementProjectionChannel( RuntimeEntityObjectEventStream events, - RuntimeSetPositionState setPosition) + RuntimeSetPositionState setPosition, + RuntimeInitialCreateContinuationExecutor initialCreateExecution) { _events = events ?? throw new ArgumentNullException(nameof(events)); _setPosition = setPosition ?? throw new ArgumentNullException(nameof(setPosition)); + _initialCreateExecution = initialCreateExecution + ?? throw new ArgumentNullException(nameof(initialCreateExecution)); } /// @@ -81,6 +85,32 @@ public sealed class RuntimePlacementProjectionChannel public int PendingCount => _setPosition.PendingProjectionCount; + /// + /// C3-1: the public host consumption shape for an initial-Create + /// continuation-executor drain's completion. Reached with the exact + /// carried by a + /// receipt + /// observed through - the same correlation + /// identity (Entity/Sequence) every other placement Kind uses. Exposes + /// exactly the facts a cutover host needs to bind presentation off an + /// initial placement (the teleport-hook phase, the drained Position + /// continuations' route facts for constrain/interpolation binding, and + /// the replayed-deferred-child count) without widening any internal + /// Runtime type's accessibility. Returns false for a generation + /// mismatch or a stale/superseded/unknown token, mirroring every other + /// generation-gated method on this channel. + /// + public bool TryGetInitialCreateCompletion( + RuntimeGenerationToken expectedGeneration, + in RuntimePlacementProjectionToken token, + out RuntimeInitialCreatePlacementCompletion completion) + { + if (IsCurrent(expectedGeneration)) + return _initialCreateExecution.TryGetCompletion(token, out completion); + completion = default; + return false; + } + private bool IsCurrent(RuntimeGenerationToken expectedGeneration) => _generationBound && expectedGeneration.Value != 0UL diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs index fb81a88c..7fcd8795 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Numerics; +using System.Reflection; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -4319,6 +4320,264 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests observed[1].Token)); } + // --------------------------------------------------------------- + // C3-1: the public RuntimePlacementProjectionChannel host consumption + // surface for an executor completion (TryGetInitialCreateCompletion). + // --------------------------------------------------------------- + + [Fact] + public void PlacementChannel_TryGetInitialCreateCompletion_ProjectsHookPhaseCellAndReplayCount() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 420UL); + const uint guid = 0x70024020u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.SubscribePlacement( + new PlacementObserver(delta => observed.Add(delta.Placement))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + RuntimePlacementProjectionSnapshot completion = Assert.Single(observed); + var generation = new RuntimeGenerationToken(420UL); + Assert.True(lifetime.Placements.TryGetInitialCreateCompletion( + generation, + completion.Token, + out RuntimeInitialCreatePlacementCompletion publicCompletion)); + Assert.Equal(canonical.Key, publicCompletion.Entity); + Assert.Equal(receipt.Entity, publicCompletion.Entity); + Assert.Equal(receipt.FullCellId, publicCompletion.FullCellId); + Assert.Equal(receipt.ReplayedDeferredChildCount, publicCompletion.ReplayedDeferredChildCount); + // This is a local-player Create (login): retail's init_player path + // requests the AfterEnterWorld teleport hook (see RunInitialTail) - + // the exact fact route-1/8's cutover caller needs to know whether to + // run the after-enter teleport suffix. No Position continuation ran + // in this scenario, so the route-fact array projects empty. + Assert.Equal( + RuntimeInitialCreateTeleportHookPhase.AfterEnterWorld, + publicCompletion.TeleportHookPhase); + Assert.Equal(RuntimeTeleportHookPhase.AfterEnterWorld, receipt.TeleportHookPhase); + Assert.Empty(publicCompletion.PositionRouteFacts); + } + + [Fact] + public void PlacementChannel_TryGetInitialCreateCompletion_ProjectsPositionRouteFactsForConstrainInterpolationBinding() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 421UL); + const uint guid = 0x70024021u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + // A teleport-advanced Position continuation performs its own + // authored SetPosition and lands a Position trace entry with real + // route facts - the same scenario as + // ExecutorCompletion_ObservedOnlyAfterAnyContinuationPlacementInFifoOrder. + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 1, + forcePositionSequence: 0, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, true, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.SubscribePlacement( + new PlacementObserver(delta => observed.Add(delta.Placement))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + RuntimePlacementProjectionSnapshot executorCompletion = observed[^1]; + Assert.Equal( + RuntimePlacementProjectionKind.ExecutorCompleted, + executorCompletion.Kind); + + RuntimeInitialCreateExecutedAction internalPositionTrace = Assert.Single( + receipt.Trace.Where( + static a => a.Kind == RuntimeInitialCreateExecutedActionKind.Position)); + + var generation = new RuntimeGenerationToken(421UL); + Assert.True(lifetime.Placements.TryGetInitialCreateCompletion( + generation, + executorCompletion.Token, + out RuntimeInitialCreatePlacementCompletion publicCompletion)); + RuntimeInitialCreatePositionRouteFact fact = Assert.Single( + publicCompletion.PositionRouteFacts); + Assert.Equal(internalPositionTrace.Sequence, fact.Sequence); + Assert.Equal(internalPositionTrace.StopInterpolating, fact.StopInterpolating); + Assert.Equal(internalPositionTrace.ZeroVelocity, fact.ZeroVelocity); + Assert.Equal(internalPositionTrace.PreserveHeading, fact.PreserveHeading); + Assert.Equal( + internalPositionTrace.SendPositionImmediately, + fact.SendPositionImmediately); + Assert.Equal( + internalPositionTrace.PositionDisposition!.Value.ToString(), + fact.Disposition.ToString()); + Assert.Equal( + internalPositionTrace.ConstrainPhase.ToString(), + fact.ConstrainPhase.ToString()); + Assert.Equal( + internalPositionTrace.HookPhase.ToString(), + fact.HookPhase.ToString()); + } + + [Fact] + public void PlacementChannel_TryGetInitialCreateCompletion_RejectsWrongGeneration() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 422UL); + const uint guid = 0x70024022u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.SubscribePlacement( + new PlacementObserver(delta => observed.Add(delta.Placement))); + RunToCompletion(lifetime, canonical, lease.Token, NoContact); + RuntimePlacementProjectionSnapshot completion = Assert.Single(observed); + + Assert.False(lifetime.Placements.TryGetInitialCreateCompletion( + new RuntimeGenerationToken(999UL), + completion.Token, + out RuntimeInitialCreatePlacementCompletion stale)); + Assert.Equal(default, stale); + } + + [Fact] + public void PlacementChannel_TryGetInitialCreateCompletion_ReturnsFalseAfterAcknowledgeReapsTheCorrelationEntry() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 423UL); + const uint guid = 0x70024023u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + RunToCompletion(lifetime, canonical, lease.Token, NoContact); + + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot completion)); + var generation = new RuntimeGenerationToken(423UL); + Assert.True(lifetime.Placements.TryGetInitialCreateCompletion( + generation, + completion.Token, + out _)); + + Assert.True(lifetime.Placements.Acknowledge(generation, completion.Token)); + + Assert.False(lifetime.Placements.TryGetInitialCreateCompletion( + generation, + completion.Token, + out RuntimeInitialCreatePlacementCompletion afterAck)); + Assert.Equal(default, afterAck); + } + + /// + /// Review fix (2026-08-02): the compiler's exhaustiveness net for the + /// three enum-mirror switches (MapHookPhase/MapDisposition/ + /// MapConstrainPhase) is gone the moment a catch-all arm exists - + /// that is exactly why those catch-alls now throw instead of silently + /// defaulting. This reflection-based test is the runtime replacement: + /// for each (internal enum, public projection enum, private mapper + /// method) triple it asserts equal arity AND drives every declared + /// internal value through the mapper via reflection (the methods are + /// `private static`), asserting the mapped public value's NAME equals + /// the internal value's name (every mapper is a literal 1:1 name + /// mirror by design - see each public enum's own doc comment). Adding a + /// new member to either enum without updating the other and the mapper + /// fails this test immediately, mirroring + /// OperationResetAllFieldsToDefaultTouchesEveryDeclaredField's + /// reflection-based completeness guard for + /// 's pooled Operation fields. + /// Sabotage-verified during development: temporarily adding an extra + /// member to RuntimeTeleportHookPhase (with no matching arm in + /// MapHookPhase or the public + /// enum) failed this + /// test exactly as predicted - both the arity assertion and the + /// unhandled-value invocation threw - before the sabotage was reverted. + /// + [Fact] + public void EnumProjectionMapsHaveEqualArityAndEveryInternalValueRoundTripsByName() + { + AssertMapIsCompleteAndNamePreserving( + typeof(RuntimeTeleportHookPhase), + typeof(RuntimeInitialCreateTeleportHookPhase), + "MapHookPhase"); + AssertMapIsCompleteAndNamePreserving( + typeof(RuntimeAuthoritativePositionDisposition), + typeof(RuntimeInitialCreatePositionDisposition), + "MapDisposition"); + AssertMapIsCompleteAndNamePreserving( + typeof(RuntimePositionConstrainPhase), + typeof(RuntimeInitialCreatePositionConstrainPhase), + "MapConstrainPhase"); + } + + private static void AssertMapIsCompleteAndNamePreserving( + Type internalEnumType, + Type publicEnumType, + string mapMethodName) + { + MethodInfo? method = typeof(RuntimeInitialCreateContinuationExecutor) + .GetMethod( + mapMethodName, + BindingFlags.NonPublic | BindingFlags.Static); + Assert.True( + method is not null, + $"{nameof(RuntimeInitialCreateContinuationExecutor)} no longer " + + $"declares a private static method named {mapMethodName} - " + + "update this test's reflection lookup to match."); + + Array internalValues = Enum.GetValues(internalEnumType); + Array publicValues = Enum.GetValues(publicEnumType); + // Equal arity: every internal value must have exactly one public + // counterpart and vice versa. A mismatch here is the first sign + // either enum grew without the other (or the mapper) being updated + // to match. + Assert.True( + internalValues.Length == publicValues.Length, + $"{internalEnumType.Name} has {internalValues.Length} values " + + $"but {publicEnumType.Name} has {publicValues.Length} - keep " + + "the internal/public enum pair in lockstep."); + + foreach (object? internalValue in internalValues) + { + // Invoked via reflection deliberately - a value this mapper + // cannot handle now throws ArgumentOutOfRangeException (see + // the mapper's own doc comment), which TargetInvocationException + // propagates through Invoke and fails this test with a clear + // message identifying exactly which enum member is unmapped. + object? mapped = method!.Invoke(null, [internalValue]); + Assert.Equal(internalValue!.ToString(), mapped!.ToString()); + } + } + [Fact] public void ExecutorCompletion_ReceiptIsReadableFromWithinTheSameSynchronousOnPlacementDispatch() { From 277ef5d032b3466a8aaa2a6deb703cb3b5de0d3c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 08:17:23 +0200 Subject: [PATCH 56/73] docs(physics): decompose C3 after the flip halted with findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first C3 implementation pass landed C3-1 (fe02c4f5) and correctly stopped on two structural gaps no planning document captured: the local player's first-entry circularity (the residence opens its placement at Create, submission needs a body, and only the zero-caller publication chain can attach one — resolvable by the campaign handoff's own route-1 order, but no driveable state machine exists) and the absence of any remote-creature body construction at Create time (retail builds physics in ACCObjectMaint::CreateObject; ours arrive with first motion). The plan now records the C3a (first-entry conductor, dormant) / C3b (retail-anchored remote body construction at Create, dormant — its contract must first resolve set_description's three FPU-elided friction/translucency gates from the PDB-paired binary) / C3c (the actual host flips + connected gates) decomposition. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-02-placement-cutover.md | 40 ++++++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 625603dc..0917bfd5 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -114,13 +114,39 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. comment-enforced; `IsCurrent(Operation)` remains available and a new reentrancy-spanning call site would silently inherit the tautology — its doc comment warns. -- **C3 — spawn-frequency cutover: routes 1 + 8.** Flip graphical AND - headless initial Create/login registration to - `RegisterEntityWithInitialResidence` + executor + placement receipts - together; `MaterializeProjection`/`BuildControllerAndCamera`/ - `SynchronizeLocalPlayer` become projection + acknowledgement only; - presentation-only rebucketing (`RebucketLiveEntity` loses `CommitRebucket`). - Gates add the exact lifecycle/reconnect connected route. +- **C3 — spawn-frequency cutover: routes 1 + 8 — DECOMPOSED 2026-08-02 + after the first implementation pass stopped with findings.** C3-1 (the + public executor-completion surface via + `RuntimePlacementProjectionChannel.TryGetInitialCreateCompletion`) + landed separately. Two structural gaps halted the flip, both real and + neither in the planning docs: + **(B)** the local player's residence lease opens its SetPosition + operation at Create time, but `SubmitPreparedPlacementCore` requires a + pre-existing canonical body that only the zero-caller publication chain + can attach — first-entry needs an explicit resumable sequence + (begin-placement → publication Prepare/Commit attaches the body → + authored-mover submit → Place receipt → Execute), which matches the + campaign handoff's route-1 required order but exists nowhere as a + driveable state machine; + **(C)** ordinary remote-creature Creates classify to `SetPosition` but + have NO production body-construction path at Create time (bodies arrive + with first motion today; retail constructs physics at CreateObject via + `ACCObjectMaint::CreateObject`/`set_description`, which our retail + notes fully document — the defaults come from the wire PhysicsDesc, + not invention). + Sub-slices, each with the standing contract/dual-review/gate + discipline: + - **C3a — Runtime first-entry sequencing (dormant):** the resumable + local-player entry transaction binding residence → publication + body-attach → mover submit → receipt → Execute, test-driven. + - **C3b — remote body construction at Create (dormant):** + retail-anchored body construction from the wire PhysicsSpawnData per + `set_description` order for residence-route remote/creature Creates. + - **C3c — the host flips (production):** both hosts onto the complete + machinery; seal the Controller setter; presentation-only + rebucketing; the connected lifecycle/reconnect + nine-stop gates + (harnesses: `tools/run-connected-world-lifecycle-gate.ps1`, + `tools/run-connected-r6-soak.ps1`). - **C4 — remaining routes: 2 (ForcePosition), 3 (portal, with the `RuntimeWorldTransitState` → `RuntimePortalPlacementAuthority` adapter), 4 (remote Create/Position; delete `RemoteTeleportController`/`Placement` From 874d94bf3423ede46efaf08b2d1d30b28ed0907f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 08:21:15 +0200 Subject: [PATCH 57/73] docs(research): byte-decode set_description's three elided float gates C3b's blocking retail question, resolved byte-certain from the PDB-paired v11.4186 binary: CPhysicsObj::set_description applies the desc's friction only when 0.0 <= friction <= 1.0 (outer JNP-on-parity gate vs 0.0 double at .rdata 0x00794610; inner <= 1.0 vs 0x3FF0... at 0x007928c0), and applies live translucency + the CPartArray propagation only when translucency != 0.0f (FCOMP m32 vs 0.0f at 0x007c6a80; translucencyOriginal is written unconditionally before the gate). Every FLD/FCOM operand address read from .rdata and every FNSTSW/TEST/Jcc decoded by hand; ACE PhysicsObj.cs:3557-3568 independently reproduces all three predicates as the cross-check. Unblocks the C3b remote body-construction port. Co-Authored-By: Claude Fable 5 --- .../2026-08-02-set-description-float-gates.md | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/research/2026-08-02-set-description-float-gates.md diff --git a/docs/research/2026-08-02-set-description-float-gates.md b/docs/research/2026-08-02-set-description-float-gates.md new file mode 100644 index 00000000..3fa4c8a5 --- /dev/null +++ b/docs/research/2026-08-02-set-description-float-gates.md @@ -0,0 +1,221 @@ +# CPhysicsObj::set_description @ 0x00514F40 — three FPU-elided gates recovered + +## Verification chain + +1. **Binary/PDB pairing**: `py tools/pdb-extract/check_exe_pdb.py "C:/Users/erikn/Downloads/acclient.exe"` + → `=== MATCH: this exe pairs with our acclient.pdb ===` (GUID + `9e847e2f-777c-4bd9-886c-22256bb87f32`, linker timestamp + 2013-09-06T00:17:56Z). Confirmed before any byte reads. +2. **PE section mapping** (hand-parsed via a one-off script, + `scratchpad/pe_read.py`): image base `0x00400000`; + `.text` VA=0x00401000 RawPtr=0x00001000; + `.rdata` VA=0x00792000 RawPtr=0x00392000 (holds the FP constants below). + VA→file-offset: `file_off = raw_ptr + (VA - image_base - section_virt_addr)`. +3. Raw bytes of the function (`0x00514F40`–`0x00515153`) were dumped and + hand-disassembled instruction-by-instruction, cross-checked line-by-line + against `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines + 283130–283251 (function body) so every address in the trace lines up + with a named pseudo-C statement. +4. **Ghidra MCP**: not available this session — no CodeBrowser open on + port 8080/8081 (both probes returned empty). Not needed; binary + ACE + agreement below is already two independent confirmations. +5. **ACE cross-check**: `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` + (main checkout, not the af5e worktree — ACE isn't vendored there), + `set_description`, lines 3557–3568. ACE's C# independently reproduces + all three predicates exactly as decoded from the binary below. Binary + is the ground truth per project policy; ACE here is 100% consistent + with it, so no conflict to adjudicate. + +Confidence: **byte-certain** for all three. Every constant was read +directly from `.rdata`, every comparison/jump opcode was decoded from +the raw instruction stream, and the result matches ACE's independent +port line-for-line. + +--- + +## Conditional 1 — friction OUTER gate (pseudo-C line 283219, VA 0x0051505a) + +### Bytes + +``` +0051504f: d9 46 68 FLD DWORD PTR [ESI+0x68] ; ST(0) = (double)esi->friction (PhysicsDesc.friction @ +0x68) +00515052: dc 15 10 46 79 00 FCOM QWORD PTR [0x00794610] ; compare ST(0) vs constant, no pop (value reused below) +00515058: df e0 FNSTSW AX +0051505a: f6 c4 05 TEST AH, 0x05 ; mask = C0(bit0) | C2(bit2) +0051505d: 7b 15 JNP 0x00515074 ; jump (skip friction block) iff PF=0 +``` + +### Constant + +VA `0x00794610` (.rdata, file offset `0x00394610`), 8 bytes: +`00 00 00 00 00 00 00 00` → **`0.0` (double, exact)**. + +### Decoding the jump + +`TEST AH,0x05` ANDs AH with the C0|C2 status bits, then the parity flag +(PF) reflects the parity of that AND result. Case table for +`FCOM esi->friction, 0.0` (ST0=friction): + +| relation | C0 | C2 | C3 | AH&0x05 | popcount | PF | +|---|---|---|---|---|---|---| +| friction > 0.0 | 0 | 0 | 0 | 0x00 | 0 | 1 | +| friction < 0.0 | 1 | 0 | 0 | 0x01 | 1 | **0** | +| friction == 0.0 | 0 | 0 | 1 | 0x00 | 0 | 1 | +| unordered (NaN) | 1 | 1 | 1 | 0x05 | 2 | 1 | + +`JNP` (jump on PF=0) only fires for the strict `<` case. So the jump +(which SKIPS the whole friction reassignment block, landing at the +shared cleanup at `0x00515074`) is taken **only when `friction < 0.0`**; +every other case (`>= 0.0`, and — as an accepted compiler-quirk +edge case irrelevant to real game data — unordered/NaN) falls through +into the block. + +### Recovered predicate + +```c +// outer gate: proceed to the friction-assignment logic only when friction is non-negative +if (esi->friction >= 0.0f) { + // ... inner compare (Conditional 2) ... +} +``` + +--- + +## Conditional 2 — friction INNER compare (pseudo-C line 283226, VA 0x0051506a) + +### Bytes + +``` +0051505f: dc 15 c0 28 79 00 FCOM QWORD PTR [0x007928c0] ; compare ST(0)=friction vs constant, no pop +00515065: df e0 FNSTSW AX +00515067: f6 c4 41 TEST AH, 0x41 ; mask = C0(bit0) | C3(bit6) +0051506a: 74 08 JZ 0x00515074 ; jump (skip assignment) iff (AH&0x41)==0 +0051506c: d9 9f bc 00 00 00 FSTP DWORD PTR [EDI+0xbc] ; this->friction = friction (field @ +0xbc), pops ST(0) +``` + +Pseudo-C had already fully rendered the C0/C2/C3 synthetic-byte +construction for this one (only the final `test ah,0x41`→bool +collapse was marked unimplemented), so the byte read is a +confirmation rather than a fresh recovery. + +### Constant + +VA `0x007928c0` (.rdata, file offset `0x003928c0`), 8 bytes: +`00 00 00 00 00 00 f0 3f` → **`1.0` (double, exact; IEEE-754 bit +pattern `0x3FF0000000000000`)**. + +### Decoding the jump + +Mask `0x41` = C0(below) | C3(equal). `JZ` (jump when the TEST result +is zero, i.e. neither bit set) skips the assignment when friction is +strictly `>` 1.0. Falls through (assigns `this->friction`) when +`friction <= 1.0` (below-or-equal family, exactly as flagged in the +task). This is the canonical `jbe` idiom. + +### Recovered predicate + +```c +// inner compare: only assign if friction also passes the upper bound +if (esi->friction <= 1.0f) + this->friction = esi->friction; +``` + +### Combined (conditionals 1+2) + +```c +if (esi->friction >= 0.0f && esi->friction <= 1.0f) + this->friction = esi->friction; +``` + +This is byte-for-byte what ACE's port does at +`PhysicsObj.cs:3557-3558`: `if (desc.Friction >= 0.0f && desc.Friction <= 1.0f) Friction = desc.Friction;` + +--- + +## Conditional 3 — translucency gate (pseudo-C line 283240, VA 0x0051509f) + +### Bytes + +``` +0051508b: d9 44 24 24 FLD DWORD PTR [ESP+0x24] ; ST(0) = (float)translucency (local copy of esi->translucency, field @ esi+0x70) +0051508f: d8 1d 80 6a 7c 00 FCOMP DWORD PTR [0x007c6a80] ; compare ST(0) vs constant, WITH pop (single precision, reg field=3) +00515095: 8b d1 MOV EDX, ECX +00515097: 89 97 b8 00 00 00 MOV [EDI+0xb8], EDX ; this->translucencyOriginal = translucency (unconditional) +0051509d: df e0 FNSTSW AX +0051509f: f6 c4 44 TEST AH, 0x44 ; mask = C2(bit2) | C3(bit6) +005150a2: 7b 15 JNP 0x005150b9 ; jump (skip live-translucency apply) iff PF=0 +005150a4: ... ; fallthrough: this->translucency = translucency; PartArray propagation +``` + +### Constant + +VA `0x007c6a80` (.rdata, file offset `0x003c6a80`), 4 bytes: +`00 00 00 00` → **`0.0f` (single-precision float, exact)**. Note this +compare is single-precision (`d8`/`FCOMP m32`), unlike the two +friction compares above which are double-precision (`dc`/`FCOM m64`) — +matches the pseudo-C's `((long double)0f)` literal notation (the `f` +suffix is BN flagging a float-typed constant) versus `((long +double)0.0)` for the friction case. + +### Decoding the jump + +Case table for `FCOMP translucency, 0.0f` (ST0=translucency), mask +`0x44` = C2(unordered) | C3(equal): + +| relation | C0 | C2 | C3 | AH&0x44 | popcount | PF | +|---|---|---|---|---|---|---| +| translucency > 0.0 | 0 | 0 | 0 | 0x00 | 0 | 1 | +| translucency < 0.0 | 1 | 0 | 0 | 0x00 | 0 | 1 | +| translucency == 0.0 | 0 | 0 | 1 | 0x40 | 1 | **0** | +| unordered (NaN) | 1 | 1 | 1 | 0x44 | 2 | 1 | + +`JNP` (PF=0) fires **only** for the exact-equal-to-zero case. So the +jump — which skips applying live `translucency`/PartArray propagation, +leaving only the unconditional `translucencyOriginal` write — is taken +**only when `translucency == 0.0f`**. Every other case (`>0`, `<0`, +and unordered/NaN as a compiler-quirk edge case) falls through and +applies. + +### Recovered predicate + +```c +// translucencyOriginal is ALWAYS written (this happens before the gate, unconditionally) +this->translucencyOriginal = translucency; + +// live translucency + PartArray propagation only when translucency is non-zero +if (translucency != 0.0f) +{ + this->translucency = translucency; + if (this->part_array != 0) + CPartArray::SetTranslucencyInternal(this->part_array, translucency); +} +``` + +Matches ACE's port at `PhysicsObj.cs:3562-3568` exactly: +```csharp +TranslucencyOriginal = desc.Translucency; +if (desc.Translucency != 0.0f) +{ + Translucency = desc.Translucency; + if (PartArray != null) + PartArray.SetTranslucencyInternal(desc.Translucency); +} +``` + +--- + +## Summary table + +| # | Gate | Predicate (apply-when) | Constant | Cert. | +|---|---|---|---|---| +| 1 | friction outer | `friction >= 0.0f` | `0.0` (double) @ VA 0x00794610 | byte-certain | +| 2 | friction inner | `friction <= 1.0f` | `1.0` (double) @ VA 0x007928c0 | byte-certain | +| 3 | translucency | `translucency != 0.0f` | `0.0f` (float) @ VA 0x007c6a80 | byte-certain | + +All three: no unresolved cases. The only caveat on all three is a +decompiler/compiler-codegen edge case around NaN (unordered operands +fall into the "true"/apply bucket rather than IEEE-strict "always +false"), which is a documented quirk of this exact MSVC x87 codegen +pattern and not something the retail struct's `float` fields would +ever hit in practice (friction/translucency are authored data, never +NaN). From 960373df2e86090d3fcf323bb9ecacac9677b637 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 09:43:15 +0200 Subject: [PATCH 58/73] feat(runtime): first-entry conductor sequences local-player world entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cutover slice C3a: the resumable transaction that dissolves the C3 flip's circularity finding. RuntimeLocalPlayerFirstEntryState drives the local player's complete entry in retail's own order — authored-mover preparation (the makeObject/set_description shape analog, via a pure no-submit extraction TryPrepareAuthoredMover), the publication chain's off-canonical Prepare + atomic body Commit against the residence's exact placement token, the Evaluate/CommitActivation enter-world analog, the Place-receipt acknowledgement as that act's virtualized completion, and only then the executor's FIFO drain (retail: enter_world at 93824 strictly precedes ProcessObjectNetBlobs at 93831). Five stages, eight typed statuses, exactly-once per stage under retry, no second token copies, and an acknowledge-stage discriminator that separates not-yet-FIFO-head (retryable) from authority-moved (typed abandonment) — a mid-flight delete can no longer strand a retry-forever entry. The residence retirement notification becomes an ordered multicast (snapshot-iterated per the event-stream precedent), the lifetime constructs the conductor with a late-bind Publication seam (transactional unbound failure — no mutation before the throw), deletion/reset converge the conductor automatically through the same choke points as the executor, and its active count is in the ownership snapshot and IsConverged. Dormant: no production Advance caller; GameRuntime binding is C3c's first act. Reviewed: retail-conformance PASS (the stage order verified step-for-step against retail's entry sequence; the live-controller-on- abandonment invariant proven structurally enforced and retail-correct — retail has no entry-flow rollback) + architecture/adversarial PASS after one fix round (acknowledge-stage authority discrimination; the wiring fold; two prescribed pre-C3c hardenings). Runtime 948/948; complete Release solution green across all nine projects. Co-Authored-By: Claude Fable 5 --- .../Entities/RuntimeEntityObjectLifetime.cs | 64 +- .../RuntimeInitialCreateResidenceState.cs | 54 +- .../RuntimeLocalPlayerFirstEntryState.cs | 690 +++++++++++++ .../Physics/RuntimeSetPositionState.cs | 68 +- ...RuntimeInitialCreateResidenceStateTests.cs | 47 + .../RuntimeLocalPlayerFirstEntryStateTests.cs | 922 ++++++++++++++++++ 6 files changed, 1816 insertions(+), 29 deletions(-) create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index c3a1e293..f9c96d00 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -4,6 +4,7 @@ using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; +using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; namespace AcDream.Runtime.Entities; @@ -56,7 +57,14 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( /// SAME underlying receipt stream - unlike ReplayFailureCount above, /// this is NOT a diagnostic-only counter. /// - int PendingCompletionReceiptCount = 0) + int PendingCompletionReceiptCount = 0, + /// + /// C3a/F2: outstanding AcDream.Runtime.Gameplay.RuntimeLocalPlayerFirstEntryState + /// tracked keys - dormant (no production caller of its own + /// Advance), but fully constructed/wired like every other owner + /// here, so its own ownership must converge to zero the same way. + /// + int LocalPlayerFirstEntryActiveCount = 0) { public bool IsConverged => IsDisposed @@ -78,6 +86,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && InitialCreateResidenceLeaseCount == 0 && InitialCreateExecutorProgressCount == 0 && PendingCompletionReceiptCount == 0 + && LocalPlayerFirstEntryActiveCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -157,6 +166,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RegisterEntityWithInitialResidence(spawn, isLocalPlayer), (canonical, version, spawn, replaceGeneration) => ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // C3a/F2: dormant - no production caller of its own Advance - but + // constructed and wired exactly like the executor above so its + // ownership converges the same way. RuntimeLocalPlayerPhysicsPublicationState + // does not exist yet at this point (GameRuntime builds + // RuntimeLocalPlayerMovementState, then attaches its publication, + // only after this lifetime); BindPublication supplies it later. + LocalPlayerFirstEntry = new RuntimeLocalPlayerFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -165,6 +184,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // residence state referencing the executor type directly. InitialCreateResidences.BindRetirementNotification( key => InitialCreateExecution.DiscardProgress(key)); + // C3a/F2: the SAME multicast retirement notification also reaps the + // first-entry conductor's tracked progress - see + // RuntimeInitialCreateResidenceState.BindRetirementNotification's + // updated doc comment for why this is now multicast. + InitialCreateResidences.BindRetirementNotification( + key => LocalPlayerFirstEntry.Forget(key)); // F2: reaps the executor's completion-receipt correlation entry // exactly when a host acknowledges the ExecutorCompleted receipt it // correlates - mirrors the residence-retirement binding immediately @@ -210,6 +235,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RegisterEntityWithInitialResidence(spawn, isLocalPlayer), (canonical, version, spawn, replaceGeneration) => ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // C3a/F2: dormant - no production caller of its own Advance - but + // constructed and wired exactly like the executor above so its + // ownership converges the same way. RuntimeLocalPlayerPhysicsPublicationState + // does not exist yet at this point (GameRuntime builds + // RuntimeLocalPlayerMovementState, then attaches its publication, + // only after this lifetime); BindPublication supplies it later. + LocalPlayerFirstEntry = new RuntimeLocalPlayerFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -218,6 +253,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // residence state referencing the executor type directly. InitialCreateResidences.BindRetirementNotification( key => InitialCreateExecution.DiscardProgress(key)); + // C3a/F2: the SAME multicast retirement notification also reaps the + // first-entry conductor's tracked progress - see + // RuntimeInitialCreateResidenceState.BindRetirementNotification's + // updated doc comment for why this is now multicast. + InitialCreateResidences.BindRetirementNotification( + key => LocalPlayerFirstEntry.Forget(key)); // F2: reaps the executor's completion-receipt correlation entry // exactly when a host acknowledges the ExecutorCompleted receipt it // correlates - mirrors the residence-retirement binding immediately @@ -263,6 +304,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RegisterEntityWithInitialResidence(spawn, isLocalPlayer), (canonical, version, spawn, replaceGeneration) => ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // C3a/F2: dormant - no production caller of its own Advance - but + // constructed and wired exactly like the executor above so its + // ownership converges the same way. RuntimeLocalPlayerPhysicsPublicationState + // does not exist yet at this point (GameRuntime builds + // RuntimeLocalPlayerMovementState, then attaches its publication, + // only after this lifetime); BindPublication supplies it later. + LocalPlayerFirstEntry = new RuntimeLocalPlayerFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -271,6 +322,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // residence state referencing the executor type directly. InitialCreateResidences.BindRetirementNotification( key => InitialCreateExecution.DiscardProgress(key)); + // C3a/F2: the SAME multicast retirement notification also reaps the + // first-entry conductor's tracked progress - see + // RuntimeInitialCreateResidenceState.BindRetirementNotification's + // updated doc comment for why this is now multicast. + InitialCreateResidences.BindRetirementNotification( + key => LocalPlayerFirstEntry.Forget(key)); // F2: reaps the executor's completion-receipt correlation entry // exactly when a host acknowledges the ExecutorCompleted receipt it // correlates - mirrors the residence-retirement binding immediately @@ -295,6 +352,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { get; } internal RuntimeInitialCreateContinuationExecutor InitialCreateExecution { get; } + internal RuntimeLocalPlayerFirstEntryState LocalPlayerFirstEntry { get; } public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership() { @@ -330,7 +388,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable parents.DeferredAcceptedRelationCount, InitialCreateExecution.ReplayFailureCount, InitialCreateExecution.LastReplayFailure is not null, - InitialCreateExecution.PendingCompletionReceiptCount); + InitialCreateExecution.PendingCompletionReceiptCount, + LocalPlayerFirstEntry.CaptureOwnership().ActiveCount); } public void BindEventContext( @@ -1674,6 +1733,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); InitialCreateResidences.Clear(); InitialCreateExecution.DiscardAll(); + LocalPlayerFirstEntry.DiscardAll(); Physics.CollisionReports.LeaveWorldBatch(active); Physics.ResetSessionPhysics(); Entities.BeginSessionClear(); diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs index 636f1725..74e3fbb1 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs @@ -489,7 +489,7 @@ internal sealed class RuntimeInitialCreateResidenceState private readonly Dictionary _entries = []; private readonly Dictionary _completed = []; private Func? _generation; - private Action? _retirementNotification; + private readonly List> _retirementNotifications = []; private ulong _nextLeaseId; internal RuntimeInitialCreateResidenceState( @@ -524,17 +524,36 @@ internal sealed class RuntimeInitialCreateResidenceState /// leave the executor's progress AND its separately-tracked pending /// continuation placement token orphaned - this class owns no reference /// to the executor type, so the lifetime binds a plain delegate here - /// instead. + /// instead. Multicast (ordered invocation list, registration order) so a + /// second independent per-key owner (e.g. the local-player first-entry + /// conductor) can subscribe to the SAME retirements the executor already + /// does, without either overwriting the other's binding. /// internal void BindRetirementNotification(Action notify) { ArgumentNullException.ThrowIfNull(notify); - if (_retirementNotification is not null) - { - throw new InvalidOperationException( - "The initial Create residence retirement notification is already bound."); - } - _retirementNotification = notify; + _retirementNotifications.Add(notify); + } + + /// + /// H1: snapshots the subscriber list before invoking anything, mirroring + /// 's own copy-on-write + /// dispatch precedent. A subscriber binding a NEW notification from + /// inside a retirement callback it is itself receiving (e.g. a future + /// third, runtime-bound subscriber added at C3c) must not corrupt or be + /// skipped by THIS iteration - _retirementNotifications is a + /// plain , so iterating it directly while + /// appends to it mid-loop would + /// throw ("Collection was + /// modified"). ToArray() is the right granularity here (unlike + /// the event stream's -guarded array swap) because + /// binding only ever happens a handful of times at construction, never + /// on a hot per-frame path. + /// + private void NotifyRetirement(RuntimeEntityKey key) + { + foreach (Action notify in _retirementNotifications.ToArray()) + notify(key); } internal bool CanAcceptCreate(WorldSession.EntitySpawn incoming) @@ -1003,7 +1022,7 @@ internal sealed class RuntimeInitialCreateResidenceState lease = entry.Lease; cancellation = _setPosition.ForgetExactPlacement( lease.Placement); - _retirementNotification?.Invoke(key); + NotifyRetirement(key); return true; } if (record.Key is { } completedKey @@ -1016,7 +1035,7 @@ internal sealed class RuntimeInitialCreateResidenceState lease = completed.Lease; cancellation = _setPosition.ForgetExactPlacement( lease.Placement); - _retirementNotification?.Invoke(completedKey); + NotifyRetirement(completedKey); return true; } lease = default; @@ -1052,13 +1071,10 @@ internal sealed class RuntimeInitialCreateResidenceState { _setPosition.PublishCancellation(cancellations[index]); } - if (_retirementNotification is { } notify) - { - foreach (Entry entry in active) - notify(entry.Lease.Token.Entity); - foreach (CompletedEntry entry in completed) - notify(entry.Receipt.Token.Entity); - } + foreach (Entry entry in active) + NotifyRetirement(entry.Lease.Token.Entity); + foreach (CompletedEntry entry in completed) + NotifyRetirement(entry.Receipt.Token.Entity); } internal RuntimeInitialCreateResidenceOwnershipSnapshot CaptureOwnership() => @@ -1245,7 +1261,7 @@ internal sealed class RuntimeInitialCreateResidenceState RuntimePlacementCancellationReceipt cancellation = _setPosition.ForgetExactPlacement(entry.Lease.Placement); _setPosition.PublishCancellation(cancellation); - _retirementNotification?.Invoke(key); + NotifyRetirement(key); } private void Retire(CompletedEntry entry) @@ -1255,6 +1271,6 @@ internal sealed class RuntimeInitialCreateResidenceState RuntimePlacementCancellationReceipt cancellation = _setPosition.ForgetExactPlacement(entry.Lease.Placement); _setPosition.PublishCancellation(cancellation); - _retirementNotification?.Invoke(key); + NotifyRetirement(key); } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs new file mode 100644 index 00000000..c5629e5d --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs @@ -0,0 +1,690 @@ +using AcDream.Content; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Gameplay; + +/// +/// Typed yields for . +/// Mirrors the executor's own RuntimeInitialCreateExecutionStatus +/// shape (terminal Completed/RejectedToken/RejectedAuthority plus named +/// retryable yields) rather than inventing a parallel vocabulary. +/// +internal enum RuntimeLocalPlayerFirstEntryStatus : byte +{ + /// + /// The underlying + /// call reported Completed: residence consumed, initial tail and + /// FIFO drained, ExecutorCompleted receipt dispatched on the placement + /// stream. Terminal; the conductor's own progress entry is removed. + /// + Completed, + + /// + /// The authored-mover Setup read + /// () is not + /// yet available (RetrySetupUnavailable) — the prepared-asset + /// package has not landed. Retry + /// with the same arguments once it has; no Runtime state changed. + /// + AwaitingCollisionSource, + + /// + /// + /// or + /// yielded DeferredCell or a retryable RejectedPlacement — + /// the destination cell's collision generation is not ready, or the + /// placement needs re-evaluation after some other change. Retry later + /// (e.g. on a collision-generation wake); the dormant activation lease + /// itself remains intact and is re-driven from the same stage. + /// + AwaitingActivation, + + /// + /// The activation committed but its Place projection has not been + /// acknowledged yet — either + /// was not (yet) the exact FIFO head, or the subsequent + /// call + /// still observed PendingPlacement from + /// . Retry the + /// same stage. + /// + AwaitingReceiptAcknowledgement, + + /// + /// Passthrough of the executor's own AwaitingContinuationPlacement + /// — a later FIFO continuation (a Position update accepted while this + /// entity's initial placement was in flight) needs its own authored + /// placement prepared/submitted/acknowledged before the drain can + /// finish. Entirely the executor's own concern from this point forward; + /// the conductor's job (residence -> publication -> Execute) is done as + /// soon as it reaches this yield. + /// + AwaitingContinuationPlacement, + + /// + /// A reentrant + /// call for the SAME entity arrived while an outer call for it was still + /// on the stack (mirrors the executor's own _executing fail-closed + /// guard). Not a Runtime-state rejection — retry once the outer call has + /// returned. + /// + Contention, + + /// The residence/placement token no longer matches anything tracked. + RejectedToken, + + /// + /// An authority-shaped failure (stale epoch/session/identity, deleted or + /// replaced record, GUID reuse, disposed identity, or an inner + /// currency check failing during a reentrant callback). Abandoned: the + /// conductor's own in-flight publication candidate/activation (if any) + /// is discarded through the shared choke points and its progress entry + /// is removed. The caller must begin a fresh first-entry sequence + /// (a new residence lease) rather than retry this exact call. + /// + RejectedAuthority, +} + +internal readonly record struct RuntimeLocalPlayerFirstEntryOwnershipSnapshot( + int ActiveCount) +{ + internal bool IsConverged => ActiveCount == 0; +} + +/// +/// The dormant, resumable Runtime transaction that dissolves C3's Finding B: +/// the local player's initial residence lease opens its SetPosition operation +/// at Create time, but nothing wires the mover-preparation -> +/// body/controller +/// attach -> placement acknowledgement -> FIFO drain sequence together into +/// one driveable state machine. This class ORCHESTRATES the existing, +/// already-tested residence +/// (), publication +/// (), mover +/// (), and +/// executor () +/// machinery — it reimplements none of their validation and bypasses none of +/// their staged semantics. +/// +/// Required order (campaign handoff route-1, +/// docs/research/2026-07-31-remaining-physics-campaign-handoff.md:280-292): +/// residence Begin (already done at registration, before this class is ever +/// invoked) -> authored-mover preparation (Setup read + PrepareMover, which +/// MUST precede publication Prepare — +/// 's own +/// CanPrepare gate requires +/// to +/// already be true) -> publication Prepare -> publication Commit (the +/// +/// seam attaches the body) -> activation Evaluate/Commit/Finalize -> +/// Place-receipt acknowledgement -> +/// (FIFO drain) -> ExecutorCompleted receipt. +/// +/// Once +/// sets an operation's DormantLocalActivation flag, +/// (and the +/// fused ) +/// must never be called against it again — see RetryDeferred's "must +/// never bypass that path through the ordinary remote CommitCanonical tail" +/// comment — so this class calls the mover-only +/// half instead +/// and never the fused method. +/// +/// Dormant by design: fully +/// constructs and wires this class (construction, publication binding, +/// retirement fan-out, bulk session-clear cleanup, ownership fold) exactly +/// like every other owner it builds, but nothing calls +/// in production — a later slice wires a host to drive +/// it. +/// +internal sealed class RuntimeLocalPlayerFirstEntryState +{ + private enum Stage : byte + { + /// No progress yet, or the mover has not been prepared. + AwaitingMoverPreparation, + + /// Mover command in hand; publication Prepare+Commit not run yet. + MoverPrepared, + + /// + /// Publication Prepare+Commit succeeded (the body/controller are + /// attached to the canonical record); Evaluate+CommitActivation not + /// yet reached Committed. Also the retry point for + /// DeferredCell/RejectedPlacement. + /// + PublicationCommitted, + + /// + /// CommitActivation reached Committed; the Place projection is + /// known but not yet acknowledged. + /// + ActivationCommitted, + + /// + /// The Place projection has been acknowledged. Only + /// + /// remains; re-acknowledging the same (already-consumed) token would + /// fail, so this stage is never re-entered by the acknowledgement + /// step. + /// + Acknowledged, + } + + private sealed class Progress + { + internal required ulong LeaseId { get; init; } + internal Stage Stage { get; set; } = Stage.AwaitingMoverPreparation; + internal RuntimeSetPositionCommand PreparedCommand { get; set; } + internal RuntimeLocalPlayerPhysicsPublicationToken PublicationToken + { get; set; } + internal RuntimeLocalPlayerPhysicsActivationToken ActivationToken + { get; set; } + internal RuntimePlacementProjectionToken Projection { get; set; } + } + + private readonly RuntimeInitialCreateResidenceState _residences; + private readonly RuntimeInitialCreateContinuationExecutor _executor; + private readonly RuntimePhysicsState _physics; + private RuntimeLocalPlayerPhysicsPublicationState? _publication; + private readonly Dictionary _progress = []; + private readonly HashSet _executing = []; + + /// + /// F2: constructs this class + /// (alongside the residence and executor it also owns) BEFORE + /// exists — + /// GameRuntime creates RuntimeLocalPlayerMovementState and + /// attaches its physics publication only after the entity-object lifetime + /// is already built. This mirrors the SAME late-bind pattern already + /// used throughout this class family (BindGeneration, + /// BindRetirementNotification, BindLiveInputs, + /// RuntimeLocalPlayerMovementState.PhysicsPublication's own + /// throws-if-unbound accessor) rather than requiring the caller to + /// construct things out of their natural order. + /// + internal RuntimeLocalPlayerFirstEntryState( + RuntimeInitialCreateResidenceState residences, + RuntimeInitialCreateContinuationExecutor executor, + RuntimePhysicsState physics) + { + _residences = residences + ?? throw new ArgumentNullException(nameof(residences)); + _executor = executor + ?? throw new ArgumentNullException(nameof(executor)); + _physics = physics + ?? throw new ArgumentNullException(nameof(physics)); + } + + internal void BindPublication( + RuntimeLocalPlayerPhysicsPublicationState publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (_publication is not null) + { + throw new InvalidOperationException( + "The local-player first-entry conductor's publication owner is already bound."); + } + _publication = publication; + } + + private RuntimeLocalPlayerPhysicsPublicationState Publication => + _publication ?? throw new InvalidOperationException( + "The local-player first-entry conductor's publication owner is not yet bound."); + + /// + /// One resumable step. Callers pass the SAME arguments on every retry; + /// this method re-reads currency from the owning states on every entry + /// rather than trusting anything cached beyond its own stage cursor and + /// the exact token/receipt/projection structs each owning method itself + /// requires as arguments — there is no other source for those; they are + /// the "exact keys" this class carries, not a second copy of any owning + /// state's internal record. + /// + internal RuntimeLocalPlayerFirstEntryStatus Advance( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + PlayerMovementConstructionOptions options, + in RuntimeLocalPlayerPhysicsActivationPreparation activationPreparation, + IPreparedCollisionSource collisionSource, + double gameTime, + in RuntimeInitialCreateExecutionInputs inputs, + out RuntimeInitialCreateExecutionReceipt receipt) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(collisionSource); + receipt = default; + if (!residenceToken.IsValid || record.Key is not { } key) + return RuntimeLocalPlayerFirstEntryStatus.RejectedToken; + + // Mirrors RuntimeInitialCreateContinuationExecutor.Execute's own + // _executing.Add(key) guard: a synchronous reentrant call for the + // SAME entity (e.g. from a collision-report/placement observer + // invoked mid-Advance) fails closed rather than interleaving two + // drains of the same stage machine. + if (!_executing.Add(key)) + return RuntimeLocalPlayerFirstEntryStatus.Contention; + try + { + return AdvanceCore( + record, + residenceToken, + options, + activationPreparation, + collisionSource, + gameTime, + inputs, + key, + out receipt); + } + finally + { + _executing.Remove(key); + } + } + + private RuntimeLocalPlayerFirstEntryStatus AdvanceCore( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + PlayerMovementConstructionOptions options, + in RuntimeLocalPlayerPhysicsActivationPreparation activationPreparation, + IPreparedCollisionSource collisionSource, + double gameTime, + in RuntimeInitialCreateExecutionInputs inputs, + RuntimeEntityKey key, + out RuntimeInitialCreateExecutionReceipt receipt) + { + receipt = default; + + // H2: fail transactionally, before any state mutation, if + // Publication has not been bound yet. Without this upfront check, + // an unbound call could still get as far as authored-mover + // preparation (which mutates RuntimeSetPositionState's own + // _preparedMovers) and creating THIS class's own Progress entry + // (stored into _progress) before the first Publication dereference + // (inside the MoverPrepared stage below) throws — leaving a + // poisoned Progress entry that a later, unrelated Discard/DiscardAll + // call (from a retirement notification or session-clear fan-out) + // would ALSO throw on. Referencing the accessor here throws + // immediately with nothing yet mutated. + _ = Publication; + + _progress.TryGetValue(key, out Progress? progress); + // ABA/GUID-reuse guard, exactly like the executor's own Progress + // reconciliation: an existing entry for a DIFFERENT (older or + // reused) lease id can never be resumed by this call. + if (progress is not null && progress.LeaseId != residenceToken.LeaseId) + { + Discard(key); + progress = null; + } + + if (progress is null || progress.Stage is Stage.AwaitingMoverPreparation) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + // No progress was ever tracked for this key under this exact + // lease — mirrors RuntimeInitialCreateResidenceState.Complete's + // own convention (a token matching neither its active nor its + // completed table is RejectedToken, not RejectedAuthority). + // Only abandon (RejectedAuthority) when THIS class was + // actually tracking in-flight publication/activation state + // that must now be discarded. + if (progress is null) + return RuntimeLocalPlayerFirstEntryStatus.RejectedToken; + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + + if (!lease.Route.PerformsSetPosition) + { + // Parented/PickedUp residence — never true for a real + // login, but kept for structural completeness: no + // SetPosition operation exists at all, so there is nothing + // for the publication chain to attach a body to. Skip + // straight to Execute (idempotent/retryable on its own). + progress ??= new Progress { LeaseId = residenceToken.LeaseId }; + progress.Stage = Stage.Acknowledged; + _progress[key] = progress; + return RunExecute(record, residenceToken, inputs, key, out receipt); + } + + RuntimeSetPositionMoverPreparationStatus moverStatus = _physics + .SetPosition.TryPrepareAuthoredMover( + record, + lease.Placement, + lease.Route.OperationKind, + lease.Route.SetPositionFlags, + collisionSource, + gameTime, + out RuntimeSetPositionCommand command); + if (moverStatus + == RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable) + { + return RuntimeLocalPlayerFirstEntryStatus + .AwaitingCollisionSource; + } + if (moverStatus != RuntimeSetPositionMoverPreparationStatus.Prepared) + { + if (progress is not null) + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + + progress ??= new Progress { LeaseId = residenceToken.LeaseId }; + progress.PreparedCommand = command; + progress.Stage = Stage.MoverPrepared; + _progress[key] = progress; + } + + if (progress.Stage is Stage.MoverPrepared) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + + RuntimeLocalPlayerPhysicsPublicationStatus prepareStatus = + Publication.Prepare( + record, + lease.Placement, + progress.PreparedCommand, + options, + activationPreparation, + out RuntimeLocalPlayerPhysicsPublicationToken pubToken); + if (prepareStatus + != RuntimeLocalPlayerPhysicsPublicationStatus.Prepared) + { + // Prepare mutates nothing canonical on rejection (its own + // second CanPrepare recheck discards any just-built + // candidate itself); there is nothing further for this + // class to undo beyond dropping its own progress entry. + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + + progress.PublicationToken = pubToken; + + RuntimeLocalPlayerPhysicsPublicationStatus commitStatus = + Publication.Commit( + pubToken, + out RuntimeLocalPlayerPhysicsActivationToken activationToken); + if (commitStatus != RuntimeLocalPlayerPhysicsPublicationStatus.Committed) + { + Discard(key); + return commitStatus + is RuntimeLocalPlayerPhysicsPublicationStatus.RejectedToken + ? RuntimeLocalPlayerFirstEntryStatus.RejectedToken + : RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + + progress.ActivationToken = activationToken; + progress.Stage = Stage.PublicationCommitted; + } + + if (progress.Stage is Stage.PublicationCommitted) + { + // A single combined retry point for Evaluate+CommitActivation. + // Every publication test that hits DeferredCell/RejectedPlacement + // WITHOUT an intervening CommitActivation call chains both calls + // together and retries both together; running EvaluateActivation + // again before every CommitActivation retry is safe even for + // CommitActivation's own internal AwaitingFinalShadowPreparation + // resumption (its top-of-method check re-validates + // activation.Receipt == receipt, which a fresh Evaluate call + // satisfies, before consulting the untouched stored + // PendingFinalCommit). + // + // EvaluateActivation's DeferredCell status is overloaded: once a + // PRIOR CommitActivation call has already registered this lease + // as awaiting a specific cell/collision generation + // (IsDormantLocalActivationAwaitingCell), a REPEATED + // EvaluateActivation call that is still not ready returns + // DeferredCell WITHOUT ever populating receipt (it stays + // default/invalid) — see + // DeferredCommitWaitsThenRearmsSameLeaseAfterExactGenerationWake + // in the publication test suite, which asserts exactly + // `waiting.IsValid == false` on that repeat call and never feeds + // it to CommitActivation. Calling CommitActivation with that + // invalid receipt would hit its own `!receipt.IsValid` guard and + // incorrectly report RejectedAuthority instead of "still + // waiting" — so this class must check validity first and simply + // yield AwaitingActivation again without calling CommitActivation + // at all in that case. + RuntimeLocalPlayerPhysicsActivationStatus evalStatus = + Publication.EvaluateActivation( + progress.ActivationToken, + out RuntimeLocalPlayerPhysicsActivationReceipt evalReceipt); + if (evalStatus is + RuntimeLocalPlayerPhysicsActivationStatus.RejectedToken + or RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority) + { + Discard(key); + return evalStatus + is RuntimeLocalPlayerPhysicsActivationStatus.RejectedToken + ? RuntimeLocalPlayerFirstEntryStatus.RejectedToken + : RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + if (!evalReceipt.IsValid) + { + // DeferredCell with nothing to commit — the destination + // cell/collision generation genuinely is not resolvable yet. + return RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation; + } + + RuntimeDormantSetPositionCommitStatus commitActivationStatus = + Publication.CommitActivation( + evalReceipt, + out RuntimePlacementProjectionToken projection); + switch (commitActivationStatus) + { + case RuntimeDormantSetPositionCommitStatus.Committed: + progress.Projection = projection; + progress.Stage = Stage.ActivationCommitted; + break; + case RuntimeDormantSetPositionCommitStatus.DeferredCell: + case RuntimeDormantSetPositionCommitStatus.RejectedPlacement: + // Stage stays PublicationCommitted — retry re-runs both + // EvaluateActivation and CommitActivation next Advance. + return RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation; + default: + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + } + + if (progress.Stage is Stage.ActivationCommitted) + { + if (!_physics.SetPosition.AcknowledgeProjection( + progress.Projection)) + { + // A failed acknowledge is retryable ONLY while nothing has + // moved authority out from under this exact projection — a + // genuinely later entity simply sitting ahead of ours in the + // FIFO. It is NOT automatically retryable: a mid-flight + // delete (TryAcceptDelete -> CompleteProjectionRetirement -> + // Physics.SetPosition.Forget -> CancelCoreDeferred) rewrites + // the SAME pending slot from Place to Discard with a bumped + // Revision, so the exact struct this class cached in + // progress.Projection can never match the FIFO head again — + // without this check, AcknowledgeProjection would fail + // forever and this progress entry would never converge. + if (!IsAcknowledgementStillPending( + record, residenceToken, progress.Projection)) + { + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + return RuntimeLocalPlayerFirstEntryStatus + .AwaitingReceiptAcknowledgement; + } + progress.Stage = Stage.Acknowledged; + } + + return RunExecute(record, residenceToken, inputs, key, out receipt); + } + + /// + /// Re-validates authority after a failed acknowledge. Two independent + /// checks, either of which failing means authority moved and this class + /// must abandon rather than keep retrying forever: (1) the residence + /// lease this whole sequence began under must still be exactly current + /// (mirrors the stage0/stage1 checks — a delete or reset retires it); + /// (2) if the FIFO head belongs to THIS entity at all, it must still be + /// the exact Place projection this class is holding — a head that + /// belongs to us but is no longer that exact token (rewritten to + /// Discard, or to a later revision) means our specific placement was + /// superseded even if the residence lookup transiently still resolves. + /// A head belonging to a DIFFERENT entity is the genuine "not yet our + /// turn" case and must stay retryable. This reads the Runtime-internal + /// directly + /// rather than through the public, generation-gated + /// — this class is part + /// of Runtime, not an external host crossing that boundary, exactly like + /// its existing direct + /// call above. + /// + private bool IsAcknowledgementStillPending( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + in RuntimePlacementProjectionToken expected) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + return false; + } + + if (_physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head) + && head.Token.Entity == expected.Entity + && (head.Kind is not RuntimePlacementProjectionKind.Place + || head.Token != expected)) + { + return false; + } + + return true; + } + + private RuntimeLocalPlayerFirstEntryStatus RunExecute( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + in RuntimeInitialCreateExecutionInputs inputs, + RuntimeEntityKey key, + out RuntimeInitialCreateExecutionReceipt receipt) + { + RuntimeInitialCreateExecutionStatus executeStatus = _executor.Execute( + record, residenceToken, inputs, out receipt); + switch (executeStatus) + { + case RuntimeInitialCreateExecutionStatus.Completed: + _progress.Remove(key); + return RuntimeLocalPlayerFirstEntryStatus.Completed; + case RuntimeInitialCreateExecutionStatus.PendingPlacement: + return RuntimeLocalPlayerFirstEntryStatus + .AwaitingReceiptAcknowledgement; + case RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement: + return RuntimeLocalPlayerFirstEntryStatus + .AwaitingContinuationPlacement; + case RuntimeInitialCreateExecutionStatus.RejectedToken: + _progress.Remove(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedToken; + default: + _progress.Remove(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + } + + /// + /// Discards any in-flight publication candidate/activation this class + /// owns for and drops its own progress entry. + /// Safe to call at every stage: + /// and + /// are both no-ops against a default/invalid token (an unreached stage's + /// token field holds exactly that) AND against a token whose activation + /// has already reached FinalizeActivation's terminal success path + /// — FinalizeActivation itself nulls the publication's own + /// tracked activation the instant the commit succeeds, because the + /// controller is now genuinely live/published, not a discardable + /// in-progress candidate. Calling this once + /// (or later) has been reached is therefore correctly a no-op on the + /// controller/body — an abandoned Place acknowledgement never + /// retroactively un-publishes an already-live entity; that is ordinary + /// entity teardown's job (the SAME path any other live entity's delete + /// already runs), not this class's. The residence and executor own + /// their own convergence independently (their existing retirement/reset + /// paths are untouched by this class). + /// + private void Discard(RuntimeEntityKey key) + { + if (!_progress.TryGetValue(key, out Progress? progress)) + return; + _progress.Remove(key); + // H2: post-H2, a Progress entry can only exist at all if Advance's + // own upfront check already found Publication bound — this should + // therefore be structurally unreachable. Guarded anyway + // (belt-and-suspenders) so a future caller shape can never turn an + // already-surfaced Advance failure into a SECOND throw from inside + // an unrelated retirement/session-clear teardown fan-out. + if (_publication is null) + return; + Publication.Discard(progress.PublicationToken); + Publication.DiscardActivation(progress.ActivationToken); + } + + /// + /// Cleanup for one key this class is tracking. + /// binds this into 's + /// multicast retirement notification (alongside the executor's own + /// DiscardProgress), so any residence retirement path — delete, + /// reset, generation replacement, a host discovering staleness — reaps + /// this class's progress automatically. The notification always carries + /// the exact the residence itself tracked + /// internally, so it converges correctly even after + /// has gone null (e.g. post-delete + /// teardown released the local id) — unlike re-deriving a key from the + /// record, which cannot do once that happens. + /// Still exposed directly for a caller that captured a key before a + /// teardown this class was not notified about (e.g. constructed + /// standalone in a test without the lifetime's fan-out). + /// + internal void Forget(RuntimeEntityKey key) => Discard(key); + + /// + /// Bulk cleanup mirroring + /// — wired into the same session-clear sequence + /// (). Discards + /// every tracked key's in-flight publication candidate/activation before + /// dropping the whole progress table, exactly like a per-key + /// for each entry. + /// + internal void DiscardAll() + { + // H2: same belt-and-suspenders tolerance as Discard above — a + // structurally unreachable case post-H2, guarded so bulk session + // clear can never throw from an unbound Publication either. + if (_publication is not null) + { + foreach (Progress progress in _progress.Values) + { + Publication.Discard(progress.PublicationToken); + Publication.DiscardActivation(progress.ActivationToken); + } + } + _progress.Clear(); + } + + internal RuntimeLocalPlayerFirstEntryOwnershipSnapshot CaptureOwnership() => + new(_progress.Count); +} diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 7a285b24..263c8f5b 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -1576,11 +1576,69 @@ internal sealed class RuntimeSetPositionState : IDisposable uint scatterAttempts = 0u, float shadowWorldOffsetX = 0f, float shadowWorldOffsetY = 0f) + { + outcome = default; + + RuntimeSetPositionMoverPreparationStatus status = + TryPrepareAuthoredMover( + record, + token, + operationKind, + flags, + collisionSource, + gameTime, + out RuntimeSetPositionCommand command, + placementClass, + portal, + line, + scatterRadiusX, + scatterRadiusY, + scatterAttempts, + shadowWorldOffsetX, + shadowWorldOffsetY); + if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) + return status; + + outcome = SubmitPreparedPlacement(token, command); + return RuntimeSetPositionMoverPreparationStatus.Prepared; + } + + /// + /// C3a: the exact-Setup-read + half of + /// , extracted (pure + /// refactor, byte-identical behavior for the existing caller above) so + /// the dormant local-player first-entry conductor + /// (AcDream.Runtime.Gameplay.RuntimeLocalPlayerFirstEntryState) + /// can obtain a prepared WITHOUT + /// the trailing call. Submission + /// through the ordinary tail is exactly what an operation with + /// DormantLocalActivation set must never receive — see + /// 's "must never bypass that path through the + /// ordinary remote CommitCanonical tail" comment; the dormant chain's own + /// Evaluate/Commit/FinalizeActivation methods are the sole substitute for + /// submission on that path. + /// + internal RuntimeSetPositionMoverPreparationStatus TryPrepareAuthoredMover( + RuntimeEntityRecord record, + in RuntimeEntityPlacementToken token, + RuntimeSetPositionOperationKind operationKind, + PhysicsSetPositionFlags flags, + IPreparedCollisionSource collisionSource, + double gameTime, + out RuntimeSetPositionCommand command, + PhysicsPlacementClass placementClass = PhysicsPlacementClass.Ordinary, + RuntimePortalPlacementAuthority portal = default, + Vector3 line = default, + float scatterRadiusX = 0f, + float scatterRadiusY = 0f, + uint scatterAttempts = 0u, + float shadowWorldOffsetX = 0f, + float shadowWorldOffsetY = 0f) { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(collisionSource); - outcome = default; + command = default; uint setupTableId = CanonicalSetupTableId(record); RuntimeSetPositionMoverSetup setup; @@ -1615,13 +1673,7 @@ internal sealed class RuntimeSetPositionState : IDisposable shadowWorldOffsetX, shadowWorldOffsetY, portal); - RuntimeSetPositionMoverPreparationStatus status = PrepareMover( - token, preparation, out RuntimeSetPositionCommand command); - if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) - return status; - - outcome = SubmitPreparedPlacement(token, command); - return RuntimeSetPositionMoverPreparationStatus.Prepared; + return PrepareMover(token, preparation, out command); } internal bool IsExactPreparedPlacementCurrent( diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs index 57ed44e8..e5344af5 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs @@ -2623,6 +2623,53 @@ public sealed class RuntimeInitialCreateResidenceStateTests lifetime.Entities.SetPhysicsBody(canonical, body); } + [Fact] + public void RetirementNotificationBoundReentrantlyDuringDispatchDoesNotCorruptTheCurrentIteration() + { + // H1: RuntimeEntityObjectLifetime's own constructor already binds + // TWO retirement notifications (the executor's DiscardProgress and + // the local-player first-entry conductor's Forget). Before this + // fix, NotifyRetirement iterated the live _retirementNotifications + // list directly; a THIRD subscriber binding from inside one of + // those two (or this test's own) callbacks would throw + // "Collection was modified" on the very next notify() call in the + // SAME foreach. Snapshotting via ToArray() before iterating must + // let a reentrant bind land safely mid-dispatch, with the newly + // added subscriber observed on the NEXT retirement, not the current + // one (it is not required to see notifications that were already + // "in flight" when it bound). + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 20UL); + var lateBoundKeys = new List(); + bool reentrantBindAttempted = false; + lifetime.InitialCreateResidences.BindRetirementNotification(key => + { + if (reentrantBindAttempted) + return; + reentrantBindAttempted = true; + lifetime.InitialCreateResidences.BindRetirementNotification( + lateBoundKeys.Add); + }); + + RuntimeEntityRecord first = lifetime.RegisterEntityWithInitialResidence( + Spawn(0x70003F80u, 1), isLocalPlayer: false).Canonical!; + RuntimeEntityKey firstKey = first.Key!.Value; + Exception? thrown = Record.Exception(() => + lifetime.InitialCreateResidences.Forget(first, out _, out _)); + + Assert.Null(thrown); + Assert.True(reentrantBindAttempted); + Assert.DoesNotContain(firstKey, lateBoundKeys); + + RuntimeEntityRecord second = lifetime.RegisterEntityWithInitialResidence( + Spawn(0x70003F81u, 1), isLocalPlayer: false).Canonical!; + RuntimeEntityKey secondKey = second.Key!.Value; + Assert.True(lifetime.InitialCreateResidences.Forget( + second, out _, out _)); + + Assert.Contains(secondKey, lateBoundKeys); + } + private static void Bind( RuntimeEntityObjectLifetime lifetime, ulong generation) diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs new file mode 100644 index 00000000..07ae7bd7 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs @@ -0,0 +1,922 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Content; +using AcDream.Content.Pak; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; +using AcDream.Runtime; + +namespace AcDream.Runtime.Tests.Gameplay; + +public sealed class RuntimeLocalPlayerFirstEntryStateTests +{ + private const uint Landblock = 0xA9B60000u; + private const uint Cell = Landblock | 0x0001u; + private const uint SetupId = 0x02000001u; + private static readonly RuntimeInitialCreateExecutionInputs NoContact = + new(UsePositionFromServer: false, PlayerDistance: 0f); + + // --------------------------------------------------------------- + // Happy path + // --------------------------------------------------------------- + + [Fact] + public void FullSequenceHappyPathReachesExecutorCompletedWithOneBodyIdentity() + { + using var fixture = new Fixture(residentWorld: true); + + RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, status); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(RuntimeTeleportHookPhase.AfterEnterWorld, + receipt.TeleportHookPhase); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + PlayerMovementController controller = + Assert.IsType(fixture.Movement.Controller); + Assert.Same(body, controller.PhysicsBody); + Assert.True(controller.IsRuntimePublished); + Assert.NotNull(fixture.Record.PhysicsHost); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().CandidateCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.False(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out _)); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .InitialCreateExecutorProgressCount); + + // Retrying with the now-stale residence token is a distinct, + // safe no-op — nothing left to resume. + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + } + + // --------------------------------------------------------------- + // Yield flavors + resume + // --------------------------------------------------------------- + + [Fact] + public void AwaitingCollisionSourceRetriesThenResumesOnceSetupLands() + { + using var fixture = new Fixture(residentWorld: true, setupTableId: SetupId); + fixture.CollisionSource.Status = PreparedAssetReadStatus.Missing; + + RuntimeLocalPlayerFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingCollisionSource, + first); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().CandidateCount); + + RuntimeLocalPlayerFirstEntryStatus second = fixture.Advance(out _); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingCollisionSource, + second); + Assert.True(fixture.CollisionSource.ReadCount >= 2); + + fixture.CollisionSource.Status = PreparedAssetReadStatus.Loaded; + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(Cell, receipt.FullCellId); + } + + [Fact] + public void AwaitingActivationRetriesWhileCellUnresolvedThenResumesAfterGenerationWake() + { + // Non-resident world: EvaluateActivation defers because the + // destination landblock's collision generation was never committed. + using var fixture = new Fixture(residentWorld: false); + + RuntimeLocalPlayerFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, first); + Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.False(fixture.Record.PhysicsBody!.InWorld); + + RuntimeLocalPlayerFirstEntryStatus second = fixture.Advance(out _); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, second); + Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); + + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, generation, ready: true); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + } + + [Fact] + public void AwaitingReceiptAcknowledgementRetriesWhileNotFifoHeadThenResumes() + { + using var fixture = new Fixture(residentWorld: true); + // Occupy the FIFO head with an unrelated, unacknowledged Place + // projection before our own activation ever commits, so our own + // token — assigned a LATER sequence — cannot acknowledge first. + (RuntimeEntityRecord other, RuntimePlacementProjectionToken otherToken) = + BeginPendingOrdinaryPlacement(fixture, 0x70099001u); + + RuntimeLocalPlayerFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingReceiptAcknowledgement, + first); + // The activation itself already committed — retrying must not + // re-run CommitActivation (which would reject a second time and + // corrupt the ledger); only the acknowledgement itself is retried. + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + + RuntimeLocalPlayerFirstEntryStatus second = fixture.Advance(out _); + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingReceiptAcknowledgement, + second); + + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(otherToken)); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(Cell, receipt.FullCellId); + _ = other; + } + + [Fact] + public void DeleteWhileAwaitingReceiptAcknowledgementAbandonsInsteadOfRetryingForeverAndConverges() + { + // F1 regression: the gap between the two original delete tests (one + // before activation ever commits, one after a non-resident + // DeferredCell). Here the activation has ALREADY committed — the + // projection is captured and real — but acknowledgement is blocked + // behind another entity's own unacknowledged Place. A full delete + // ALSO retires the residence (TryAcceptDelete's own + // ForgetInitialCreateResidence call), which — now that F2 wires this + // class's Forget into that same retirement notification — converges + // everything automatically before Advance is ever called again; + // that path is covered by the delete-while-AwaitingActivation and + // reincarnation tests instead. This test exercises the narrower + // mechanism directly: Physics.SetPosition.Forget alone (the exact + // call TryAcceptDelete itself makes, releasePreparedMover: true) + // rewrites the still-pending Place slot straight to Discard with a + // bumped revision (CancelCoreDeferred) — invalidating BOTH the + // residence's own placement-tracked currency AND the FIFO head's + // projection kind/token for this entity. Without + // IsAcknowledgementStillPending's re-check on a failed acknowledge, + // the cached progress.Projection struct could never match the FIFO + // head again and retrying Advance would report + // AwaitingReceiptAcknowledgement forever, with Progress/Publication + // ownership never converging. + using var fixture = new Fixture(residentWorld: true); + (RuntimeEntityRecord other, RuntimePlacementProjectionToken otherToken) = + BeginPendingOrdinaryPlacement(fixture, 0x70099002u); + + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingReceiptAcknowledgement, + fixture.Advance(out _)); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out _)); + + RuntimePlacementCancellationReceipt cancellation = fixture.Lifetime + .Physics.SetPosition.Forget( + fixture.Record, + releasePreparedMover: true); + fixture.Lifetime.Physics.SetPosition.PublishCancellation(cancellation); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + // FinalizeActivation already nulled Publication's own tracked + // activation the instant CommitActivation succeeded (the controller + // is genuinely live/published from that point on, not an in-progress + // candidate) — so PendingActivationCount was already 0 before this + // abandonment ran, and stays 0. Abandoning the acknowledgement here + // does not retroactively un-publish the now-live controller; that is + // ordinary entity teardown's job, not this class's. + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.NotNull(fixture.Movement.Controller); + Assert.True(fixture.Movement.Controller!.IsRuntimePublished); + + // The unrelated entity's own placement is untouched and still + // acknowledgeable — this class's abandonment must not have reached + // past its own entity's projection. + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(otherToken)); + _ = other; + } + + [Fact] + public void AwaitingContinuationPlacementPropagatesExecutorYieldThenResumes() + { + using var fixture = new Fixture(residentWorld: true); + // A fresh Position arrives while the initial residence is still + // pending — exactly the scenario the executor's own FIFO exists + // for. It is enqueued as a continuation and only classified once + // the executor actually drains it. + WorldSession.EntityPositionUpdate update = new( + fixture.Record.ServerGuid, + new CreateObject.ServerPosition(Cell, 40f, 20f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: 2, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 1, + ForcePositionSequence: 0); + Assert.True(fixture.Lifetime.TryApplyPosition( + update, + isLocalPlayer: true, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: true, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance(out _); + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingContinuationPlacement, + status); + // The conductor's OWN sequence (residence -> publication -> Execute) + // already reached its terminal Acknowledged stage; its progress + // entry is retained (not zero) purely so a retry skips straight to + // re-calling Execute rather than restarting mover-prep/publication + // from scratch — discarding it here would incorrectly reject a + // retry with RejectedAuthority once the original residence lease is + // long gone. + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + + RuntimeEntityKey key = fixture.Record.Key!.Value; + Assert.True(fixture.Lifetime.InitialCreateExecution + .TryGetPendingContinuationPlacement( + key, out RuntimeEntityPlacementToken placement)); + Assert.True(fixture.Lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute( + key, out RuntimeAuthoritativePositionRoute route)); + CompleteOrdinaryPlacement(fixture, placement, route); + + RuntimeLocalPlayerFirstEntryStatus resumed = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, resumed); + Assert.Contains(receipt.Trace, + a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + [Fact] + public void ReentrantAdvanceDuringCollisionCallbackFailsClosedWithContentionAndOuterCallStillCompletes() + { + using var fixture = new Fixture(residentWorld: true); + bool reentered = false; + RuntimeLocalPlayerFirstEntryStatus? innerStatus = null; + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => + { + if (!reentered && phase is TransitionCellCollisionPhase.Environment) + { + reentered = true; + innerStatus = fixture.Advance(out _); + } + return observed; + }; + + RuntimeLocalPlayerFirstEntryStatus outerStatus = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.True(reentered); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Contention, innerStatus); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, outerStatus); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // Publication binding (H2) + // --------------------------------------------------------------- + + [Fact] + public void AdvanceWithUnboundPublicationThrowsTransactionallyBeforeAnyStateMutation() + { + // H2: RuntimeEntityObjectLifetime constructs LocalPlayerFirstEntry + // before RuntimeLocalPlayerPhysicsPublicationState can exist, so an + // Advance in the window before a host calls BindPublication must + // fail with NOTHING mutated — no authored-mover Setup-read/ + // PrepareMover call against RuntimeSetPositionState's own + // _preparedMovers, no Progress entry created. Otherwise a poisoned + // Progress entry would sit in _progress forever, and a later + // Discard/DiscardAll from an unrelated retirement/session-clear + // fan-out would ALSO throw. + using var lifetime = new RuntimeEntityObjectLifetime(); + var generation = new RuntimeGenerationToken(1UL); + lifetime.BindEventContext(() => generation, static () => 1UL); + RuntimeEntityRecord record = lifetime.RegisterEntityWithInitialResidence( + Spawn(0x70090099u, incarnation: 1), + isLocalPlayer: true).Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + record, out RuntimeInitialCreateResidenceLease lease)); + var collisionSource = new FakeCollisionSource( + 0u, + new FlatSetupCollision( + ImmutableArray.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + Assert.Throws(() => + lifetime.LocalPlayerFirstEntry.Advance( + record, + lease.Token, + PlayerMovementConstructionOptions.Fallback, + new RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless), + collisionSource, + gameTime: 10d, + NoContact, + out _)); + + Assert.Equal(0, lifetime.LocalPlayerFirstEntry.CaptureOwnership().ActiveCount); + // The residence lease is untouched — the SAME token still drives to + // completion once a host binds Publication, proving nothing was + // mutated by the failed attempt. + Assert.True(lifetime.TryGetInitialCreateResidence( + record, out RuntimeInitialCreateResidenceLease stillLease)); + Assert.Equal(lease.Token, stillLease.Token); + + var movement = new RuntimeLocalPlayerMovementState(); + var identity = new RuntimeLocalPlayerIdentityState(); + try + { + identity.ServerGuid = record.ServerGuid; + var publication = new RuntimeLocalPlayerPhysicsPublicationState( + lifetime.Entities, lifetime.Physics, movement, identity); + movement.AttachPhysicsPublication(publication); + lifetime.LocalPlayerFirstEntry.BindPublication(publication); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + lifetime.LocalPlayerFirstEntry.Advance( + record, + lease.Token, + PlayerMovementConstructionOptions.Fallback, + new RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless), + collisionSource, + gameTime: 10d, + NoContact, + out _)); + } + finally + { + // F2 disposal-ordering constraint: Lifetime's own Dispose runs + // BeginSessionClear -> LocalPlayerFirstEntry.DiscardAll, which + // needs Publication still alive. + lifetime.Dispose(); + movement.Dispose(); + identity.Dispose(); + } + } + + [Fact] + public void BindPublicationTwiceThrows() + { + using var fixture = new Fixture(residentWorld: false); + Assert.Throws( + () => fixture.Conductor.BindPublication(fixture.Publication)); + } + + // --------------------------------------------------------------- + // Retry idempotency + // --------------------------------------------------------------- + + [Fact] + public void RetryAtMoverPreparationNeverCreatesAPublicationCandidate() + { + using var fixture = new Fixture(residentWorld: true, setupTableId: SetupId); + fixture.CollisionSource.Status = PreparedAssetReadStatus.Missing; + + for (int i = 0; i < 3; i++) + { + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingCollisionSource, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Publication.CaptureOwnership().CandidateCount); + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + } + } + + [Fact] + public void RetryAtAwaitingActivationNeverRecommitsPublicationOrDuplicatesTheBody() + { + using var fixture = new Fixture(residentWorld: false); + PhysicsBody? body = null; + + for (int i = 0; i < 3; i++) + { + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Publication.CaptureOwnership().CandidateCount); + Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); + body ??= fixture.Record.PhysicsBody; + Assert.Same(body, fixture.Record.PhysicsBody); + Assert.Same(fixture.Movement.Controller, fixture.Movement.Controller); + } + } + + // --------------------------------------------------------------- + // Abandonment + converged ledgers + // --------------------------------------------------------------- + + [Fact] + public void DeleteMidFlightDuringActivationAbandonsWithoutShadowOrPlaceAndConverges() + { + // Mirrors the publication suite's own + // CollisionCallbackDeleteRetiresPrephaseWithoutShadowOrPlace: the + // delete lands DURING the same Advance call (from inside + // CommitActivation's collision dispatch), not across a retry + // boundary — the only place a genuine mid-flight abandonment (as + // opposed to a stale-lease retry) can be exercised for this fused + // Prepare->Commit->Evaluate->CommitActivation sequence. + using var fixture = new Fixture(residentWorld: true); + bool deleted = false; + var placements = new List(); + using IDisposable placementSubscription = fixture.Lifetime.Events + .SubscribePlacement(new PlacementObserver(d => placements.Add(d))); + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (deleted || phase is not TransitionCellCollisionPhase.Environment) + return observed; + deleted = true; + DeleteEntity(fixture); + return observed; + }; + + RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance(out _); + + Assert.True(deleted); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority, status); + Assert.DoesNotContain(placements, + d => d.Placement.Kind is RuntimePlacementProjectionKind.Place); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().CandidateCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.Null(fixture.Movement.Controller); + } + + [Fact] + public void DeleteWhileAwaitingActivationConvergesAutomaticallyThroughTheRetirementFanOut() + { + // RuntimeEntityRecord.Key is computed from LocalEntityId and goes + // null the instant ReleaseLocalId runs (part of delete's teardown, + // RuntimeEntityObjectLifetime.CompleteProjectionRetirement) — so a + // SUBSEQUENT Advance call can never recompute the dictionary key to + // reach its own stale progress (RejectedToken, mirroring the + // executor's own convention for "nothing addressable here" — same + // as calling Execute with a record whose Key just went null). F2: + // this no longer matters for cleanup, because TryAcceptDelete's own + // ForgetInitialCreateResidence call fires the residence's multicast + // retirement notification SYNCHRONOUSLY, DURING delete itself — + // RuntimeEntityObjectLifetime binds this class's Forget into that + // SAME fan-out (alongside the executor's DiscardProgress), using the + // exact key the notification carries, never one re-derived from + // record.Key. Convergence is therefore already complete by the time + // delete returns, with no separate host Forget call needed. + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + fixture.Advance(out _)); + Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); + + DeleteEntity(fixture); + Assert.Null(fixture.Record.Key); + + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.Null(fixture.Movement.Controller); + + // Retrying Advance afterward is a safe, distinct no-op. + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + } + + [Fact] + public void MovementResetSessionMidFlightConvergesOwnershipThroughOrdinaryAdvance() + { + // Unlike delete, ResetSession does not touch RuntimeEntityRecord.Key + // at all — it only clears RuntimeLocalPlayerPhysicsPublicationState's + // own candidate/activation directly. So a plain retry of Advance (no + // captured-key Forget needed) reaches EvaluateActivation, which + // reports RejectedToken because _activation is now null outright + // (its own "nothing here" status, not "found but stale") — and this + // class's Discard(key) still runs on that path, converging ownership + // through ordinary retry alone. + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + fixture.Advance(out _)); + + fixture.Movement.ResetSession(); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + } + + // --------------------------------------------------------------- + // GUID / LeaseId staleness + // --------------------------------------------------------------- + + [Fact] + public void DeleteAndSameGuidReincarnationAutomaticallyFreesThePublicationSlotForTheFreshIncarnation() + { + // Delete releases the old LocalEntityId (ReleaseLocalId), so a + // reincarnation's RuntimeEntityKey structurally differs from the + // deleted one — the orphaned incarnation-1 progress entry can never + // collide with the fresh incarnation-2 dictionary slot. Separately, + // RuntimeLocalPlayerPhysicsPublicationState holds exactly ONE + // dormant candidate/activation globally (there is only ever one + // local player), so the fresh incarnation's own Prepare would be + // structurally rejected (CanPrepare requires the slot empty) for as + // long as the orphaned incarnation-1 activation still occupied it. + // F2: TryAcceptDelete's own ForgetInitialCreateResidence call fires + // the residence's multicast retirement notification synchronously, + // DURING delete — RuntimeEntityObjectLifetime binds this class's + // Forget into that SAME fan-out, so the stale activation is already + // discarded by the time delete returns. No separate host Forget is + // needed for the fresh incarnation to proceed immediately. + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + fixture.Advance(out _)); + RuntimeEntityKey staleKey = fixture.Record.Key!.Value; + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); + + uint guid = fixture.Record.ServerGuid; + DeleteEntity(fixture); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + RuntimeEntityRecord reincarnated = fixture.Lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, incarnation: 2), + isLocalPlayer: true) + .Canonical!; + // ReleaseLocalId (part of delete's teardown) returns the old + // LocalEntityId to the free pool rather than pinning it per GUID, so + // the reincarnation's key differs in BOTH fields, not just + // Incarnation — either way it is a different _progress dictionary + // key than the deleted incarnation's. + Assert.NotEqual(staleKey, reincarnated.Key!.Value); + fixture.Identity.ServerGuid = reincarnated.ServerGuid; + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + reincarnated, + out RuntimeInitialCreateResidenceLease freshLease)); + fixture.Record = reincarnated; + fixture.Lease = freshLease; + + // The fresh incarnation's own publication Prepare succeeds + // immediately — the one global slot was already freed by delete's + // automatic convergence above, with no explicit Forget call in + // between. The fixture's non-resident world defers the fresh + // incarnation's own activation once — wake it the same way the + // AwaitingActivation retry test does. + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + fixture.Advance(out _)); + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, generation, ready: true); + + RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, status); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Same(reincarnated, fixture.Record); + Assert.Equal(reincarnated.Key!.Value.LocalEntityId, + fixture.Movement.Controller!.LocalEntityId); + Assert.Same(reincarnated.PhysicsBody, fixture.Movement.Controller.PhysicsBody); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private static (RuntimeEntityRecord Record, RuntimePlacementProjectionToken Token) + BeginPendingOrdinaryPlacement(Fixture fixture, uint guid) + { + RuntimeEntityRecord record = fixture.Lifetime.RegisterEntity( + Spawn(guid, incarnation: 1, includePosition: true)).Canonical!; + var body = new PhysicsBody + { + Position = new Vector3(50f, 50f, 3f), + Orientation = Quaternion.Identity, + State = record.FinalPhysicsState, + }; + body.SnapToCell(Cell, body.Position, body.Position); + fixture.Lifetime.Entities.SetPhysicsBody(record, body); + RuntimeEntityPlacementToken placement = fixture.Lifetime.Physics + .SetPosition.BeginAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(placement.IsValid); + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement | PhysicsSetPositionFlags.Slide); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + fixture.Lifetime.Physics.SetPosition.PrepareMover( + placement, preparation, out RuntimeSetPositionCommand command)); + RuntimeSetPositionOutcome outcome = fixture.Lifetime.Physics.SetPosition + .SubmitPreparedPlacement(placement, command); + Assert.Equal(RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + return (record, outcome.Projection); + } + + private static void CompleteOrdinaryPlacement( + Fixture fixture, + in RuntimeEntityPlacementToken placement, + in RuntimeAuthoritativePositionRoute route) + { + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + route.OperationKind, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + route.SetPositionFlags); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + fixture.Lifetime.Physics.SetPosition.PrepareMover( + placement, preparation, out RuntimeSetPositionCommand command)); + RuntimeSetPositionOutcome outcome = fixture.Lifetime.Physics.SetPosition + .SubmitPreparedPlacement(placement, command); + Assert.Equal(RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(outcome.Projection)); + } + + private static void DeleteEntity(Fixture fixture) + { + Assert.True(fixture.Lifetime.TryAcceptDelete( + new DeleteObject.Parsed(fixture.Record.ServerGuid, fixture.Record.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + fixture.Lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(fixture.Lifetime.RetireCanonicalOnly(fixture.Record)); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation, + bool includePosition = true, + uint setupTableId = 0u) + { + CreateObject.ServerPosition? position = includePosition + ? new CreateObject.ServerPosition(Cell, 1f, 2f, 3f, 1f, 0f, 0f, 0f) + : null; + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + RawState: (uint)(PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions), + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: setupTableId == 0u ? null : setupTableId, + 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); + return new WorldSession.EntitySpawn( + Guid: guid, + Position: position, + SetupTableId: setupTableId == 0u ? null : setupTableId, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "first-entry-fixture", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: physics.RawState, + ObjectDescriptionFlags: 0x8u, + Friction: null, + Elasticity: null, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class PlacementObserver(Action onPlacement) + : IRuntimePlacementObserver + { + public void OnPlacement(in RuntimePlacementDelta delta) => onPlacement(delta); + } + + private sealed class FakeCollisionSource( + uint expectedSetupTableId, + FlatSetupCollision setup) : IPreparedCollisionSource + { + internal int ReadCount { get; private set; } + internal PreparedAssetReadStatus Status { get; set; } = + PreparedAssetReadStatus.Loaded; + + public PreparedAssetPresence ProbeCollision( + PakAssetType type, uint sourceFileId) => + PreparedAssetPresence.Available; + + public PreparedCollisionReadResult ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + ReadCount++; + Assert.Equal(expectedSetupTableId, sourceFileId); + return Status switch + { + PreparedAssetReadStatus.Loaded => + PreparedCollisionReadResult.Loaded(setup), + PreparedAssetReadStatus.Corrupt => + PreparedCollisionReadResult.Corrupt, + _ => PreparedCollisionReadResult.Missing, + }; + } + + public PreparedCollisionReadResult + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() + { + } + } + + private sealed class Fixture : IDisposable + { + internal Fixture(bool residentWorld, uint setupTableId = 0u) + { + if (residentWorld) + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + Lifetime = new RuntimeEntityObjectLifetime(engine); + } + else + { + Lifetime = new RuntimeEntityObjectLifetime(); + } + var generation = new RuntimeGenerationToken(1UL); + Lifetime.BindEventContext(() => generation, static () => 1UL); + + Movement = new RuntimeLocalPlayerMovementState(); + Identity = new RuntimeLocalPlayerIdentityState(); + Publication = new RuntimeLocalPlayerPhysicsPublicationState( + Lifetime.Entities, Lifetime.Physics, Movement, Identity); + Movement.AttachPhysicsPublication(Publication); + // F2: use the SAME conductor instance RuntimeEntityObjectLifetime + // itself constructs and wires into the residence's multicast + // retirement fan-out and BeginSessionClear — not a separate, + // standalone instance — so these tests exercise the real + // production wiring (automatic convergence on delete/reset/ + // session-clear), not a parallel copy of it. + Conductor = Lifetime.LocalPlayerFirstEntry; + Conductor.BindPublication(Publication); + + Record = Lifetime.RegisterEntityWithInitialResidence( + Spawn(0x70090001u, incarnation: 1, setupTableId: setupTableId), + isLocalPlayer: true).Canonical!; + Identity.ServerGuid = Record.ServerGuid; + Assert.True(Lifetime.TryGetInitialCreateResidence( + Record, out RuntimeInitialCreateResidenceLease lease)); + Lease = lease; + + CollisionSource = new FakeCollisionSource( + setupTableId, + new FlatSetupCollision( + ImmutableArray.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + } + + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal RuntimeLocalPlayerMovementState Movement { get; } + internal RuntimeLocalPlayerIdentityState Identity { get; } + internal RuntimeLocalPlayerPhysicsPublicationState Publication { get; } + internal RuntimeLocalPlayerFirstEntryState Conductor { get; } + internal RuntimeEntityRecord Record { get; set; } + internal RuntimeInitialCreateResidenceLease Lease { get; set; } + internal FakeCollisionSource CollisionSource { get; } + + internal RuntimeLocalPlayerFirstEntryStatus Advance( + out RuntimeInitialCreateExecutionReceipt receipt) => + Conductor.Advance( + Record, + Lease.Token, + PlayerMovementConstructionOptions.Fallback, + new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless), + CollisionSource, + gameTime: 10d, + NoContact, + out receipt); + + public void Dispose() + { + // F2: RuntimeEntityObjectLifetime's own Dispose runs + // BeginSessionClear, which now reaches + // LocalPlayerFirstEntry.DiscardAll() -> Publication.Discard for + // any still-tracked entity — Publication must still be alive for + // that. Lifetime must therefore be disposed BEFORE Movement + // (whose Dispose tears down Publication), never after. + Lifetime.Dispose(); + Movement.Dispose(); + Identity.Dispose(); + } + } +} From d62b99509e40eca1d6556b430ab2622caa029dd1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 09:43:32 +0200 Subject: [PATCH 59/73] docs(physics): record cutover slice C3a completion C3a landed at 960373df with dual review PASS. The plan records the conductor's five-stage sequence (verified step-for-step against retail's entry order, with the mover-shapes-first correction the tested preconditions forced), the convergence/wiring closures, and the two carried findings C3c must honor. Next: C3b remote body construction at Create, whose float-gate oracle is committed at 874d94bf. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-02-placement-cutover.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 0917bfd5..6e17aefb 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -136,9 +136,21 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. not invention). Sub-slices, each with the standing contract/dual-review/gate discipline: - - **C3a — Runtime first-entry sequencing (dormant):** the resumable - local-player entry transaction binding residence → publication - body-attach → mover submit → receipt → Execute, test-driven. + - **C3a — Runtime first-entry sequencing — COMPLETE at `960373df` + (2026-08-02, dual reviews PASS).** `RuntimeLocalPlayerFirstEntryState`: + five stages (mover-prep → publication Prepare/Commit → activation → + acknowledgement → Execute) in retail's own order — mover shapes + BEFORE placement, matching makeObject/set_description preceding + enter_world; the original contract prose had it backwards and the + tested preconditions forced the faithful order. Acknowledge-stage + authority discrimination, automatic convergence through the (now + multicast, snapshot-iterated) retirement fan-out, ownership-ledger + fold, transactional late-bind Publication seam. Dormant: C3c's first + act is the GameRuntime binding + production Advance drive. + **Carried findings for C3c:** the controller is live from the + activation commit onward (abandonment leaves it to ordinary entity + teardown — retail has no entry-flow rollback); EvaluateActivation's + post-commit DeferredCell overload is encapsulated behind Advance. - **C3b — remote body construction at Create (dormant):** retail-anchored body construction from the wire PhysicsSpawnData per `set_description` order for residence-route remote/creature Creates. From 0934a1211105cdbf7a28fb739eeacf203ca06312 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 10:20:48 +0200 Subject: [PATCH 60/73] feat(runtime): construct remote bodies at Create per retail set_description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cutover slice C3b: residence-route remote/creature/projectile Creates now get their canonical PhysicsBody at Create time — retail's order, closing the C3 flip's Finding C (production builds bodies at first motion; retail builds them in ACCObjectMaint::CreateObject). RuntimeRemoteBodyDescription walks set_description 0x00514F40 exactly: the motion-table gate (zero id PASSES — verified at 0051871f/005127ca), the frame-vs-movement branch keyed on retail's movement_buffer != 0 (an empty-buffer movement payload takes the PLACEMENT branch and writes no autonomy — the wire-shape defect the retail review caught), set_state, the byte-certain friction gate (inclusive [0,1]; NaN deliberately skipped per the gates doc's sanctioned deviation), the set_elasticity clamp with retail's unordered-to-zero NaN routing (ACE diverges to 0.1 on that edge), the translucency gate (!= 0.0f, original always recorded), velocity via setter, omega raw, and ctor-defaults for absent wire fields (0.95f/0.05f/0 — the fresh-desc-per- message flow verified at both UnPack call sites). InWorld stays false until submission, the enter_world analog. RuntimeRemoteFirstEntryState sequences mover-prep -> body construction -> placement -> acknowledgement -> Execute with every C3a hardening inherited: exactly-once stages, the shared acknowledge-stage discriminator (extracted to RuntimeFirstEntryAcknowledgement, one body for both conductors), typed Contention against in-flight remote-motion binds, never-clobber body binding through the canonical writer (foreign body fails closed — provably safe coexistence with today's build-at-first-motion path in both directions), automatic convergence through the retirement fan-out, and the construction receipt riding the terminal Advance. Dormant: no production caller; C3c wires both hosts. Reviewed: retail-conformance PASS (the construction order, both gate boundary/NaN semantics, the motion-table and autonomy verdicts all re-derived from the pseudo-C) + architecture/adversarial PASS after one fix round. Runtime 982/982; complete Release solution 10,777 passed / 4 intentional skips. Co-Authored-By: Claude Fable 5 --- .../Entities/RuntimeEntityObjectLifetime.cs | 53 +- .../RuntimeFirstEntryAcknowledgement.cs | 66 ++ .../Entities/RuntimeRemoteBodyDescription.cs | 321 ++++++ .../Entities/RuntimeRemoteFirstEntryState.cs | 649 ++++++++++++ .../RuntimeLocalPlayerFirstEntryState.cs | 55 +- .../RuntimeRemoteFirstEntryStateTests.cs | 980 ++++++++++++++++++ 6 files changed, 2083 insertions(+), 41 deletions(-) create mode 100644 src/AcDream.Runtime/Entities/RuntimeFirstEntryAcknowledgement.cs create mode 100644 src/AcDream.Runtime/Entities/RuntimeRemoteBodyDescription.cs create mode 100644 src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs create mode 100644 tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index f9c96d00..f9c3f5c9 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -64,7 +64,13 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( /// Advance), but fully constructed/wired like every other owner /// here, so its own ownership must converge to zero the same way. /// - int LocalPlayerFirstEntryActiveCount = 0) + int LocalPlayerFirstEntryActiveCount = 0, + /// + /// C3b: outstanding tracked + /// keys - the remote/projectile Create-time body-construction conductor. + /// Dormant like its C3a sibling; converges to zero the same way. + /// + int RemoteFirstEntryActiveCount = 0) { public bool IsConverged => IsDisposed @@ -87,6 +93,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && InitialCreateExecutorProgressCount == 0 && PendingCompletionReceiptCount == 0 && LocalPlayerFirstEntryActiveCount == 0 + && RemoteFirstEntryActiveCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -176,6 +183,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences, InitialCreateExecution, Physics); + // C3b: the remote/projectile analog of the conductor above — retail + // body construction at Create time in place of the publication + // chain. Dormant like its sibling (no production caller of Advance); + // constructed and wired identically so its ownership converges the + // same way. + RemoteFirstEntry = new RuntimeRemoteFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -190,6 +206,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // updated doc comment for why this is now multicast. InitialCreateResidences.BindRetirementNotification( key => LocalPlayerFirstEntry.Forget(key)); + // C3b: the remote conductor joins the SAME multicast retirement + // fan-out, third in registration order. + InitialCreateResidences.BindRetirementNotification( + key => RemoteFirstEntry.Forget(key)); // F2: reaps the executor's completion-receipt correlation entry // exactly when a host acknowledges the ExecutorCompleted receipt it // correlates - mirrors the residence-retirement binding immediately @@ -245,6 +265,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences, InitialCreateExecution, Physics); + // C3b: the remote/projectile analog of the conductor above — retail + // body construction at Create time in place of the publication + // chain. Dormant like its sibling (no production caller of Advance); + // constructed and wired identically so its ownership converges the + // same way. + RemoteFirstEntry = new RuntimeRemoteFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -259,6 +288,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // updated doc comment for why this is now multicast. InitialCreateResidences.BindRetirementNotification( key => LocalPlayerFirstEntry.Forget(key)); + // C3b: the remote conductor joins the SAME multicast retirement + // fan-out, third in registration order. + InitialCreateResidences.BindRetirementNotification( + key => RemoteFirstEntry.Forget(key)); // F2: reaps the executor's completion-receipt correlation entry // exactly when a host acknowledges the ExecutorCompleted receipt it // correlates - mirrors the residence-retirement binding immediately @@ -314,6 +347,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences, InitialCreateExecution, Physics); + // C3b: the remote/projectile analog of the conductor above — retail + // body construction at Create time in place of the publication + // chain. Dormant like its sibling (no production caller of Advance); + // constructed and wired identically so its ownership converges the + // same way. + RemoteFirstEntry = new RuntimeRemoteFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -328,6 +370,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // updated doc comment for why this is now multicast. InitialCreateResidences.BindRetirementNotification( key => LocalPlayerFirstEntry.Forget(key)); + // C3b: the remote conductor joins the SAME multicast retirement + // fan-out, third in registration order. + InitialCreateResidences.BindRetirementNotification( + key => RemoteFirstEntry.Forget(key)); // F2: reaps the executor's completion-receipt correlation entry // exactly when a host acknowledges the ExecutorCompleted receipt it // correlates - mirrors the residence-retirement binding immediately @@ -353,6 +399,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable internal RuntimeInitialCreateContinuationExecutor InitialCreateExecution { get; } internal RuntimeLocalPlayerFirstEntryState LocalPlayerFirstEntry { get; } + internal RuntimeRemoteFirstEntryState RemoteFirstEntry { get; } public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership() { @@ -389,7 +436,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.ReplayFailureCount, InitialCreateExecution.LastReplayFailure is not null, InitialCreateExecution.PendingCompletionReceiptCount, - LocalPlayerFirstEntry.CaptureOwnership().ActiveCount); + LocalPlayerFirstEntry.CaptureOwnership().ActiveCount, + RemoteFirstEntry.CaptureOwnership().ActiveCount); } public void BindEventContext( @@ -1734,6 +1782,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences.Clear(); InitialCreateExecution.DiscardAll(); LocalPlayerFirstEntry.DiscardAll(); + RemoteFirstEntry.DiscardAll(); Physics.CollisionReports.LeaveWorldBatch(active); Physics.ResetSessionPhysics(); Entities.BeginSessionClear(); diff --git a/src/AcDream.Runtime/Entities/RuntimeFirstEntryAcknowledgement.cs b/src/AcDream.Runtime/Entities/RuntimeFirstEntryAcknowledgement.cs new file mode 100644 index 00000000..10b93734 --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeFirstEntryAcknowledgement.cs @@ -0,0 +1,66 @@ +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Entities; + +/// +/// The ONE post-failed-acknowledge re-validation both first-entry conductors +/// (AcDream.Runtime.Gameplay.RuntimeLocalPlayerFirstEntryState and +/// ) share. Extracted (C3b review +/// M2) so the C3a abandonment fix — a delete rewriting the pending Place +/// slot to Discard with a bumped revision would otherwise make +/// AcknowledgeProjection fail forever while the conductor retried +/// endlessly — cannot regress independently in either copy. Follows the +/// class family's pure-static-helper convention +/// (, +/// , +/// ). +/// +internal static class RuntimeFirstEntryAcknowledgement +{ + /// + /// Re-validates authority after a failed acknowledge. Two independent + /// checks, either of which failing means authority moved and the + /// conductor must abandon rather than keep retrying forever: + /// (1) the residence lease the whole sequence began under must still be + /// exactly current (a delete or reset retires it); (2) if the FIFO head + /// belongs to THIS entity at all, it must still be the exact Place + /// projection the conductor is holding — a head that belongs to us but + /// is no longer that exact token (rewritten to Discard, or to a later + /// revision) means our specific placement was superseded even if the + /// residence lookup transiently still resolves. A head belonging to a + /// DIFFERENT entity is the genuine "not yet our turn" case and must + /// stay retryable. This reads the Runtime-internal + /// directly + /// rather than through the public, generation-gated + /// — the conductors are + /// part of Runtime, not external hosts crossing that boundary, exactly + /// like their existing direct + /// calls. + /// + internal static bool IsStillPending( + RuntimeInitialCreateResidenceState residences, + RuntimeSetPositionState setPosition, + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + in RuntimePlacementProjectionToken expected) + { + if (!residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + return false; + } + + if (setPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head) + && head.Token.Entity == expected.Entity + && (head.Kind is not RuntimePlacementProjectionKind.Place + || head.Token != expected)) + { + return false; + } + + return true; + } +} diff --git a/src/AcDream.Runtime/Entities/RuntimeRemoteBodyDescription.cs b/src/AcDream.Runtime/Entities/RuntimeRemoteBodyDescription.cs new file mode 100644 index 00000000..b2335135 --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeRemoteBodyDescription.cs @@ -0,0 +1,321 @@ +using System.Numerics; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Entities; + +/// +/// Typed record of one retail-ordered body construction. Fields with no +/// slot (translucency) or no unconditional retail +/// write (the three float gates) are recorded here so the construction's +/// gated outcomes stay provable both ways without a parallel body field. +/// The receipt is Runtime-internal evidence, never a second source of truth: +/// the body's own fields remain authoritative for everything they carry. +/// +internal readonly record struct RuntimeRemoteBodyConstructionReceipt( + /// The motion-table id retail's gate evaluated (0 = none on the wire). + uint MotionTableId, + /// + /// Retail CPhysicsObj::SetMotionTableID (0x00512780, + /// pseudo-C:280528) fails ONLY when part_array == 0 (005127da) or + /// when MotionTableManager::Create fails for a NONZERO id + /// (CPartArray::SetMotionTableID 0x005186E0, pseudo-C:286732, + /// 0051872f). A ZERO id skips manager creation (0051871f falls through + /// to return 1 at 00518743) and CPhysicsObj then skips + /// MakeMovementManager for INVALID_DID (005127ca) — the gate + /// PASSES. In Runtime the part-array precondition is the already-run + /// mover-preparation stage (the Setup shape resolved before this + /// construction is reachable) and motion-table DAT installation stays + /// presentation-side, so the gate passes for zero and nonzero ids alike; + /// this field records that it was evaluated in retail's position. + /// + bool MotionTableGatePassed, + /// + /// True when the frozen PhysicsDesc carried a Movement payload — + /// retail's mutually-exclusive branch (set_description step 4, + /// 0x00514F40): movement present means NO placement frame is staged. + /// + bool MovementBranch, + /// Retail last_move_was_autonomous written on the movement branch. + bool LastMoveWasAutonomous, + /// True when the no-movement branch staged the dormant cell frame. + bool PlacementFrameStaged, + /// The friction value the body carries after the gated write. + float Friction, + /// + /// Friction gate outcome — byte-certain gates 1+2 of + /// docs/research/2026-08-02-set-description-float-gates.md: applied only + /// when 0.0f <= friction <= 1.0f. + /// + bool FrictionApplied, + /// The elasticity the body carries after retail set_elasticity's clamp. + float Elasticity, + /// + /// Retail's UNCONDITIONAL translucencyOriginal write + /// (0x00515097, gates doc conditional 3 — written before the gate, + /// always). has no translucency slot + /// (translucency is presentation-side in acdream — the AP-89 + /// TranslucencyFadeManager family), so the construction receipt is where + /// the unconditional write lands Runtime-side. + /// + float TranslucencyOriginal, + /// + /// Translucency gate outcome — byte-certain gate 3: live translucency + /// (+ PartArray propagation, presentation-side) only when + /// translucency != 0.0f. + /// + bool TranslucencyApplied, + /// True when a present, finite wire velocity was applied via set_velocity. + bool VelocityApplied, + /// True when a present, finite wire omega was written (raw field, no setter). + bool OmegaApplied); + +/// +/// Pure retail-ordered construction of one remote/projectile canonical +/// from the frozen wire , +/// per CPhysicsObj::set_description (0x00514F40, pseudo-C:283155-283276) +/// as called from ACCObjectMaint::CreateObject (0x00558870, +/// pseudo-C:356155-356245, step 6). The caller +/// () sequences this AFTER +/// mover preparation (retail: CPhysicsObj::makeObject shapes the +/// Setup/part-array before set_description runs) and binds the result through +/// the canonical +/// writer — never a parallel binding idiom. +/// +/// Field routing, per the retail order: +/// +/// Motion-table gate — evaluated (see +/// ); +/// motion-table DAT installation stays presentation-side. +/// Sound table (step 2) / physics-script table (step 3) — snapshot/ +/// presentation-side today; not body fields. +/// Placement-frame-vs-Movement (step 4, mutually exclusive) — movement +/// payload present writes +/// (retail last_move_was_autonomous = get_autonomous_movement) and +/// stages NO frame (movement unpack itself is presentation-side today); +/// no payload stages the dormant cell frame from the PREPARED MOVER +/// COMMAND's exact position (retail +/// SetPlacementFrameInternal; enter_world remains the later +/// submission — stays false). +/// set_state (step 5) — from +/// , the retail +/// state-transition view of the wire state (mirrors the existing canonical +/// writers' convention: RuntimePhysicsState.InitializeNewPhysicsBody, +/// SetRemoteMotion). +/// Scale (step 6) — NOT a field; it rides +/// the prepared mover command +/// ( reads +/// Snapshot.Physics.Scale ?? ObjScale ?? 1f, matching the +/// PhysicsDesc constructor default 1f at 0x0051D4D0). +/// Friction (step 7) — gated per the byte-certain gates doc. +/// Elasticity (step 8) — via the set_elasticity clamp port below. +/// Translucency (step 9) — original always recorded; live apply gated; +/// both receipt-side (no body slot; presentation owns render alpha). +/// set_velocity (step 10) / omega raw write (step 11) — retail passes +/// the desc values unconditionally, but the desc DEFAULTS are the zero +/// vector (PhysicsDesc ctor 0x0051D4D0 / Destroy 0x0051D5D0), so gating on +/// wire presence — the existing InitializeNewPhysicsBody +/// convention, mirrored here — produces the identical end state for an +/// absent field (the fresh body's velocity/omega are already zero). +/// default_script / default_script_intensity (step 12) — snapshot/ +/// presentation-side; not body fields. +/// All nine PhysicsTimeStamp slots (step 13, LAST) — already applied +/// at admission and frozen on the residence lease +/// (); nothing body-side. +/// +/// +internal static class RuntimeRemoteBodyDescription +{ + /// + /// PhysicsDesc constructor default friction: 0x0051D4D0 (pseudo-C:292056) + /// writes bytes "33s?" = 0x3F733333 = 0.95f. Same value as + /// . + /// + private const float DefaultDescFriction = 0.95f; + + /// PhysicsDesc constructor default elasticity (0x0051D4D0): 0.05f. + private const float DefaultDescElasticity = 0.05f; + + /// + /// Retail CPhysicsObj::set_elasticity (0x0050FD40, + /// pseudo-C:277817) upper clamp constant 0.100000001f (float 0.1). + /// Cross-check: ACE PhysicsGlobals.MaxElasticity = 0.1f + /// (ACE PhysicsObj.cs:3586-3599 reproduces the identical clamp). + /// + private const float MaxElasticity = 0.1f; + + internal static PhysicsBody Construct( + RuntimeEntityRecord record, + PhysicsSpawnData? description, + in RuntimeSetPositionCommand preparedCommand, + out RuntimeRemoteBodyConstructionReceipt receipt) + { + ArgumentNullException.ThrowIfNull(record); + var body = new PhysicsBody(); + + // 1. Motion-table gate — set_description step 1 (0x00514F5C, + // pseudo-C:283159): the ONLY group that gates the rest of the + // function. See the receipt field's doc comment for the recovered + // zero-id semantics; in Runtime the gate passes (part-array analog + // already satisfied by the sequenced mover preparation; DAT + // motion-table installation is presentation-side). + uint motionTableId = description?.MotionTableId ?? 0u; + const bool motionTableGatePassed = true; + + // 2./3. Sound table + physics-script table (steps 2-3) — snapshot/ + // presentation-side; deliberately untouched here. + + // 4. Placement-frame-vs-movement — mutually exclusive + // (set_description step 4, 0x00514F40; load-bearing ordering fact 2 + // of the retail notes). C3b retail review R1: the retail + // discriminator is the movement BUFFER pointer (`movement_buffer != + // 0`), NOT the wire flag — PhysicsDesc::UnPack (0x0051DDD0) assigns + // movement_buffer only inside its `if (buff_length != 0)` block + // (0051DE1F-0051DE2A) after Destroy nulled it (0051D61A), so a + // movement-flag-set-with-EMPTY-buffer desc reaches set_description + // with movement_buffer == 0 and takes the PLACEMENT branch. The + // autonomy write lives in the ELSE (movement) branch only — retail's + // empty-buffer flavor writes neither the frame-suppression NOR + // last_move_was_autonomous (UnPack likewise reads + // autonomous_movement only inside the same nonzero-length block, so + // an empty-buffer desc never even carries a wire autonomy value). + // Our parser materializes a non-null PhysicsMovementData wrapper + // with empty RawData for exactly that case, so the wrapper's + // presence alone must never decide the branch. + bool movementBranch = description?.Movement is { } movementData + && !movementData.RawData.IsEmpty; + bool lastMoveWasAutonomous = false; + bool placementFrameStaged = false; + if (movementBranch) + { + // Retail: this->last_move_was_autonomous = + // PhysicsDesc::get_autonomous_movement(esi) — written whenever a + // movement payload is present, before the createMode-gated + // unpack_movement (which is presentation-side today). + lastMoveWasAutonomous = + description!.Value.Movement!.Value.IsAutonomous ?? false; + body.LastMoveWasAutonomous = lastMoveWasAutonomous; + } + else + { + // Retail: CPhysicsObj::SetPlacementFrameInternal — direct + // placement from the description's position frame. The staged + // frame uses the PREPARED MOVER COMMAND's exact values (the same + // accepted wire position the submission will resolve), so there + // is no second position source. enter_world remains the later + // submission suffix — InWorld stays false + // (PhysicsBody.StageDormantCellFrame's documented contract). + body.Orientation = preparedCommand.Physics.Orientation; + body.StageDormantCellFrame( + preparedCommand.Physics.CellId, + preparedCommand.Physics.Position, + preparedCommand.Physics.CellLocalPosition); + placementFrameStaged = true; + } + + // 5. set_state — unconditional, AFTER the position/movement branch + // (set_description step 5). FinalPhysicsState is the retail + // state-transition view of the wire state; assigning it mirrors + // every existing canonical writer. + body.State = record.FinalPhysicsState; + + // 6. Scale — command-side (see the class doc comment); no body slot. + + // 7. Friction — byte-certain gates 1+2 + // (docs/research/2026-08-02-set-description-float-gates.md): + // outer FCOM vs 0.0 (VA 0x0051505A) admits friction >= 0.0f; inner + // FCOM vs 1.0 (VA 0x0051506A) admits friction <= 1.0f; only then is + // this->friction assigned (0x0051506C). An absent wire friction + // takes the PhysicsDesc constructor default 0.95f, which passes the + // gate — identical to the fresh body's own default, applied + // explicitly to keep retail's write order observable. + // NaN (C3b retail review R2b): retail's x87 unordered case falls + // into the APPLY bucket on both compares — the gates doc's + // pre-declared accepted compiler-codegen quirk ("not something the + // retail struct's float fields would ever hit in practice"). This + // modern port's `>= && <=` deliberately SKIPS NaN (both comparisons + // fail), the doc's sanctioned deviation; keep it — do not "fix" it + // toward the quirk. + float friction = description?.Friction ?? DefaultDescFriction; + bool frictionApplied = friction >= 0.0f && friction <= 1.0f; + if (frictionApplied) + body.Friction = friction; + + // 8. Elasticity — unconditional via the setter (set_description step + // 8); the CLAMP lives inside CPhysicsObj::set_elasticity 0x0050FD40: + // < 0 -> 0; <= 0.1 -> value; > 0.1 -> 0.1 (ACE MaxElasticity = 0.1f + // agrees; the <= vs < boundary difference at exactly 0.1 is + // valueless — both assign 0.1). + // NaN (C3b retail review R2a): retail's FIRST x87 compare sends the + // unordered case into the zeroing arm (0x0050FD51) — NaN -> 0f. + // `!(e >= 0f)` reproduces that exactly (NaN fails every ordered + // comparison, so it lands in the first arm like retail; a plain + // `e < 0f` would instead fall through and clamp NaN to 0.1f, which + // is ACE's — divergent — behavior, not the binary's). + float elasticity = description?.Elasticity ?? DefaultDescElasticity; + body.Elasticity = !(elasticity >= 0f) + ? 0f + : elasticity <= MaxElasticity + ? elasticity + : MaxElasticity; + + // 9. Translucency — translucencyOriginal is ALWAYS written + // (0x00515097, before the gate); the LIVE apply + PartArray + // propagation happen only when translucency != 0.0f (byte-certain + // gate 3, JNP fires ONLY for exact equality with 0.0f). No body + // slot — recorded on the receipt; render alpha stays + // presentation-side. NaN: `!=` sends NaN into the apply bucket, + // which here MATCHES retail's unordered case exactly (gates doc + // conditional 3's case table) — no deviation to manage. + // R2c note: IsFinite guards remain velocity/omega-only (the + // existing InitializeNewPhysicsBody convention); the float trio's + // NaN routes are each pinned above instead — no broader validation. + float translucency = description?.Translucency ?? 0f; + bool translucencyApplied = translucency != 0.0f; + + // 10. set_velocity — via the setter (set_description step 10). + // Presence-gated per the InitializeNewPhysicsBody convention; the + // absent-field end state is identical (desc default zero vector). + bool velocityApplied = false; + if (description?.Velocity is { } velocity && IsFinite(velocity)) + { + body.set_velocity(velocity); + velocityApplied = true; + } + + // 11. Omega — DIRECT field write, not the setter (load-bearing + // ordering fact 4 of the retail notes: velocity and omega are not + // symmetric). + bool omegaApplied = false; + if (description?.AngularVelocity is { } omega && IsFinite(omega)) + { + body.Omega = omega; + omegaApplied = true; + } + + // 12. default_script / default_script_intensity — snapshot-side. + // 13. All nine timestamps — LAST in retail; already applied at + // admission and frozen on the residence lease; nothing body-side. + + receipt = new RuntimeRemoteBodyConstructionReceipt( + motionTableId, + motionTableGatePassed, + movementBranch, + lastMoveWasAutonomous, + placementFrameStaged, + body.Friction, + frictionApplied, + body.Elasticity, + translucency, + translucencyApplied, + velocityApplied, + omegaApplied); + return body; + } + + private static bool IsFinite(Vector3 value) => + float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Z); +} diff --git a/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs new file mode 100644 index 00000000..9a8ae39e --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs @@ -0,0 +1,649 @@ +using AcDream.Content; +using AcDream.Core.Physics; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Entities; + +/// +/// Typed yields for . +/// Mirrors the C3a conductor's vocabulary +/// (RuntimeLocalPlayerFirstEntryStatus) rather than inventing a +/// parallel one; the two publication-only statuses have no remote analog. +/// +internal enum RuntimeRemoteFirstEntryStatus : byte +{ + /// + /// The underlying + /// call reported Completed: residence consumed, initial tail and + /// FIFO drained, ExecutorCompleted receipt dispatched. Terminal. + /// + Completed, + + /// + /// The authored-mover Setup read + /// () is not + /// yet available. Retry with the same arguments once the prepared-asset + /// package lands; no Runtime state changed. + /// + AwaitingCollisionSource, + + /// + /// The submitted placement deferred (DeferredCell — destination + /// collision generation not ready, or a collision-prefix quiescence held + /// it) and its parked operation has not produced an acknowledgeable + /// Place projection yet. The wake is internal to + /// (collision-generation commit + /// drives RetryDeferred); retry after it. + /// + AwaitingPlacement, + + /// + /// Our projection exists but could not be acknowledged this call — + /// either another entity's receipt sits ahead of ours in the one ordered + /// FIFO, or + /// still observed PendingPlacement. Retry the same stage. + /// + AwaitingReceiptAcknowledgement, + + /// + /// Passthrough of the executor's own AwaitingContinuationPlacement + /// — a later FIFO continuation needs its own authored placement before + /// the drain can finish; entirely the executor's concern from here on. + /// + AwaitingContinuationPlacement, + + /// + /// A reentrant for the SAME entity arrived while an + /// outer call for it was still on the stack, or another owner's + /// body/remote-motion binding callback is mid-flight on this record. + /// Retry once the outer call has returned. + /// + Contention, + + /// + /// The residence token matches nothing this conductor can own — including + /// a LOCAL-PLAYER lease (), + /// which belongs to the C3a conductor, never this one. + /// + RejectedToken, + + /// + /// An authority-shaped failure (stale epoch/session/identity, deleted or + /// replaced record, a foreign physics body bound out-of-band, a rejected + /// or cancelled submission). Abandoned; progress removed. The caller must + /// begin a fresh sequence (a new residence lease), never retry this call. + /// + RejectedAuthority, +} + +internal readonly record struct RuntimeRemoteFirstEntryOwnershipSnapshot( + int ActiveCount) +{ + internal bool IsConverged => ActiveCount == 0; +} + +/// +/// The dormant, resumable Runtime transaction that dissolves C3's Finding C: +/// ordinary remote-creature and projectile Creates classify to +/// SetPosition, but no production path constructs their canonical +/// at Create time (bodies arrive with first motion +/// today), so 's +/// Record.PhysicsBody requirement rejects the residence route's +/// initial placement. This class is the remote analog of the C3a conductor +/// (RuntimeLocalPlayerFirstEntryState) WITHOUT the publication chain — +/// remotes have no PlayerMovementController — and with retail body +/// construction in its place: +/// +/// mover preparation (retail CPhysicsObj::makeObject shaping the +/// Setup, which precedes set_description in +/// ACCObjectMaint::CreateObject 0x00558870 step 2-vs-6) -> +/// body construction per the exact set_description order +/// (0x00514F40; ) bound through the +/// canonical writer +/// -> ordinary authored submission +/// ( — retail +/// enter_world, which SmartBox::HandleCreateObject 0x00454C80 +/// runs for a top-level object with a nonzero wire cell AFTER CreateObject +/// returns) -> Withdraw/Place receipt acknowledgement -> +/// (FIFO +/// drain). +/// +/// Unlike the local-player path this class never touches the dormant +/// activation family: no operation it drives ever has +/// DormantLocalActivation set, so the ordinary submission tail is the +/// correct — and only — commit route. +/// +/// Dormant by design: fully +/// constructs and wires this class (construction, retirement fan-out, bulk +/// session-clear cleanup, ownership fold) exactly like the C3a conductor, +/// but nothing calls in production — C3c wires the +/// hosts. +/// +internal sealed class RuntimeRemoteFirstEntryState +{ + private enum Stage : byte + { + /// No progress yet, or the mover has not been prepared. + AwaitingMoverPreparation, + + /// Mover command in hand; the body has not been constructed. + MoverPrepared, + + /// + /// The canonical body is constructed and bound; the placement has + /// not been submitted. + /// + BodyConstructed, + + /// + /// Submission deferred (DeferredCell): the parked operation's + /// Withdraw/Place receipts are drained from the projection FIFO as + /// they surface; the wake itself is internal to + /// . + /// + PlacementSubmitted, + + /// + /// The Place projection token is known but not yet acknowledged. + /// + PlacementCommitted, + + /// + /// The Place projection has been acknowledged. Only + /// + /// remains; the acknowledgement step is never re-entered. + /// + Acknowledged, + } + + private sealed class Progress + { + internal required ulong LeaseId { get; init; } + internal Stage Stage { get; set; } = Stage.AwaitingMoverPreparation; + internal RuntimeSetPositionCommand PreparedCommand { get; set; } + internal PhysicsBody? ConstructedBody { get; set; } + internal RuntimeRemoteBodyConstructionReceipt Construction { get; set; } + internal RuntimePlacementProjectionToken Projection { get; set; } + } + + private readonly RuntimeInitialCreateResidenceState _residences; + private readonly RuntimeInitialCreateContinuationExecutor _executor; + private readonly RuntimePhysicsState _physics; + private readonly Dictionary _progress = []; + private readonly HashSet _executing = []; + + internal RuntimeRemoteFirstEntryState( + RuntimeInitialCreateResidenceState residences, + RuntimeInitialCreateContinuationExecutor executor, + RuntimePhysicsState physics) + { + _residences = residences + ?? throw new ArgumentNullException(nameof(residences)); + _executor = executor + ?? throw new ArgumentNullException(nameof(executor)); + _physics = physics + ?? throw new ArgumentNullException(nameof(physics)); + } + + /// + /// Exposes the body-construction receipt for a still-tracked entry — + /// the MID-FLIGHT half of the consumption rule documented on + /// (C3b review M1): while the sequence is in + /// flight this query serves diagnostics/tests; the terminal + /// Completed yield delivers the same receipt through Advance's + /// own out-param in the call that reaps this entry. Returns false once + /// the sequence completed or was abandoned. + /// + internal bool TryGetConstruction( + RuntimeEntityKey key, + out RuntimeRemoteBodyConstructionReceipt construction) + { + if (_progress.TryGetValue(key, out Progress? progress) + && progress.ConstructedBody is not null) + { + construction = progress.Construction; + return true; + } + construction = default; + return false; + } + + /// + /// One resumable step. Callers pass the SAME arguments on every retry; + /// this method re-reads currency from the owning states on every entry + /// rather than trusting anything cached beyond its own stage cursor and + /// the exact command/token structs the owning methods themselves require. + /// + /// Construction-receipt consumption rule (C3b review M1), + /// following the C3a/F2 precedent of receipts riding the terminal + /// Advance out-params: is populated + /// ONLY on the + /// yield — the same call that delivers the executor + /// — because the terminal Advance is a C3c + /// host's one natural consumption point and the progress entry (the + /// receipt's only retained storage) is reaped in that same call. + /// Mid-flight the receipt stays inspectable via + /// ; after Completed nothing is + /// retained. A lease whose route performs no SetPosition (Parented/ + /// PickedUp) constructs no body, so its terminal receipt is default. + /// + internal RuntimeRemoteFirstEntryStatus Advance( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + IPreparedCollisionSource collisionSource, + double gameTime, + in RuntimeInitialCreateExecutionInputs inputs, + out RuntimeInitialCreateExecutionReceipt receipt, + out RuntimeRemoteBodyConstructionReceipt construction) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(collisionSource); + receipt = default; + construction = default; + if (!residenceToken.IsValid || record.Key is not { } key) + return RuntimeRemoteFirstEntryStatus.RejectedToken; + + // Mirrors the executor's and the C3a conductor's _executing guard: a + // synchronous reentrant call for the SAME entity fails closed rather + // than interleaving two drains of one stage machine. + if (!_executing.Add(key)) + return RuntimeRemoteFirstEntryStatus.Contention; + try + { + return AdvanceCore( + record, + residenceToken, + collisionSource, + gameTime, + inputs, + key, + out receipt, + out construction); + } + finally + { + _executing.Remove(key); + } + } + + private RuntimeRemoteFirstEntryStatus AdvanceCore( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + IPreparedCollisionSource collisionSource, + double gameTime, + in RuntimeInitialCreateExecutionInputs inputs, + RuntimeEntityKey key, + out RuntimeInitialCreateExecutionReceipt receipt, + out RuntimeRemoteBodyConstructionReceipt construction) + { + receipt = default; + construction = default; + + _progress.TryGetValue(key, out Progress? progress); + // ABA/GUID-reuse guard, exactly like the C3a conductor and the + // executor's own Progress reconciliation. + if (progress is not null && progress.LeaseId != residenceToken.LeaseId) + { + Discard(key); + progress = null; + } + + if (progress is null || progress.Stage is Stage.AwaitingMoverPreparation) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + if (progress is null) + return RuntimeRemoteFirstEntryStatus.RejectedToken; + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + + // This conductor owns REMOTE and PROJECTILE residence leases + // only. A local-player lease (InitialLogin — or the structurally + // impossible-at-Create LocalAuthoritative) belongs to the C3a + // conductor and its publication chain; refusing it here is + // "nothing tracked in this domain", not an abandonment. + if (lease.Route.OperationKind + is not (RuntimeSetPositionOperationKind.RemoteAuthoritative + or RuntimeSetPositionOperationKind.ProjectileAuthoritative)) + { + return RuntimeRemoteFirstEntryStatus.RejectedToken; + } + + if (!lease.Route.PerformsSetPosition) + { + // Parented/PickedUp residence: no SetPosition operation + // exists, so there is nothing to place and — matching + // today's production behavior for those routes — no body is + // constructed at Create (retail constructs one, but a + // parented child's placement is driven by later parent/ + // pickup events; body-at-Create for those routes stays with + // the first-motion path until a later slice widens this). + // Skip straight to Execute, mirroring the C3a conductor. + progress ??= new Progress { LeaseId = residenceToken.LeaseId }; + progress.Stage = Stage.Acknowledged; + _progress[key] = progress; + return RunExecute( + record, + residenceToken, + inputs, + key, + progress, + out receipt, + out construction); + } + + RuntimeSetPositionMoverPreparationStatus moverStatus = _physics + .SetPosition.TryPrepareAuthoredMover( + record, + lease.Placement, + lease.Route.OperationKind, + lease.Route.SetPositionFlags, + collisionSource, + gameTime, + out RuntimeSetPositionCommand command); + if (moverStatus + == RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable) + { + return RuntimeRemoteFirstEntryStatus.AwaitingCollisionSource; + } + if (moverStatus != RuntimeSetPositionMoverPreparationStatus.Prepared) + { + if (progress is not null) + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + + progress ??= new Progress { LeaseId = residenceToken.LeaseId }; + progress.PreparedCommand = command; + progress.Stage = Stage.MoverPrepared; + _progress[key] = progress; + } + + if (progress.Stage is Stage.MoverPrepared) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + + if (record.PhysicsBody is { } existing) + { + if (ReferenceEquals(progress.ConstructedBody, existing)) + { + // Idempotent retry: our own construction already bound. + progress.Stage = Stage.BodyConstructed; + } + else + { + // A body this conductor did not construct appeared while + // the residence lease was still active — an out-of-band + // owner raced Create-time construction. Never clobber an + // existing canonical body (the writer map's invariant); + // fail closed and let the lease's own retirement path + // converge. + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + } + else if (record.PhysicsBodyAcquisitionInProgress + || record.RemoteMotionBindingInProgress) + { + // Another owner's binding callback is mid-flight on this + // exact record (only reachable when this Advance itself runs + // inside that callback). Typed contention instead of letting + // GetOrCreatePhysicsBody throw its structural guard. + return RuntimeRemoteFirstEntryStatus.Contention; + } + else + { + // Retail order: CreateObject acquires the physics object + // from the Setup (makeObject — our mover preparation, stage + // 1) and then applies the PhysicsDesc via set_description + // (RuntimeRemoteBodyDescription.Construct). Binding runs + // through the canonical GetOrCreatePhysicsBody writer: its + // post-factory InitializeNewPhysicsBody re-applies + // state/velocity/omega from the live snapshot — identical by + // value to the frozen-description writes the factory already + // made (nothing between admission and this call mutates the + // snapshot's physics payload; continuations are queued, not + // applied) — and its SynchronizeBodyActiveState aligns the + // Active transient bit with the record's object clock. + RuntimeRemoteBodyConstructionReceipt built = default; + PhysicsBody constructed = _physics.GetOrCreatePhysicsBody( + record, + r => RuntimeRemoteBodyDescription.Construct( + r, + lease.InitialCreate.Physics, + progress.PreparedCommand, + out built)); + progress.ConstructedBody = constructed; + progress.Construction = built; + progress.Stage = Stage.BodyConstructed; + } + } + + if (progress.Stage is Stage.BodyConstructed) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + + RuntimeSetPositionOutcome outcome = _physics.SetPosition + .SubmitPreparedPlacement(lease.Placement, progress.PreparedCommand); + switch (outcome.Status) + { + case RuntimeSetPositionStatus.CommittedHostAcknowledgementPending: + progress.Projection = outcome.Projection; + progress.Stage = Stage.PlacementCommitted; + break; + case RuntimeSetPositionStatus.DeferredCell: + // ParkDeferred published a Withdraw receipt and parked + // the operation; the projection FIFO drives everything + // from here (drained in the PlacementSubmitted stage + // below, this same call). + progress.Stage = Stage.PlacementSubmitted; + break; + default: + // Rejected (the SetPosition transaction failed — retail's + // enter_world failure leaves the object celless; the + // resident-cell-cleanup family owns that destiny, not a + // silent retry here) or Cancelled (a reentrant observer + // displaced the operation). Fail closed. + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + } + + if (progress.Stage is Stage.PlacementSubmitted) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + + // Drain OUR OWN receipts from the FIFO head as they surface: + // Withdraw (the deferred park) must be acknowledged before the + // internal collision-generation wake can resubmit; the wake's + // commit then publishes the Place this stage is waiting for. + while (true) + { + if (!_physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + // Nothing pending anywhere — the operation is parked + // awaiting its cell/collision-generation wake. The + // residence currency check above already proved the + // placement operation itself is still tracked. + return RuntimeRemoteFirstEntryStatus.AwaitingPlacement; + } + if (head.Token.Entity != key) + { + // Another entity's receipt sits ahead of ours in the one + // ordered FIFO. + return RuntimeRemoteFirstEntryStatus + .AwaitingReceiptAcknowledgement; + } + if (head.Kind is RuntimePlacementProjectionKind.Withdraw) + { + if (!_physics.SetPosition.AcknowledgeProjection(head.Token)) + { + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + // The withdrawal acknowledgement may have re-armed (or — + // when the generation was already ready — synchronously + // resubmitted) the parked operation; peek again. + continue; + } + if (head.Kind is RuntimePlacementProjectionKind.Place) + { + progress.Projection = head.Token; + progress.Stage = Stage.PlacementCommitted; + break; + } + // Discard (a delete/cancel rewrote our slot) or any other + // kind bearing our key: authority moved. Leave the receipt + // for the ordinary host drain — mirroring the C3a + // conductor's abandonment, which never consumes a Discard + // it did not publish — and fail closed. + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + } + + if (progress.Stage is Stage.PlacementCommitted) + { + if (!_physics.SetPosition.AcknowledgeProjection(progress.Projection)) + { + // Same re-validation the C3a conductor performs on a failed + // acknowledge: only a genuinely-not-our-turn FIFO head stays + // retryable; a retired lease or a rewritten/superseded slot + // means authority moved. + if (!IsAcknowledgementStillPending( + record, residenceToken, progress.Projection)) + { + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + return RuntimeRemoteFirstEntryStatus + .AwaitingReceiptAcknowledgement; + } + progress.Stage = Stage.Acknowledged; + } + + return RunExecute( + record, + residenceToken, + inputs, + key, + progress, + out receipt, + out construction); + } + + /// + /// Re-validates authority after a failed acknowledge — the exact C3a + /// mechanism, shared verbatim with the local-player conductor via + /// (C3b + /// review M2: one body, so the abandonment fix cannot regress + /// independently in either conductor). Full rationale on the shared + /// helper. + /// + private bool IsAcknowledgementStillPending( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + in RuntimePlacementProjectionToken expected) => + RuntimeFirstEntryAcknowledgement.IsStillPending( + _residences, + _physics.SetPosition, + record, + residenceToken, + expected); + + private RuntimeRemoteFirstEntryStatus RunExecute( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + in RuntimeInitialCreateExecutionInputs inputs, + RuntimeEntityKey key, + Progress progress, + out RuntimeInitialCreateExecutionReceipt receipt, + out RuntimeRemoteBodyConstructionReceipt construction) + { + construction = default; + RuntimeInitialCreateExecutionStatus executeStatus = _executor.Execute( + record, residenceToken, inputs, out receipt); + switch (executeStatus) + { + case RuntimeInitialCreateExecutionStatus.Completed: + // C3b review M1: the terminal Advance is the one natural + // consumption point — deliver the construction receipt in + // the same call that reaps its only retained storage (this + // progress entry). Default (no body constructed) for a + // route that performs no SetPosition. + construction = progress.Construction; + _progress.Remove(key); + return RuntimeRemoteFirstEntryStatus.Completed; + case RuntimeInitialCreateExecutionStatus.PendingPlacement: + return RuntimeRemoteFirstEntryStatus + .AwaitingReceiptAcknowledgement; + case RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement: + return RuntimeRemoteFirstEntryStatus + .AwaitingContinuationPlacement; + case RuntimeInitialCreateExecutionStatus.RejectedToken: + _progress.Remove(key); + return RuntimeRemoteFirstEntryStatus.RejectedToken; + default: + _progress.Remove(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + } + + /// + /// Drops this class's own progress entry for . + /// Unlike the C3a conductor there is no publication candidate/activation + /// to discard — the constructed body, once bound through the canonical + /// writer, belongs to the record and is torn down by ordinary entity + /// teardown (retail has no entry-flow rollback; the C3a carried finding + /// applies identically here). The residence and executor own their own + /// convergence independently. + /// + private void Discard(RuntimeEntityKey key) => _progress.Remove(key); + + /// + /// Cleanup for one key. binds + /// this into 's multicast + /// retirement notification (alongside the executor's + /// DiscardProgress and the C3a conductor's Forget), so any + /// residence retirement path — delete, reset, generation replacement, a + /// host discovering staleness — reaps this class's progress + /// automatically, using the exact key the residence tracked internally. + /// + internal void Forget(RuntimeEntityKey key) => Discard(key); + + /// + /// Bulk cleanup wired into the same session-clear sequence + /// () as the + /// executor's and the C3a conductor's own DiscardAll calls. + /// + internal void DiscardAll() => _progress.Clear(); + + internal RuntimeRemoteFirstEntryOwnershipSnapshot CaptureOwnership() => + new(_progress.Count); +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs index c5629e5d..f366c733 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs @@ -533,49 +533,26 @@ internal sealed class RuntimeLocalPlayerFirstEntryState } /// - /// Re-validates authority after a failed acknowledge. Two independent - /// checks, either of which failing means authority moved and this class - /// must abandon rather than keep retrying forever: (1) the residence - /// lease this whole sequence began under must still be exactly current - /// (mirrors the stage0/stage1 checks — a delete or reset retires it); - /// (2) if the FIFO head belongs to THIS entity at all, it must still be - /// the exact Place projection this class is holding — a head that - /// belongs to us but is no longer that exact token (rewritten to - /// Discard, or to a later revision) means our specific placement was - /// superseded even if the residence lookup transiently still resolves. - /// A head belonging to a DIFFERENT entity is the genuine "not yet our - /// turn" case and must stay retryable. This reads the Runtime-internal - /// directly - /// rather than through the public, generation-gated - /// — this class is part - /// of Runtime, not an external host crossing that boundary, exactly like - /// its existing direct - /// call above. + /// Re-validates authority after a failed acknowledge. C3b review M2: + /// the mechanism (residence-lease currency + exact-head-token match; a + /// DIFFERENT entity's head stays retryable) is shared verbatim with the + /// remote conductor via + /// — one + /// body, so the abandonment fix that stops a delete-rewritten Discard + /// head from producing an infinite AwaitingReceiptAcknowledgement retry + /// cannot regress independently in either conductor. Full rationale on + /// the shared helper. /// private bool IsAcknowledgementStillPending( RuntimeEntityRecord record, in RuntimeInitialCreateResidenceToken residenceToken, - in RuntimePlacementProjectionToken expected) - { - if (!_residences.TryGetCurrent( - record, - out RuntimeInitialCreateResidenceLease lease) - || lease.Token != residenceToken) - { - return false; - } - - if (_physics.SetPosition.TryPeekProjection( - out RuntimePlacementProjectionSnapshot head) - && head.Token.Entity == expected.Entity - && (head.Kind is not RuntimePlacementProjectionKind.Place - || head.Token != expected)) - { - return false; - } - - return true; - } + in RuntimePlacementProjectionToken expected) => + RuntimeFirstEntryAcknowledgement.IsStillPending( + _residences, + _physics.SetPosition, + record, + residenceToken, + expected); private RuntimeLocalPlayerFirstEntryStatus RunExecute( RuntimeEntityRecord record, diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs new file mode 100644 index 00000000..7f54988d --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs @@ -0,0 +1,980 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Content; +using AcDream.Content.Pak; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime; + +namespace AcDream.Runtime.Tests.Entities; + +public sealed class RuntimeRemoteFirstEntryStateTests +{ + private const uint Landblock = 0xA9B60000u; + private const uint Cell = Landblock | 0x0001u; + private const uint SetupId = 0x02000001u; + private static readonly RuntimeInitialCreateExecutionInputs NoContact = + new(UsePositionFromServer: false, PlayerDistance: 0f); + + // --------------------------------------------------------------- + // Happy path + // --------------------------------------------------------------- + + [Fact] + public void FullSequenceRemoteEntryConstructsGatedBodyPlacesAndCompletes() + { + using var fixture = new Fixture(residentWorld: true); + + RuntimeRemoteFirstEntryStatus status = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt, + out RuntimeRemoteBodyConstructionReceipt construction); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, status); + Assert.Equal(Cell, receipt.FullCellId); + // Remote Creates carry NO teleport hook (classifier: AfterEnterWorld + // is local-player-only). + Assert.Equal(RuntimeTeleportHookPhase.None, receipt.TeleportHookPhase); + // M1: the terminal Advance delivers the construction receipt in the + // same call that reaps its retained storage — the C3c host's one + // natural consumption point. + Assert.True(construction.MotionTableGatePassed); + Assert.Equal(0x09000001u, construction.MotionTableId); + Assert.False(construction.MovementBranch); + Assert.True(construction.PlacementFrameStaged); + Assert.True(construction.FrictionApplied); + Assert.Equal(0.5f, construction.Friction); + Assert.False(construction.TranslucencyApplied); + Assert.Equal(0f, construction.TranslucencyOriginal); + Assert.True(construction.VelocityApplied); + Assert.True(construction.OmegaApplied); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.Equal(Cell, fixture.Record.FullCellId); + // set_description field application from the frozen wire desc: + // friction 0.5 passed gates 1+2; elasticity 0.05 inside the + // set_elasticity clamp; state is the record's retail-transitioned + // view; omega was the raw step-11 write. + Assert.Equal(0.5f, body.Friction); + Assert.Equal(0.05f, body.Elasticity); + Assert.Equal(fixture.Record.FinalPhysicsState, body.State); + Assert.Equal(new Vector3(0f, 0f, 0.25f), body.Omega); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.False(fixture.Conductor.TryGetConstruction( + fixture.Key, out _)); + Assert.False(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out _)); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .InitialCreateExecutorProgressCount); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .RemoteFirstEntryActiveCount); + + // Retrying with the now-stale residence token is a distinct, safe + // no-op — nothing left to resume. + Assert.Equal( + RuntimeRemoteFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + } + + [Fact] + public void ProjectileFlavorFullSequenceCompletesWithMissileState() + { + using var fixture = new Fixture( + residentWorld: true, + rawState: (uint)(PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.Inelastic + | PhysicsStateFlags.PathClipped + | PhysicsStateFlags.AlignPath)); + + // The Missile flag classified the Create to ProjectileAuthoritative + // (RuntimeInitialCreateResidenceState.Begin) — the conductor accepts + // that flavor through the identical stages. + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal( + RuntimeSetPositionOperationKind.ProjectileAuthoritative, + lease.Route.OperationKind); + + RuntimeRemoteFirstEntryStatus status = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, status); + Assert.Equal(Cell, receipt.FullCellId); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.True(body.State.HasFlag(PhysicsStateFlags.Missile)); + Assert.Equal(fixture.Record.FinalPhysicsState, body.State); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // The three float gates, proven both ways on the pure construction + // (docs/research/2026-08-02-set-description-float-gates.md is law) + // --------------------------------------------------------------- + + [Theory] + [InlineData(-1f, false, 0.95f)] + [InlineData(0f, true, 0f)] + [InlineData(0.5f, true, 0.5f)] + [InlineData(1f, true, 1f)] + [InlineData(1.5f, false, 0.95f)] + // R2b: NaN is SKIPPED (body keeps its default) — the gates doc's + // sanctioned deviation from retail's unordered-goes-to-apply codegen + // quirk; see the friction comment in RuntimeRemoteBodyDescription. + [InlineData(float.NaN, false, 0.95f)] + public void FrictionGateAppliesOnlyInsideClosedUnitInterval( + float wireFriction, + bool expectedApplied, + float expectedBodyFriction) + { + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(friction: wireFriction); + + Assert.Equal(expectedApplied, receipt.FrictionApplied); + Assert.Equal(expectedBodyFriction, body.Friction); + Assert.Equal(body.Friction, receipt.Friction); + } + + [Fact] + public void FrictionAbsentOnWireTakesTheDescConstructorDefault() + { + // PhysicsDesc ctor 0x0051D4D0 seeds friction 0.95f; it passes the + // gate, so an absent wire friction still records an APPLIED write. + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(friction: null); + + Assert.True(receipt.FrictionApplied); + Assert.Equal(0.95f, body.Friction); + } + + [Theory] + [InlineData(0f, false)] + [InlineData(0.5f, true)] + [InlineData(-0.25f, true)] + // R2: NaN lands in the apply bucket — matching retail's unordered case + // exactly (gates doc conditional 3: JNP fires ONLY on exact equality + // with 0.0f). + [InlineData(float.NaN, true)] + public void TranslucencyGateAppliesOnlyWhenNonZeroAndOriginalIsAlwaysRecorded( + float wireTranslucency, + bool expectedApplied) + { + (_, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(translucency: wireTranslucency); + + // translucencyOriginal is written UNCONDITIONALLY (0x00515097, + // before the gate); the live apply fires for every value except + // exact 0.0f (gate 3's JNP fires only on equality). + Assert.Equal(wireTranslucency, receipt.TranslucencyOriginal); + Assert.Equal(expectedApplied, receipt.TranslucencyApplied); + } + + [Fact] + public void TranslucencyAbsentOnWireIsZeroOriginalAndNoLiveApply() + { + (_, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(translucency: null); + + Assert.Equal(0f, receipt.TranslucencyOriginal); + Assert.False(receipt.TranslucencyApplied); + } + + [Theory] + [InlineData(-0.5f, 0f)] + [InlineData(0f, 0f)] + [InlineData(0.05f, 0.05f)] + [InlineData(0.1f, 0.1f)] + [InlineData(0.2f, 0.1f)] + // R2a: retail's FIRST x87 compare sends unordered to the zeroing arm + // (0x0050FD51) — NaN -> 0f, NOT ACE's divergent NaN -> 0.1f. + [InlineData(float.NaN, 0f)] + public void ElasticityClampMatchesRetailSetter( + float wireElasticity, + float expectedBodyElasticity) + { + // CPhysicsObj::set_elasticity 0x0050FD40: < 0 -> 0; <= 0.1 -> value; + // > 0.1 -> 0.1 (ACE PhysicsGlobals.MaxElasticity = 0.1f agrees). + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(elasticity: wireElasticity); + + Assert.Equal(expectedBodyElasticity, body.Elasticity); + Assert.Equal(body.Elasticity, receipt.Elasticity); + } + + // --------------------------------------------------------------- + // Placement-frame-vs-movement branch (set_description step 4) + // --------------------------------------------------------------- + + [Fact] + public void MovementPayloadSuppressesPlacementFrameAndWritesAutonomy() + { + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(movement: new PhysicsMovementData( + RawData: new byte[] { 0x01 }, + MotionState: null, + IsAutonomous: true)); + + Assert.True(receipt.MovementBranch); + Assert.True(receipt.LastMoveWasAutonomous); + Assert.True(body.LastMoveWasAutonomous); + Assert.False(receipt.PlacementFrameStaged); + // No frame staged: the body's cell identity stays default-detached. + Assert.Equal(0u, body.CellPosition.ObjCellId); + Assert.False(body.InWorld); + } + + [Fact] + public void MovementFlagWithEmptyBufferTakesThePlacementBranch() + { + // R1: retail's branch discriminator is `movement_buffer != 0` — + // PhysicsDesc::UnPack (0x0051DDD0) assigns the buffer only inside + // `if (buff_length != 0)` (0051DE1F-0051DE2A), so a movement flag + // with a ZERO-LENGTH buffer reaches set_description with a null + // buffer and takes the PLACEMENT branch; the autonomy write (in the + // movement else-branch only) never runs. Our parser materializes + // exactly this wrapper shape (CreateObject.cs: non-null + // PhysicsMovementData with empty RawData). IsAutonomous is set true + // here deliberately — the BRANCH, never the wrapper's value, must + // decide. + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(movement: new PhysicsMovementData( + RawData: ReadOnlyMemory.Empty, + MotionState: null, + IsAutonomous: true)); + + Assert.False(receipt.MovementBranch); + Assert.True(receipt.PlacementFrameStaged); + Assert.Equal(Cell, body.CellPosition.ObjCellId); + Assert.False(body.InWorld); + Assert.False(receipt.LastMoveWasAutonomous); + Assert.False(body.LastMoveWasAutonomous); + } + + [Fact] + public void NoMovementPayloadStagesTheDormantPlacementFrame() + { + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(movement: null); + + Assert.False(receipt.MovementBranch); + Assert.True(receipt.PlacementFrameStaged); + Assert.Equal(Cell, body.CellPosition.ObjCellId); + // SetPlacementFrameInternal is NOT enter_world — the submission owns + // world residence. + Assert.False(body.InWorld); + Assert.False(body.LastMoveWasAutonomous); + } + + // --------------------------------------------------------------- + // Yield flavors + retry idempotency per stage + // --------------------------------------------------------------- + + [Fact] + public void AwaitingCollisionSourceRetriesThenResumesOnceSetupLands() + { + using var fixture = new Fixture(residentWorld: true, setupTableId: SetupId); + fixture.CollisionSource.Status = PreparedAssetReadStatus.Missing; + + RuntimeRemoteFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingCollisionSource, first); + // No progress entry, no body, nothing mutated while the Setup is + // outstanding. + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Null(fixture.Record.PhysicsBody); + + RuntimeRemoteFirstEntryStatus second = fixture.Advance(out _); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingCollisionSource, second); + Assert.True(fixture.CollisionSource.ReadCount >= 2); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Null(fixture.Record.PhysicsBody); + + fixture.CollisionSource.Status = PreparedAssetReadStatus.Loaded; + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(Cell, receipt.FullCellId); + Assert.NotNull(fixture.Record.PhysicsBody); + } + + [Fact] + public void DeferredPlacementRetainsOneBodyIdentityAcrossRetriesThenWakesAndCompletes() + { + // Non-resident world: the ordinary submission defers (the + // destination landblock's collision world was never added), parking + // the operation behind a Withdraw the conductor drains itself. + using var fixture = new Fixture(residentWorld: false); + + RuntimeRemoteFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, first); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.False(body.InWorld); + Assert.Equal(0u, fixture.Record.FullCellId); + // The construction is still inspectable mid-flight, and the + // set_description writes are already on the body. + Assert.True(fixture.Conductor.TryGetConstruction( + fixture.Key, out RuntimeRemoteBodyConstructionReceipt construction)); + Assert.True(construction.FrictionApplied); + Assert.Equal(0.5f, body.Friction); + Assert.True(construction.VelocityApplied); + Assert.Equal(new Vector3(1f, 2f, 0.5f), body.Velocity); + Assert.True(construction.OmegaApplied); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + + // Retry idempotency: the SAME body identity survives every retry — + // no duplicate construction, no re-submission churn. + for (int i = 0; i < 3; i++) + { + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, + fixture.Advance(out _)); + Assert.Same(body, fixture.Record.PhysicsBody); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Landblock, generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Landblock, generation, ready: true); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Same(body, fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(Cell, fixture.Record.FullCellId); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + [Fact] + public void ReentrantAdvanceDuringCollisionCallbackFailsClosedWithContentionAndOuterCallStillCompletes() + { + using var fixture = new Fixture(residentWorld: true); + bool reentered = false; + RuntimeRemoteFirstEntryStatus? innerStatus = null; + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => + { + if (!reentered && phase is TransitionCellCollisionPhase.Environment) + { + reentered = true; + innerStatus = fixture.Advance(out _); + } + return observed; + }; + + RuntimeRemoteFirstEntryStatus outerStatus = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.True(reentered); + Assert.Equal(RuntimeRemoteFirstEntryStatus.Contention, innerStatus); + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, outerStatus); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + [Fact] + public void AwaitingReceiptAcknowledgementWhileAnotherEntityHoldsTheFifoHeadThenResumes() + { + using var fixture = new Fixture(residentWorld: true); + (RuntimeEntityRecord other, RuntimePlacementProjectionToken otherToken) = + BeginPendingOrdinaryPlacement(fixture, 0x70099001u); + + // Our submission commits, but its Place sits BEHIND the other + // entity's unacknowledged receipt in the one ordered FIFO. + RuntimeRemoteFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal( + RuntimeRemoteFirstEntryStatus.AwaitingReceiptAcknowledgement, + first); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + + RuntimeRemoteFirstEntryStatus second = fixture.Advance(out _); + Assert.Equal( + RuntimeRemoteFirstEntryStatus.AwaitingReceiptAcknowledgement, + second); + + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(otherToken)); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(Cell, receipt.FullCellId); + _ = other; + } + + [Fact] + public void DeleteWhileAwaitingReceiptAcknowledgementAbandonsInsteadOfRetryingForeverAndConverges() + { + // M2a — the exact C3a-shaped scenario: the placement has ALREADY + // committed (body in world) but acknowledgement is blocked behind + // another entity's unacknowledged Place. The delete-path mechanism + // (Physics.SetPosition.Forget with releasePreparedMover — the exact + // inner call TryAcceptDelete makes) rewrites our still-pending Place + // slot to Discard with a bumped revision, so the cached + // progress.Projection can never match the FIFO head again. Without + // the shared IsAcknowledgementStillPending re-check, Advance would + // report AwaitingReceiptAcknowledgement forever and ownership would + // never converge. + using var fixture = new Fixture(residentWorld: true); + (RuntimeEntityRecord other, RuntimePlacementProjectionToken otherToken) = + BeginPendingOrdinaryPlacement(fixture, 0x70099002u); + + Assert.Equal( + RuntimeRemoteFirstEntryStatus.AwaitingReceiptAcknowledgement, + fixture.Advance(out _)); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out _)); + + RuntimePlacementCancellationReceipt cancellation = fixture.Lifetime + .Physics.SetPosition.Forget( + fixture.Record, + releasePreparedMover: true); + fixture.Lifetime.Physics.SetPosition.PublishCancellation(cancellation); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.RejectedAuthority, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + + // The unrelated entity's own placement is untouched and still + // acknowledgeable — the abandonment never reached past our own + // entity's projection. + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(otherToken)); + _ = other; + } + + [Fact] + public void AwaitingContinuationPlacementPropagatesExecutorYieldThenResumes() + { + using var fixture = new Fixture(residentWorld: true); + // A fresh Position arrives while the initial residence is still + // pending — enqueued as a continuation, classified only at drain. + WorldSession.EntityPositionUpdate update = new( + fixture.Record.ServerGuid, + new CreateObject.ServerPosition(Cell, 40f, 20f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: 2, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 1, + ForcePositionSequence: 0); + Assert.True(fixture.Lifetime.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + RuntimeRemoteFirstEntryStatus status = fixture.Advance(out _); + Assert.Equal( + RuntimeRemoteFirstEntryStatus.AwaitingContinuationPlacement, + status); + // The conductor's own sequence reached its terminal Acknowledged + // stage; the retained progress entry lets a retry skip straight to + // re-calling Execute. + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + + RuntimeEntityKey key = fixture.Key; + Assert.True(fixture.Lifetime.InitialCreateExecution + .TryGetPendingContinuationPlacement( + key, out RuntimeEntityPlacementToken placement)); + Assert.True(fixture.Lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute( + key, out RuntimeAuthoritativePositionRoute route)); + CompleteOrdinaryPlacement(fixture, placement, route); + + RuntimeRemoteFirstEntryStatus resumed = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, resumed); + Assert.Contains(receipt.Trace, + a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // Domain refusal + never-clobber + // --------------------------------------------------------------- + + [Fact] + public void LocalPlayerLeaseIsRefusedWithoutTrackingAnything() + { + using var fixture = new Fixture(residentWorld: true, isLocalPlayer: true); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Null(fixture.Record.PhysicsBody); + // The lease itself is untouched — the C3a conductor still owns it. + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal(fixture.Lease.Token, lease.Token); + } + + [Fact] + public void ForeignBodyBoundOutOfBandAbandonsWithoutClobbering() + { + using var fixture = new Fixture(residentWorld: true); + var foreign = new PhysicsBody + { + Position = new Vector3(1f, 2f, 3f), + State = fixture.Record.FinalPhysicsState, + }; + fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, foreign); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.RejectedAuthority, + fixture.Advance(out _)); + // The foreign body was never replaced or mutated toward ours. + Assert.Same(foreign, fixture.Record.PhysicsBody); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // Delete / reset mid-flight convergence + // --------------------------------------------------------------- + + [Fact] + public void DeleteWhileAwaitingPlacementConvergesAutomaticallyThroughTheRetirementFanOut() + { + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, + fixture.Advance(out _)); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + + DeleteEntity(fixture); + Assert.Null(fixture.Record.Key); + + // TryAcceptDelete's ForgetInitialCreateResidence fired the multicast + // retirement notification synchronously — this conductor's Forget is + // bound into that same fan-out, so convergence is complete before + // delete returns. + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + + // Retrying Advance afterward is a safe, distinct no-op. + Assert.Equal(RuntimeRemoteFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + } + + [Fact] + public void SessionClearMidFlightConvergesOwnership() + { + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, + fixture.Advance(out _)); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + + _ = fixture.Lifetime.BeginSessionClear(); + + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .RemoteFirstEntryActiveCount); + } + + [Fact] + public void DeleteAndSameGuidReincarnationStartsAFreshSequence() + { + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, + fixture.Advance(out _)); + RuntimeEntityKey staleKey = fixture.Key; + uint guid = fixture.Record.ServerGuid; + + DeleteEntity(fixture); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + + RuntimeEntityRecord reincarnated = fixture.Lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, incarnation: 2), + isLocalPlayer: false) + .Canonical!; + Assert.NotEqual(staleKey, reincarnated.Key!.Value); + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + reincarnated, + out RuntimeInitialCreateResidenceLease freshLease)); + fixture.Record = reincarnated; + fixture.Lease = freshLease; + + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, + fixture.Advance(out _)); + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Landblock, generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Landblock, generation, ready: true); + + RuntimeRemoteFirstEntryStatus status = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, status); + Assert.Equal(Cell, receipt.FullCellId); + Assert.NotNull(reincarnated.PhysicsBody); + Assert.True(reincarnated.PhysicsBody!.InWorld); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private static (PhysicsBody Body, RuntimeRemoteBodyConstructionReceipt Receipt) + ConstructDirect( + float? friction = 0.5f, + float? elasticity = 0.05f, + float? translucency = null, + PhysicsMovementData? movement = null) + { + var record = new RuntimeEntityRecordFactory(); + RuntimeEntityRecord canonical = record.Lifetime + .RegisterEntity( + Spawn( + 0x70090077u, + incarnation: 1, + friction: friction, + elasticity: elasticity, + translucency: translucency, + movement: movement)) + .Canonical!; + var command = new RuntimeSetPositionCommand( + new PhysicsSetPositionRequest( + new Vector3(1f, 2f, 3f), + Quaternion.Identity, + Cell, + new Vector3(1f, 2f, 3f), + ImmutableArray.Empty, + Scale: 1f, + StepUpHeight: 0f, + StepDownHeight: 0f, + canonical.FinalPhysicsState, + ObjectInfoState.None, + canonical.Key?.LocalEntityId ?? 0u, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement | PhysicsSetPositionFlags.Slide), + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 1d, + ExpectedVelocityAuthorityVersion: 0UL); + + PhysicsBody body = RuntimeRemoteBodyDescription.Construct( + canonical, + canonical.Snapshot.Physics, + command, + out RuntimeRemoteBodyConstructionReceipt receipt); + record.Dispose(); + return (body, receipt); + } + + private sealed class RuntimeEntityRecordFactory : IDisposable + { + internal RuntimeEntityObjectLifetime Lifetime { get; } = new(); + + internal RuntimeEntityRecordFactory() + { + var generation = new RuntimeGenerationToken(1UL); + Lifetime.BindEventContext(() => generation, static () => 1UL); + } + + public void Dispose() => Lifetime.Dispose(); + } + + private static (RuntimeEntityRecord Record, RuntimePlacementProjectionToken Token) + BeginPendingOrdinaryPlacement(Fixture fixture, uint guid) + { + RuntimeEntityRecord record = fixture.Lifetime.RegisterEntity( + Spawn(guid, incarnation: 1)).Canonical!; + var body = new PhysicsBody + { + Position = new Vector3(50f, 50f, 3f), + Orientation = Quaternion.Identity, + State = record.FinalPhysicsState, + }; + body.SnapToCell(Cell, body.Position, body.Position); + fixture.Lifetime.Entities.SetPhysicsBody(record, body); + RuntimeEntityPlacementToken placement = fixture.Lifetime.Physics + .SetPosition.BeginAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(placement.IsValid); + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement | PhysicsSetPositionFlags.Slide); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + fixture.Lifetime.Physics.SetPosition.PrepareMover( + placement, preparation, out RuntimeSetPositionCommand command)); + RuntimeSetPositionOutcome outcome = fixture.Lifetime.Physics.SetPosition + .SubmitPreparedPlacement(placement, command); + Assert.Equal(RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + return (record, outcome.Projection); + } + + private static void CompleteOrdinaryPlacement( + Fixture fixture, + in RuntimeEntityPlacementToken placement, + in RuntimeAuthoritativePositionRoute route) + { + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + route.OperationKind, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + route.SetPositionFlags); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + fixture.Lifetime.Physics.SetPosition.PrepareMover( + placement, preparation, out RuntimeSetPositionCommand command)); + RuntimeSetPositionOutcome outcome = fixture.Lifetime.Physics.SetPosition + .SubmitPreparedPlacement(placement, command); + Assert.Equal(RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(outcome.Projection)); + } + + private static void DeleteEntity(Fixture fixture) + { + Assert.True(fixture.Lifetime.TryAcceptDelete( + new DeleteObject.Parsed( + fixture.Record.ServerGuid, fixture.Record.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + fixture.Lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(fixture.Lifetime.RetireCanonicalOnly(fixture.Record)); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation, + bool includePosition = true, + uint setupTableId = 0u, + uint rawState = (uint)(PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions), + float? friction = 0.5f, + float? elasticity = 0.05f, + float? translucency = null, + PhysicsMovementData? movement = null) + { + CreateObject.ServerPosition? position = includePosition + ? new CreateObject.ServerPosition(Cell, 1f, 2f, 3f, 1f, 0f, 0f, 0f) + : null; + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + RawState: rawState, + Position: position, + Movement: movement, + AnimationFrame: null, + SetupTableId: setupTableId == 0u ? null : setupTableId, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: 1f, + Friction: friction, + Elasticity: elasticity, + Translucency: translucency, + Velocity: new Vector3(1f, 2f, 0.5f), + Acceleration: null, + AngularVelocity: new Vector3(0f, 0f, 0.25f), + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + Guid: guid, + Position: position, + SetupTableId: setupTableId == 0u ? null : setupTableId, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "remote-entry-fixture", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: physics.RawState, + ObjectDescriptionFlags: 0x8u, + Friction: friction, + Elasticity: elasticity, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class FakeCollisionSource( + uint expectedSetupTableId, + FlatSetupCollision setup) : IPreparedCollisionSource + { + internal int ReadCount { get; private set; } + internal PreparedAssetReadStatus Status { get; set; } = + PreparedAssetReadStatus.Loaded; + + public PreparedAssetPresence ProbeCollision( + PakAssetType type, uint sourceFileId) => + PreparedAssetPresence.Available; + + public PreparedCollisionReadResult ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + ReadCount++; + Assert.Equal(expectedSetupTableId, sourceFileId); + return Status switch + { + PreparedAssetReadStatus.Loaded => + PreparedCollisionReadResult.Loaded(setup), + PreparedAssetReadStatus.Corrupt => + PreparedCollisionReadResult.Corrupt, + _ => PreparedCollisionReadResult.Missing, + }; + } + + public PreparedCollisionReadResult + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() + { + } + } + + private sealed class Fixture : IDisposable + { + internal Fixture( + bool residentWorld, + uint setupTableId = 0u, + uint rawState = (uint)(PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions), + bool isLocalPlayer = false) + { + if (residentWorld) + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + Lifetime = new RuntimeEntityObjectLifetime(engine); + } + else + { + Lifetime = new RuntimeEntityObjectLifetime(); + } + var generation = new RuntimeGenerationToken(1UL); + Lifetime.BindEventContext(() => generation, static () => 1UL); + + // The SAME conductor instance RuntimeEntityObjectLifetime itself + // constructs and wires into the residence's multicast retirement + // fan-out and BeginSessionClear — never a standalone copy — so + // these tests exercise the real production wiring. + Conductor = Lifetime.RemoteFirstEntry; + + Record = Lifetime.RegisterEntityWithInitialResidence( + Spawn( + 0x70090002u, + incarnation: 1, + setupTableId: setupTableId, + rawState: rawState), + isLocalPlayer).Canonical!; + Assert.True(Lifetime.TryGetInitialCreateResidence( + Record, out RuntimeInitialCreateResidenceLease lease)); + Lease = lease; + + CollisionSource = new FakeCollisionSource( + setupTableId, + new FlatSetupCollision( + ImmutableArray.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + } + + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal RuntimeRemoteFirstEntryState Conductor { get; } + internal RuntimeEntityRecord Record { get; set; } + internal RuntimeInitialCreateResidenceLease Lease { get; set; } + internal FakeCollisionSource CollisionSource { get; } + + internal RuntimeEntityKey Key => Record.Key!.Value; + + internal RuntimeRemoteFirstEntryStatus Advance( + out RuntimeInitialCreateExecutionReceipt receipt) => + Advance(out receipt, out _); + + internal RuntimeRemoteFirstEntryStatus Advance( + out RuntimeInitialCreateExecutionReceipt receipt, + out RuntimeRemoteBodyConstructionReceipt construction) => + Conductor.Advance( + Record, + Lease.Token, + CollisionSource, + gameTime: 10d, + NoContact, + out receipt, + out construction); + + public void Dispose() => Lifetime.Dispose(); + } +} From 78f1eb189612bf0303ee54b41dbe813648aa3af0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 10:21:05 +0200 Subject: [PATCH 61/73] docs(physics): record cutover slice C3b completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C3b landed at 0934a121 with dual review PASS. The plan records the remote-entry mechanism and its verified retail anchors; the float-gates doc gains the port note pinning the NaN dispositions (friction's sanctioned skip; elasticity and translucency routed exactly as the binary; ACE's elasticity NaN divergence recorded). Every dormant C3 prerequisite is now complete — C3c, the host flip with the connected gates, is the sole remaining piece of C3. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-02-placement-cutover.md | 14 +++++++++++--- .../2026-08-02-set-description-float-gates.md | 10 ++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 6e17aefb..b992bb71 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -151,9 +151,17 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. activation commit onward (abandonment leaves it to ordinary entity teardown — retail has no entry-flow rollback); EvaluateActivation's post-commit DeferredCell overload is encapsulated behind Advance. - - **C3b — remote body construction at Create (dormant):** - retail-anchored body construction from the wire PhysicsSpawnData per - `set_description` order for residence-route remote/creature Creates. + - **C3b — remote body construction at Create — COMPLETE at `0934a121` + (2026-08-02, dual reviews PASS).** `RuntimeRemoteBodyDescription` + + `RuntimeRemoteFirstEntryState`: the full `set_description` order with + the byte-certain gates (friction [0,1] inclusive, NaN sanctioned-skip; + elasticity clamp with retail's unordered-to-zero; translucency + != 0.0f), the movement-branch discriminator on retail's + `movement_buffer != 0` (empty-buffer → placement branch, no autonomy), + motion-table zero-id pass, ctor-defaults for absent wire fields, and + never-clobber coexistence with the build-at-first-motion production + path. The acknowledge discriminator is one shared body + (`RuntimeFirstEntryAcknowledgement`) for both conductors. Dormant. - **C3c — the host flips (production):** both hosts onto the complete machinery; seal the Controller setter; presentation-only rebucketing; the connected lifecycle/reconnect + nine-stop gates diff --git a/docs/research/2026-08-02-set-description-float-gates.md b/docs/research/2026-08-02-set-description-float-gates.md index 3fa4c8a5..fe9c56f6 100644 --- a/docs/research/2026-08-02-set-description-float-gates.md +++ b/docs/research/2026-08-02-set-description-float-gates.md @@ -219,3 +219,13 @@ false"), which is a documented quirk of this exact MSVC x87 codegen pattern and not something the retail struct's `float` fields would ever hit in practice (friction/translucency are authored data, never NaN). + +## Port note (C3b, `RuntimeRemoteBodyDescription.cs`) + +acdream's friction port uses `f >= 0.0f && f <= 1.0f`, which deliberately +SKIPS NaN rather than reproducing the unordered-goes-to-apply codegen quirk +tabled above — the sanctioned modern-boundary deviation this doc +pre-declared. Elasticity's setter port (`!(e >= 0f)` first arm) and +translucency's `!= 0.0f` gate both route NaN exactly as the binary does +(0f and apply respectively); ACE's `set_elasticity` sends NaN to 0.1f and +is divergent from the binary on that edge. From 529e0e9d8862455942cfcc80f8856d059498cf6e Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 18:10:33 +0200 Subject: [PATCH 62/73] feat(runtime): C3c - production placement cutover: both hosts on the residence conductors (routes 1+8) Campaign P remaining-physics-divergence, placement cutover slice C3c (docs/plans/2026-08-02-placement-cutover.md). Both production hosts now register every initial Create through the residence + continuation- executor + first-entry-conductor machinery (C0-C3b): - Graphical (route 1): RegisterEntityWithInitialResidence at Create; the shared RuntimeFirstEntryDriveController pumps both conductors from the placement-receipt flow; MaterializeProjection and RebucketLiveEntity are presentation-only while a residence is ACTIVE (ExecutorCompleted is the presentation-binding receipt); post-residence entities take the full legacy path including the prepare_to_enter_world clock edges. PlayerModeController attaches presentation to the Runtime-published controller; its legacy resolve/step-heights/host-construction path is deleted; presentation-only rollback (retail has no entry-flow rollback). - Headless (route 8): OnSpawned registers with residence when a drive exists; content-less sessions keep the pre-flip direct registration; SynchronizeLocalPlayer/CreateController/ApplySetupStepHeights deleted; prepared-collision read failure is a typed AwaitingCollisionSource retry; far remotes outside the service window complete celless. - RuntimeLocalPlayerMovementState.Controller setter sealed internal; all controller mutation flows through the publication lifecycle. Fix slices landed within this cutover, each dual-gated: - F1: live movement-stat/server-physics application routed through the Runtime ownership seam (post-logout ingest crash on the retired controller eliminated; RuntimeMovementSkillProjection deleted). - F2: login activation wedge - collision-admission prefix gate factored out of the seal (reentrant-commit RejectedAuthority), rearm generation identity corrected, PlayerModeAutoEntry requires the Runtime-published controller (world reveal can no longer seal unmaterialized). - F3: landblock-prefix 0-sentinel replaced by explicit absent-id guards; map-corner landblocks (grid row/col 0) fully legal through admission, park/rearm/retire, quiescence, and outdoor shadow seeds. - F5: local-player first-entry ground contact seeded by the shared SpawnPlacementSettler (moved App->Core) at FinalizeActivation - the retail first-gravity-frame touch (enter_world 0x00516170 carries no seed); the legacy unconditional force-seed is overwritten by a real floor-found contact; airborne spawns stay airborne; outbound contact bit verified end-to-end. Fixes the standing-cast 'You can't do that while in the air!' rejections. - R1 (dual-review round): login constraint leash armed at the committed placement (HandleReceivedPosition 0x00453FD0 analog); register rows AD-61 (settle-timing compression now covering the local player) and AD-42 (repointed off the deleted resolve split) in this commit; residence-conversion owner API; wire-landblock guards; drive-pending ledger in IsConverged; route attach/detach latch; executor-drain drift model documented + source-pinned. Gates: Runtime 1,003, App 4,039/3 skips, Headless 79, complete solution 10,816/0 failed/4 skips (Release, -m:1); connected lifecycle/reconnect gate PASS (logs/connected-world-gate-20260802-175401; graceful exits, world-visible, zero airborne rejections). The nine-stop soak remains red for the pre-existing 6b28ff99 whole-world collision-clone throughput regression (attributed with evidence; scheduled as its own slice before C5). Dual Opus reviews (retail-conformance + adversarial): delta PASS. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 5 +- .../Composition/SessionPlayerComposition.cs | 79 +- src/AcDream.App/Input/PlayerModeAutoEntry.cs | 20 +- src/AcDream.App/Input/PlayerModeController.cs | 334 ++------ .../Net/GraphicalSessionEventRoute.cs | 34 +- .../Net/LiveMovementStatsApplier.cs | 86 ++ .../Net/LiveSessionRuntimeFactory.cs | 70 +- .../LiveEntityNetworkUpdateController.cs | 17 +- .../DatLiveEntityProjectionMaterializer.cs | 35 +- .../EquippedChildRenderController.cs | 16 + .../World/LiveEntityHydrationController.cs | 27 +- src/AcDream.App/World/LiveEntityRuntime.cs | 233 +++++- .../World/RuntimePlacementPresentationSink.cs | 72 +- .../Physics/ShadowObjectRegistry.cs | 18 +- .../Physics/SpawnPlacementSettler.cs} | 22 +- .../HeadlessRuntimePlacementProjectionSink.cs | 40 +- .../Hosting/HeadlessSessionEventRoute.cs | 15 +- .../Hosting/HeadlessSessionHost.cs | 45 +- .../Hosting/HeadlessSessionWorldProjection.cs | 171 ++-- .../Entities/RuntimeEntityObjectLifetime.cs | 86 +- .../RuntimeInitialCreateResidenceState.cs | 46 ++ .../Entities/RuntimeRemoteFirstEntryState.cs | 12 +- src/AcDream.Runtime/GameRuntime.cs | 7 + .../Gameplay/PlayerMovementController.cs | 168 +++- .../RuntimeLocalPlayerFirstEntryState.cs | 12 +- .../RuntimeLocalPlayerMovementState.cs | 110 ++- ...ntimeLocalPlayerPhysicsPublicationState.cs | 129 ++- .../RuntimeMovementSkillProjection.cs | 41 - ...imeAuthoritativePositionRouteClassifier.cs | 33 + .../Physics/RuntimePhysicsState.cs | 47 +- .../Physics/RuntimeSetPositionState.cs | 99 ++- .../RuntimeFirstEntryDriveController.cs | 354 +++++++++ .../RuntimeLiveEntitySessionController.cs | 24 +- .../Input/C3cF2AutoEntryWiringTests.cs | 59 ++ .../LiveEntityRuntimeFixture.cs | 192 ++++- .../LiveEntitySpawnFixture.cs | 53 ++ .../Net/LiveMovementStatsApplierTests.cs | 204 +++++ .../Net/LiveSessionResetPlanTests.cs | 7 +- .../Physics/C3cF1ProductionWiringTests.cs | 53 ++ .../Physics/Issue270ProductionWiringTests.cs | 35 +- .../LiveEntityInboundAuthorityGateTests.cs | 7 +- .../Physics/RemotePhysicsUpdaterTests.cs | 5 +- .../EquippedChildProjectionWithdrawalTests.cs | 183 ++++- .../Rendering/LiveAppearanceAnimationTests.cs | 6 +- ...veEntityCreateSupersessionRecoveryTests.cs | 21 +- .../Vfx/LiveEntityLightControllerTests.cs | 2 +- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 164 +--- .../LocalPlayerTeleportControllerTests.cs | 4 +- .../StreamingFrameControllerTests.cs | 2 +- .../World/C3cR1F3DriftModelSourcePinTests.cs | 74 ++ ...iveEntityRuntimeComponentLifecycleTests.cs | 4 +- .../LiveEntityHydrationControllerTests.cs | 361 ++++++++- .../World/LiveEntityLifecycleStressTests.cs | 5 + .../LiveEntityPhysicsHostOwnershipTests.cs | 35 +- .../LiveEntityPresentationControllerTests.cs | 5 +- ...tityProjectionWithdrawalControllerTests.cs | 3 + .../World/LiveEntityRuntimeTests.cs | 215 ++++- .../RuntimeFirstEntryHostIntegrationTests.cs | 743 ++++++++++++++++++ .../RuntimePlacementPresentationSinkTests.cs | 10 + .../World/UpdateFrameOrchestratorTests.cs | 18 +- .../Physics/ShadowObjectRegistryTests.cs | 23 + .../Physics/SpawnPlacementSettlerTests.cs} | 15 +- .../HeadlessSessionHostTests.cs | 368 ++++++++- .../RuntimeLocalPlayerFirstEntryStateTests.cs | 91 ++- .../RuntimeLocalPlayerMovementStateTests.cs | 257 ++++++ ...LocalPlayerPhysicsPublicationStateTests.cs | 366 ++++++++- ...onPrefixQuiescenceTests.CornerLandblock.cs | 356 +++++++++ ...RuntimeLiveEntitySessionControllerTests.cs | 355 ++++++++- 68 files changed, 5977 insertions(+), 831 deletions(-) create mode 100644 src/AcDream.App/Net/LiveMovementStatsApplier.cs rename src/{AcDream.App/Physics/RemoteSpawnPlacementSettler.cs => AcDream.Core/Physics/SpawnPlacementSettler.cs} (61%) delete mode 100644 src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs create mode 100644 src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs create mode 100644 tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs create mode 100644 tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/C3cF1ProductionWiringTests.cs create mode 100644 tests/AcDream.App.Tests/World/C3cR1F3DriftModelSourcePinTests.cs create mode 100644 tests/AcDream.App.Tests/World/RuntimeFirstEntryHostIntegrationTests.cs rename tests/{AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs => AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs} (86%) create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.CornerLandblock.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 35d2cf94..f73acf81 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 46 active rows (AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 47 active rows (AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-42 refreshed same round — its cited App-side login resolve split was deleted by the C3c flip, the split survives only on the unflipped remote-teleport/headless portal-resync paths; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -138,13 +138,14 @@ readiness/requeue adaptation. See | AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 | | AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) | | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses the WHOLE SetPositionInternal-shaped commit (contact/walkable flags, HitGround/LeaveGround, `handle_all_collisions`, `cached_velocity`) on a no-move frame — narrowed 2026-07-30 (#265 bounce rework) from "only handle_all_collisions"; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) | -| AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | +| AD-42 | **Refreshed 2026-08-02 (C3c review round 1).** The two-call enter-world placement split (legacy `Resolve` = retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` = the verbatim object-aware `find_placement_pos` ring search) survives ONLY on the unflipped portal-arrival paths: the remote-teleport controller and the headless portal-arrival resync. The LOCAL login first-entry no longer uses it — the C3c flip routes it through the single canonical Runtime SetPosition transaction (the faithful placement family), retiring the row's original `GameWindow.EnterPlayerModeNow` citation. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Physics/RemoteTeleportController.cs` (`ResolvePlacement`); `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` (`ResynchronizeLocalPlayerForPortalArrival`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. The surviving split paths are C4's portal-route flip scope | A teleport arrival that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | | AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 | | AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | | AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` | | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | | AD-59 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The `SameIncarnationCreate` envelope buffers one publish per committed stage and flushes them ALL, in stage order, only after the LAST stage commits (constant-true per-field predicate, `IsCurrent`-checked at flush - the per-field closure variant was invalidated by WeenieDescription's six-field `AdvanceCreateAuthority`). A subscriber sees N back-to-back events with no interleaved observation point, each carrying the FINAL merged post-envelope record state, not per-stage state. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyEnvelope` buffered-publish tail; `Publish`/`PublishNow`) | Retail's own tail is one synchronous critical section, and retail emits ONE notice per Create (`ECM_Physics::SendNotice_CreateObject`, fired whenever a weenie exists, independent of the physics-registration outcome) - never N per-internal-step notices. The buffered flush is closer to retail's one-signal model than per-step publication would be, though not a literal 1:1 match. | A subscriber diffing consecutive `Updated` events from the SAME envelope to isolate one stage's delta gets every stage's cumulative state on each event - silently wrong incremental-diff logic, not a crash. | `SmartBox::HandleCreateObject` 0x00454C80 same-incarnation tail (one synchronous critical section); `ACCObjectMaint::CreateObject` 0x00558870 step 11 (`ECM_Physics::SendNotice_CreateObject`) | +| AD-61 | **Filed 2026-08-02 (C3c review round 1).** The #270 settle-timing compression now covers the LOCAL player: `RuntimeLocalPlayerPhysicsPublicationState.SettleFirstEntryGroundContact` runs the shared `SpawnPlacementSettler` exactly once after the dormant activation's final commit (suffix-current authority only), compressing retail's first post-`enter_world` gravity frame — which grants CONTACT/ON_WALKABLE from a real touch — into the placement transaction. The legacy App-era force-seed (`Contact\|OnWalkable\|Active` in `PlayerMovementController.SetPositionCore`) still RUNS during publication-candidate preparation and is then OVERWRITTEN by the faithful activation commit + settle (it was never deleted). Caveat (review minor M2): the settler commits `settle.Position` but discards `settle.CellId` — a settle whose few-cm sweep crosses a cell boundary keeps the placement cell until the next resolve corrects it (inherited #270 semantics; ISSUES entry filed) | `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs` (`SettleFirstEntryGroundContact`); `src/AcDream.Core/Physics/SpawnPlacementSettler.cs` (`TrySettle`); overwritten seed `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`SetPositionCore`) | Timing compression only: contact comes exclusively from the sweep's real touch (no caller-bool seeding, no forced transients), an airborne spawn stays genuinely airborne, and the overwritten force-seed leaves no observable residue past the activation commit — the committed state is exactly what retail's first gravity frame produces | A settle crossing a cell boundary reports the stale placement cell for the frames before the next resolve; a future reader trusting `SetPositionCore`'s "treat as grounded" seed comment could reintroduce the Contact-without-plane state the landing family calls unrepresentable | `CPhysicsObj::enter_world` 0x00516170; `SmartBox::HandleCreateObject` 0x00454C80 | | AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** Executor Position-continuation merges never directly commit residency: `ApplyPositionAction` refreshes `canonical.Snapshot.Position` with the retained wire pose but withholds the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`); only a Runtime `SetPosition` commit (the continuation's own classified placement) or a later simulation full-cell commit may change residency. The LEGACY immediate-apply path's `RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)` (`RuntimeEntityObjectLifetime.cs:1338`) still derives `FullCellId` from bare wire acceptance - that coarser rule is part of the AP-1 divergence this campaign is removing, not something this row blesses. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment) | Matches retail exactly: `HandleReceivedPosition` never writes a resident cell - `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. | If a future change passes `refreshPosition: true` here, a wire Position would make a cellless canonical body resident without any placement/collision commit - the classic AP-1-shaped bug this campaign exists to close. | `SmartBox::HandleReceivedPosition` 0x00453FD0; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | --- diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 2702f7b9..84d04dca 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -500,6 +500,79 @@ internal sealed class SessionPlayerCompositionPhase d.PlayerIdentity, dormantLiveEntities, d.Options.DumpLiveSpawns ? d.Log : null); + // C3c: the graphical first-entry drive controller — walks every + // initial-Create residence through its Runtime conductor with the + // production prepared-collision source, the live movement-skill + // options, and the truthful local-player activation preparation + // (authored-cylinder radius/height with the legacy fallbacks, and + // the shadow disposition read from the exact shadow registry the + // activation commit validates against). + IPreparedCollisionSource firstEntryCollision = + content.PreparedAssets as IPreparedCollisionSource + ?? throw new NotSupportedException( + "Production prepared assets must expose the matching " + + "prepared-collision catalog."); + // C3c-R1 review F9: the provider runs on EVERY drive pump while the + // entity's conductor is still yielding, and the Setup cylinder per + // incarnation is immutable (SourceGfxObjOrSetupId and the spawn + // record's ObjScale are fixed at Create) — cache the resolved + // world-entity lookup + GetSetupCylinder per pending incarnation + // (keyed by the incarnation-unique local id; an unresolved/default + // result is NOT cached so late hydration still upgrades it). The + // shadow disposition deliberately stays live: shadow registration + // can land between pumps and the activation commit validates the + // disposition against the exact registry. + uint firstEntryCylinderLocalId = 0u; + float firstEntryCylinderRadius = 0f; + float firstEntryCylinderHeight = 0f; + var firstEntryDrive = new RuntimeFirstEntryDriveController( + d.EntityObjects, + d.Runtime.Clock, + firstEntryCollision, + () => PlayerMovementConstructionOptions.From( + d.Runtime.CharacterOwner.MovementSkills.Snapshot), + record => + { + float radius = 0.48f; + float height = 1.835f; + uint localId = record.Key?.LocalEntityId ?? 0u; + if (localId != 0u && localId == firstEntryCylinderLocalId) + { + radius = firstEntryCylinderRadius; + height = firstEntryCylinderHeight; + } + else if (live.LiveEntities.TryGetWorldEntity( + record.ServerGuid, + out WorldEntity? playerEntity) + && playerEntity is not null) + { + (float setupRadius, float setupHeight) = + d.MotionBindings.GetSetupCylinder( + record.ServerGuid, + playerEntity); + if (setupRadius >= 0.05f) + { + radius = setupRadius; + height = setupHeight; + if (localId != 0u) + { + firstEntryCylinderLocalId = localId; + firstEntryCylinderRadius = radius; + firstEntryCylinderHeight = height; + } + } + } + bool hasAuthoredShadow = record.Key is { } key + && d.PhysicsEngine.ShadowObjects.HasLogicalOwner( + key.LocalEntityId); + return new RuntimeLocalPlayerPhysicsActivationPreparation( + radius, + height, + hasAuthoredShadow + ? RuntimeLocalPlayerShadowDisposition + .RegisteredAuthoredPayload + : RuntimeLocalPlayerShadowDisposition.ProvenShapeless); + }); var hydration = new LiveEntityHydrationController( live.LiveEntities, d.EntityObjects, @@ -517,7 +590,8 @@ internal sealed class SessionPlayerCompositionPhase d.PlayerIdentity, deletion, dormantLiveEntities, - d.Options.DumpLiveSpawns ? d.Log : null); + d.Options.DumpLiveSpawns ? d.Log : null, + firstEntryDrive); bindings.Adopt( "landblock-loaded hydration", live.LandblockLoaded.Bind(hydration)); @@ -884,7 +958,8 @@ internal sealed class SessionPlayerCompositionPhase d.RemoteMovementObservations, live.RenderSceneShadow, live.PlacementProjection, - placementProjectionRetry), + placementProjectionRetry, + firstEntryDrive), liveSessionCommands, d.Log); LiveSessionHost sessionHost = sessionRuntimeFactory.Create( diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs index cc158601..f75e80f0 100644 --- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs +++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs @@ -2,6 +2,7 @@ using System; using AcDream.App.Net; using AcDream.App.Streaming; using AcDream.App.World; +using AcDream.Runtime.Physics; namespace AcDream.App.Input; @@ -83,7 +84,24 @@ internal sealed class LivePlayerModeAutoEntryContext public bool IsPlayerEntityPresent => _liveEntities.ContainsWorldEntity(_identity.ServerGuid); - public bool IsPlayerControllerReady => true; + /// + /// C3c-F2: post-flip the movement controller is Runtime-owned, so this + /// precondition has to report the Runtime first-entry conductor's commit + /// — exactly what PlayerModeController.TryEnter requires. It was + /// the constant true, which was harmless only while entry itself + /// CONSTRUCTED the controller and therefore could not fail on it. After + /// the flip a not-yet-committed conductor made entry return false, and + /// because this guard is a one-shot that disarms before invoking (and + /// completes the world reveal + /// unconditionally), a single early attempt permanently sealed the reveal + /// with the player never in world. + /// + public bool IsPlayerControllerReady => + _playerMode.Controller is { IsRuntimePublished: true } + && _liveEntities.TryGetRecord( + _identity.ServerGuid, + out LiveEntityRecord record) + && record.PhysicsHost is EntityPhysicsHost; public bool IsWorldReady => _liveEntities.TryGetSnapshot( diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index 732684d7..0080c8ba 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -164,7 +164,9 @@ internal sealed class PlayerModeController : try { RetireApproachLifetime(); } catch (Exception error) { failures.Add(error); } _mode.IsPlayerMode = false; - _controllerSlot.Controller = null; + // C3c: the movement controller is Runtime-owned — player-mode exit + // detaches presentation only; the publication lifecycle (generation + // reset/teardown) owns the controller's retirement. _hostSlot.Host = null; _chase.Legacy = null; _chase.Retail = null; @@ -201,7 +203,9 @@ internal sealed class PlayerModeController : try { RetireApproachLifetime(); } catch (Exception error) { failures.Add(error); } _mode.ResetSession(); - _controllerSlot.Controller = null; + // C3c: the Runtime generation reset retires the controller through + // RuntimeLocalPlayerMovementState.ResetSession; App detaches + // presentation only. _hostSlot.Host = null; _chase.Legacy = null; _chase.Retail = null; @@ -233,6 +237,19 @@ internal sealed class PlayerModeController : return false; } + // C3c: player mode attaches presentation to the Runtime-published + // controller/host; until the first-entry conductor commits them, + // entry simply retries on a later frame. + if (_controllerSlot.Controller is not { } publishedController + || !publishedController.IsRuntimePublished + || playerRecord.PhysicsHost is not EntityPhysicsHost) + { + Console.WriteLine( + $"live: {loggingTag} — Runtime first-entry controller for " + + $"0x{playerGuid:X8} not committed yet"); + return false; + } + BuildControllerAndCamera( loggingTag, playerGuid, @@ -247,6 +264,32 @@ internal sealed class PlayerModeController : WorldEntity playerEntity, LiveEntityRecord playerRecord) { + // C3c route-1 flip: the movement controller, physics body, host, and + // committed placement are Runtime-owned — constructed and activated + // by the first-entry conductor's publication chain before player + // mode can enter. This method attaches only App presentation + // (approach lifetime, animation bindings, camera, shadow, host + // slot). A failure here rolls back camera/shadow ONLY and never + // touches Runtime. C3c-R1 review F8: auto-entry does NOT retry a + // throw from this attach — PlayerModeAutoEntry.TryEnter disarms its + // one-shot BEFORE invoking EnterPlayerMode, so an exception here + // burns the shot; recovery is the manual Tab entry (or a session + // reset re-arming the trigger). + if (_controllerSlot.Controller is not { } controller + || !controller.IsRuntimePublished) + { + throw new InvalidOperationException( + $"Player mode ({loggingTag}) requires the Runtime-published " + + "local movement controller; the first-entry conductor has " + + "not committed it yet."); + } + if (playerRecord.PhysicsHost is not EntityPhysicsHost playerHost) + { + throw new InvalidOperationException( + $"Player mode ({loggingTag}) requires the Runtime-committed " + + "local physics host."); + } + IPlayerApproachCompletionSink approachLifetime = _approachCompletions.BeginControllerLifetime(); bool lifetimeCommitted = false; @@ -256,46 +299,11 @@ internal sealed class PlayerModeController : LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture(); try { - var controller = new PlayerMovementController( - _physics, - playerRecord.ObjectClock, - PlayerMovementConstructionOptions.From(_skills.Snapshot)); - controller.ApplyPhysicsState(playerRecord.FinalPhysicsState); - - // Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one - // MoveToManager facade over the local CPhysicsObj seams. - PlayerMovementController capturedController = controller; - EntityPhysicsHost playerHost = null!; - controller.Movement.MoveToFactory = () => + // Approach-completion presentation rides the Runtime-owned + // MoveToManager (created by the publication chain's own + // MakeMoveToManager). + if (controller.MoveTo is { } moveTo) { - var moveTo = new MoveToManager( - capturedController.Motion, - stopCompletely: () => - capturedController.StopCompletelyAtPhysicsObjectBoundary(), - getPosition: () => new Position( - capturedController.CellId, - capturedController.Position, - capturedController.BodyOrientation), - getHeading: () => MoveToMath.HeadingFromYaw(capturedController.Yaw), - setHeading: (heading, _) => capturedController.Yaw = - MoveToMath.YawFromHeading(heading), - getOwnRadius: () => _motionBindings.GetSetupCylinder( - playerGuid, - playerEntity).Radius, - getOwnHeight: () => _motionBindings.GetSetupCylinder( - playerGuid, - playerEntity).Height, - contact: () => capturedController.BodyInContact, - isInterpolating: () => false, - getVelocity: () => capturedController.BodyVelocity, - getSelfId: () => playerGuid, - setTarget: (context, target, radius, quantum) => - playerHost.SetTarget(context, target, radius, quantum), - clearTarget: playerHost.ClearTarget, - getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(), - setTargetQuantum: playerHost.TargetManager.SetTargetQuantum, - curTime: () => capturedController.SimTimeSeconds); - moveTo.MoveToComplete = error => { if (PhysicsDiagnostics.ProbeAutoWalkEnabled) @@ -307,75 +315,8 @@ internal sealed class PlayerModeController : }; moveTo.MoveToCancelled = error => approachLifetime.PublishCancellation(error); - moveTo.StickTo = (target, radius, height) => - playerHost.PositionManager.StickTo(target, radius, height); - moveTo.Unstick = () => playerHost.PositionManager.UnStick(); - return moveTo; - }; - - MovementManager exactMovement = controller.Movement; - var configuredHost = new EntityPhysicsHost( - playerGuid, - getPosition: () => new Position( - playerRecord.FullCellId, - playerRecord.WorldEntity?.Position ?? capturedController.Position, - capturedController.BodyOrientation), - getVelocity: () => capturedController.BodyVelocity, - getRadius: () => _motionBindings.GetSetupCylinder( - playerGuid, - playerEntity).Radius, - inContact: () => capturedController.BodyInContact, - minterpMaxSpeed: () => capturedController.Motion.GetAdjustedMaxSpeed(), - curTime: () => capturedController.SimTimeSeconds, - physicsTimerTime: () => capturedController.SimTimeSeconds, - getObjectA: _motionBindings.ResolvePhysicsHost, - handleUpdateTarget: info => - { - if (PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - Console.WriteLine( - $"[autowalk-target] object=0x{info.ObjectId:X8} " - + $"status={info.Status} context={info.ContextId} " - + $"target=({info.TargetPosition.Frame.Origin.X:F2}," - + $"{info.TargetPosition.Frame.Origin.Y:F2}," - + $"{info.TargetPosition.Frame.Origin.Z:F2})"); - } - exactMovement.HandleUpdateTarget(info); - }, - interruptCurrentMovement: () => exactMovement.CancelMoveTo( - WeenieError.ActionCancelled)); - playerHost = EntityPhysicsHostComposition.SelectStableHostWithoutRebind( - _liveEntities, - playerRecord, - configuredHost); - - exactMovement.MakeMoveToManager(); - controller.Motion.UnstickFromObject = () => - playerHost.PositionManager.UnStick(); - controller.PositionManager = playerHost.PositionManager; - controller.Motion.InterruptCurrentMovement = () => - { - if (PhysicsDiagnostics.ProbeAutoWalkEnabled - && exactMovement.IsMovingTo()) - { - Console.WriteLine("[autowalk-end] reason=interrupt"); - } - exactMovement.CancelMoveTo(WeenieError.ActionCancelled); - }; - - if (RuntimeMovementSkillProjection.ApplyTo( - _skills, - controller)) - { - Console.WriteLine( - $"live: {loggingTag} — applied server skills " - + $"run={_skills.RunSkill} jump={_skills.JumpSkill}"); } - ApplyStepHeights(controller, playerEntity, playerGuid); - uint initialCellId = ResolveInitialCell(playerGuid, playerEntity); - - Action? drainPriorAnimationQueue = null; if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation) && animation.Sequencer is { } sequencer) { @@ -392,51 +333,9 @@ internal sealed class PlayerModeController : sequencer.Manager.CheckForCompletedMotions; controller.Motion.DefaultSink = new MotionTableDispatchSink(sequencer); - drainPriorAnimationQueue = sequencer.Manager.HandleEnterWorld; + sequencer.Manager.HandleEnterWorld(); } - // Retail CPhysicsObj owns CMotionInterp and CPartArray throughout - // construction. Our split owners preserve that lifetime with a - // narrow preparation lease: SetPosition's synchronous type-5 - // completion reaches this candidate MotionInterpreter, while the - // public controller slot remains unpublished until every other - // player-mode edge has prepared successfully. - using IDisposable motionPreparation = - _controllerSlot.BeginMotionPreparation( - controller, - drainPriorAnimationQueue); - - ResolveResult initial = _physics.Resolve( - playerEntity.Position, - initialCellId, - Vector3.Zero, - 100f); - var (placementRadius, placementHeight) = - _motionBindings.GetSetupCylinder(playerGuid, playerEntity); - if (placementRadius < 0.05f) - { - placementRadius = 0.48f; - placementHeight = 1.835f; - } - - ResolveResult placement = _physics.ResolvePlacement( - initial.Position, - initial.CellId, - placementRadius, - placementHeight, - controller.StepUpHeight, - controller.StepDownHeight, - ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, - playerEntity.Id); - if (placement.Ok) - initial = placement; - - controller.PreparePositionForCommit( - initial.Position, - initial.CellId, - CellLocalForSeed(initial.Position, initial.CellId)); - controller.SetBodyOrientation(playerEntity.Rotation); - var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect }; var retailCamera = new RetailChaseCamera { @@ -446,44 +345,15 @@ internal sealed class PlayerModeController : cameraAttempted = true; _camera.EnterChaseMode(legacyCamera, retailCamera); - EntityPhysicsHost stableAfterCamera = - EntityPhysicsHostComposition.SelectStableHostWithoutRebind( - _liveEntities, - playerRecord, - configuredHost); - if (!ReferenceEquals(stableAfterCamera, playerHost)) - { - throw new InvalidOperationException( - "The local physics host changed during chase-camera activation."); - } - shadowAttempted = true; _shadow.SyncPose( playerEntity, - initial.Position, + controller.Position, playerEntity.Rotation, - initial.CellId, + controller.CellId, force: true); - // Publish the incarnation-stable CPhysicsObj delegates only after all - // DAT, placement, shadow, and camera preparation has succeeded. A - // late preparation failure therefore cannot expose an abandoned - // controller through LiveEntityRecord.PhysicsHost. - EntityPhysicsHost publishedHost = EntityPhysicsHostComposition.InstallOrRebind( - _liveEntities, - playerRecord, - configuredHost); - if (!ReferenceEquals(publishedHost, playerHost)) - { - throw new InvalidOperationException( - "The local physics host changed between preparation and commit."); - } - - playerEntity.SetPosition(initial.Position); - playerEntity.ParentCellId = initial.CellId; - controller.CommitPreparedPosition(); - _hostSlot.Host = publishedHost; - _controllerSlot.Controller = controller; + _hostSlot.Host = playerHost; _chase.Legacy = legacyCamera; _chase.Retail = retailCamera; _mode.IsPlayerMode = true; @@ -505,8 +375,16 @@ internal sealed class PlayerModeController : catch (Exception cleanupError) { failures.Add(cleanupError); } } + // C3c: presentation-only rollback. The Runtime-published + // controller/body/host stay live — retail has no entry-flow + // rollback. C3c-R1 review F8: this rethrow is NOT retried by + // auto-entry — the one-shot trigger disarms before invoking + // (PlayerModeAutoEntry.TryEnter), and the throw also propagates + // out of the auto-entry context before its world-reveal + // Complete() call. After a failed attach the player re-enters + // via the manual Tab path (or a session reset re-arms the + // trigger). _mode.IsPlayerMode = false; - _controllerSlot.Controller = null; _hostSlot.Host = null; _chase.Legacy = null; _chase.Retail = null; @@ -532,92 +410,4 @@ internal sealed class PlayerModeController : _approachCompletions.RetireControllerLifetime(lifetime); } - private void ApplyStepHeights( - PlayerMovementController controller, - WorldEntity playerEntity, - uint playerGuid) - { - if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) - { - DatReaderWriter.DBObjs.Setup? setup; - lock (_datLock) - setup = _dats.Get( - playerEntity.SourceGfxObjOrSetupId); - if (setup is not null) - _collisionAssets.CacheSetup( - playerEntity.SourceGfxObjOrSetupId, - setup); - // TS-46 (2026-07-30): CPartArray::GetStepUpHeight/GetStepDownHeight - // (0x005180d0/0x005180f0) return setup->step_up_height * this->scale - // — apply the same ObjScale multiply the remote/ordinary paths now - // use (LiveEntityMotionRuntimeController.GetSetupMoverShape), for - // parity on a non-1.0-scale player (a rare but real case — e.g. a - // disguise/size-changing effect). Human ObjScale is 1.0 in the - // overwhelming common case, so this is a no-op there. - float scale = - _liveEntities.Snapshots.TryGetValue(playerGuid, out var sp) - && sp.ObjScale is { } objScale && objScale > 0f - ? objScale - : (playerEntity.Scale > 0f ? playerEntity.Scale : 1f); - controller.StepUpHeight = setup is { StepUpHeight: > 0f } - ? setup.StepUpHeight * scale - : 0.4f; - controller.StepDownHeight = setup is { StepDownHeight: > 0f } - ? setup.StepDownHeight * scale - : 0.4f; - // TS-46 (2026-07-30): the Setup's own ≤2-sphere list, verbatim — - // retail CPhysicsObj::transition (0x00512dc0) seeds the sweep - // from CPartArray::GetSphere, not a (radius, height) capsule - // reconstruction. Empty (no Setup, or a Setup with no sphere - // rows) leaves SphereList at its default empty value, which - // ResolveWithTransition treats as "use the legacy scalar - // reconstruction." - controller.SphereList = setup?.Spheres is { Count: > 0 } spheres - ? spheres - .Select(s => new FlatCollisionSphere(s.Origin, s.Radius)) - .ToImmutableArray() - : ImmutableArray.Empty; - Console.WriteLine( - $"physics: player step heights — StepUp={controller.StepUpHeight:F3} m " - + $"(Setup.StepUpHeight={(setup?.StepUpHeight ?? 0f):F3}), " - + $"StepDown={controller.StepDownHeight:F3} m " - + $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3}), " - + $"Spheres={controller.SphereList.Length}"); - return; - } - - controller.StepUpHeight = 0.4f; - controller.StepDownHeight = 0.4f; - controller.SphereList = ImmutableArray.Empty; - Console.WriteLine( - "physics: player step heights — defaulting to 0.4 m (no setup dat)"); - } - - private uint ResolveInitialCell(uint playerGuid, WorldEntity playerEntity) - { - if (_liveEntities.Snapshots.TryGetValue(playerGuid, out var spawn) - && spawn.Position is { LandblockId: not 0u } position) - { - return position.LandblockId; - } - - int landblockX = _origin.CenterX - + (int)MathF.Floor(playerEntity.Position.X / 192f); - int landblockY = _origin.CenterY - + (int)MathF.Floor(playerEntity.Position.Y / 192f); - return ((uint)landblockX << 24) - | ((uint)landblockY << 16) - | 0x0001u; - } - - private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId) - { - int landblockX = (int)((cellId >> 24) & 0xFFu); - int landblockY = (int)((cellId >> 16) & 0xFFu); - var origin = new Vector3( - (landblockX - _origin.CenterX) * 192f, - (landblockY - _origin.CenterY) * 192f, - 0f); - return worldPosition - origin; - } } diff --git a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs index e2411320..518a2ae8 100644 --- a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs +++ b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs @@ -15,6 +15,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting _createSubscription; private readonly Func _generation; private readonly RuntimePlacementProjectionRetrySlot _retries; + private readonly RuntimeFirstEntryDriveController? _firstEntry; private RuntimePlacementProjectionSubscription? _subscription; private IDisposable? _retryLease; private bool _attachStarted; @@ -25,7 +26,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting ILiveSessionEventRouting events, GameRuntime runtime, IRuntimePlacementProjectionSink placements, - RuntimePlacementProjectionRetrySlot retries) + RuntimePlacementProjectionRetrySlot retries, + RuntimeFirstEntryDriveController? firstEntry = null) : this( events, () => new RuntimePlacementProjectionSubscription( @@ -33,7 +35,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting placements, retryPendingOnSubscribe: false), () => runtime.Generation, - retries) + retries, + firstEntry) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(placements); @@ -43,7 +46,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting ILiveSessionEventRouting events, Func createSubscription, Func generation, - RuntimePlacementProjectionRetrySlot retries) + RuntimePlacementProjectionRetrySlot retries, + RuntimeFirstEntryDriveController? firstEntry = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _createSubscription = createSubscription @@ -51,6 +55,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting _generation = generation ?? throw new ArgumentNullException(nameof(generation)); _retries = retries ?? throw new ArgumentNullException(nameof(retries)); + _firstEntry = firstEntry; } public void Attach() @@ -60,6 +65,10 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting return; _attachStarted = true; + // C3c-R1 review F6: assert (not assume) that the prior route + // detached — session reset precedes a new route — before this route + // takes ownership of the shared drive controller's tracked entries. + _firstEntry?.AttachRoute(this); _events.Attach(); RuntimePlacementProjectionSubscription? subscription = null; @@ -67,9 +76,20 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting try { subscription = _createSubscription(); + RuntimePlacementProjectionSubscription boundSubscription = + subscription; retryLease = _retries.BindOwned( _generation(), - subscription.RetryPending); + // C3c: drive pending first-entry sequences before + // republishing the pending FIFO head — a conductor's own + // Advance is what consumes conductor-owned receipts, and the + // subsequent RetryPending lets the presentation sink apply + // whatever new head the drive surfaced. + () => + { + _firstEntry?.DriveAll(); + return boundSubscription.RetryPending(); + }); _subscription = subscription; _retryLease = retryLease; _ = subscription.RetryPending(); @@ -91,6 +111,12 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting // can therefore never retry a retired generation or disposed route. Interlocked.Exchange(ref _retryLease, null)?.Dispose(); Interlocked.Exchange(ref _subscription, null)?.Dispose(); + // C3c: the drive controller's tracked entries die with this exact + // session route; Runtime's own retirement/session-clear fan-out owns + // conductor/residence convergence independently. C3c-R1 review F6: + // route-scoped — a route that never attached cannot clear a live + // route's entries. + _firstEntry?.DetachRoute(this); if (!_eventsDisposed) { _events.Dispose(); diff --git a/src/AcDream.App/Net/LiveMovementStatsApplier.cs b/src/AcDream.App/Net/LiveMovementStatsApplier.cs new file mode 100644 index 00000000..f1afc1ec --- /dev/null +++ b/src/AcDream.App/Net/LiveMovementStatsApplier.cs @@ -0,0 +1,86 @@ +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.Net; + +/// +/// C3c-F1 (2026-08-02): the App half of the movement-stats application +/// seam. Every server stat recompute (skills, burden, stamina, PK status — +/// the character-bindings OnSkillsUpdated/OnMovementStatsUpdated +/// callbacks) routes through +/// ; +/// App holds no controller reference and performs no direct configuration +/// mutation. A recompute displaced past session teardown (the post-logout +/// inbound-Create ingest chain that crashed the connected lifecycle gate) +/// observes a typed dropped outcome and is logged under the existing +/// player diagnostics instead of faulting the session. +/// +/// +/// Stuck-cast fix (2026-07-30): retail fires +/// CPhysicsObj::report_exhaustion from exactly ONE site — +/// CommandInterpreter::HandleExhaustion (0x006b3c70), a +/// notification-handler vtable slot invoked on the stamina-exhaustion +/// EVENT — not on every vitals refresh. The P1 wiring called +/// ReportExhaustion() on EVERY movement-stats application +/// (every stamina regen/drain tick), and each call re-dispatches the +/// current movement state through the animation sink — truncating any +/// in-flight action animation (cast gestures wedged mid-play; the +/// diagnostic session showed 490 spurious stance re-queues). The +/// re-apply fires only when the exhausted state (stamina == 0) +/// actually TRANSITIONS, matching retail's event semantics. Skill/ +/// burden changes still reach PlayerWeenie immediately through +/// the owner seam — the next natural dispatch picks up the new rates, +/// exactly as retail. The edge is observed for dormant applications too +/// (the event fired; a player not yet in world has no movement to +/// re-dispatch, and activation starts movement from the already-current +/// stamina gate), but dispatched only on a live controller. +/// +internal sealed class LiveMovementStatsApplier( + RuntimeLocalPlayerMovementState movement, + RuntimeMovementSkillState skills, + Action log) +{ + private readonly RuntimeLocalPlayerMovementState _movement = movement + ?? throw new ArgumentNullException(nameof(movement)); + private readonly RuntimeMovementSkillState _skills = skills + ?? throw new ArgumentNullException(nameof(skills)); + private readonly Action _log = log + ?? throw new ArgumentNullException(nameof(log)); + private readonly StaminaExhaustionEdgeTracker _staminaExhaustion = new(); + + /// + /// Forgets the retiring character/session exhaustion baseline; the next + /// generation's first sample must not synthesize an edge. + /// + public void Reset() => _staminaExhaustion.Reset(); + + public RuntimeMovementStatsApplication Apply(string reason) + { + RuntimeMovementStatsApplication outcome = + _movement.ApplyCharacterMovementStats(_skills); + switch (outcome) + { + case RuntimeMovementStatsApplication.DroppedNoController: + case RuntimeMovementStatsApplication.DroppedIncompleteSnapshot: + // Byte-identical to the pre-F1 ApplyTo=false silent skip. + return outcome; + case RuntimeMovementStatsApplication.DroppedDisplacedController: + _log( + $"player: dropped displaced movement {reason} — the " + + "Runtime movement controller is terminal"); + return outcome; + } + + RuntimeMovementSkillSnapshot snapshot = _skills.Snapshot; + if (_staminaExhaustion.Observe(snapshot.CurrentStamina) + && outcome is RuntimeMovementStatsApplication.AppliedLive) + { + _movement.ReportExhaustion(); + } + + _log( + $"player: applied server movement {reason} " + + $"run={snapshot.RunSkill} jump={snapshot.JumpSkill} " + + $"burden={snapshot.Burden:F2} stamina={snapshot.CurrentStamina}"); + return outcome; + } +} diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index a300012e..b9103078 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -85,7 +85,8 @@ internal sealed record LiveSessionWorldRuntime( RemoteMovementObservationTracker RemoteMovementObservations, RenderSceneShadowRuntime? RenderSceneShadow, RuntimePlacementPresentationSink PlacementProjection, - RuntimePlacementProjectionRetrySlot PlacementRetries); + RuntimePlacementProjectionRetrySlot PlacementRetries, + RuntimeFirstEntryDriveController FirstEntryDrive); /// /// Builds the exact per-generation route/reset graph for the canonical live @@ -100,7 +101,7 @@ internal sealed class LiveSessionRuntimeFactory private readonly LiveSessionWorldRuntime _world; private readonly LiveSessionCommandSurface _commands; private readonly Action _log; - private readonly StaminaExhaustionEdgeTracker _staminaExhaustion = new(); + private readonly LiveMovementStatsApplier _movementStats; public LiveSessionRuntimeFactory( LiveSessionPlayerRuntime player, @@ -119,6 +120,12 @@ internal sealed class LiveSessionRuntimeFactory _world = world ?? throw new ArgumentNullException(nameof(world)); _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _log = log ?? throw new ArgumentNullException(nameof(log)); + // C3c-F1: stat recomputes route through the Runtime movement owner's + // typed application seam; App keeps zero direct controller mutations. + _movementStats = new LiveMovementStatsApplier( + _player.Controller, + _domain.Character.MovementSkills, + _log); } public LiveSessionHost Create( @@ -192,7 +199,7 @@ internal sealed class LiveSessionRuntimeFactory private void ResetPlayerPresentation() { - _staminaExhaustion.Reset(); + _movementStats.Reset(); _interaction.PlayerMode.ResetSession(); _world.SpawnClaims.Reset(); } @@ -254,7 +261,8 @@ internal sealed class LiveSessionRuntimeFactory route, _domain.Runtime, _world.PlacementProjection, - _world.PlacementRetries); + _world.PlacementRetries, + _world.FirstEntryDrive); } private LiveInventorySessionBindings CreateInventoryBindings() => new( @@ -284,59 +292,19 @@ internal sealed class LiveSessionRuntimeFactory _domain.Actions.Combat, _domain.Character, ResolveSkillFormulaBonus: skillCreditResolver.Resolve, - OnSkillsUpdated: (runSkill, jumpSkill) => ApplyMovementStats("skills"), + OnSkillsUpdated: (runSkill, jumpSkill) => + _movementStats.Apply("skills"), OnConfirmationRequest: request => _ui.RetailUi?.HandleConfirmationRequest(request), OnConfirmationDone: done => _ui.RetailUi?.HandleConfirmationDone(done), ClientTime: ClientTimerNow, // Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes - // reactively re-apply to the live controller through the SAME - // seam skills already used, then wire the previously-dead - // ReportExhaustion() R3-W4 seam so movement re-evaluates - // immediately (pseudocode doc §8/§9). - OnMovementStatsUpdated: () => ApplyMovementStats("stats")); - } - - /// - /// Re-applies the current - /// snapshot (skills/burden/stamina) to the live player controller. - /// - /// - /// Stuck-cast fix (2026-07-30): retail fires - /// CPhysicsObj::report_exhaustion from exactly ONE site — - /// CommandInterpreter::HandleExhaustion (0x006b3c70), a - /// notification-handler vtable slot invoked on the stamina-exhaustion - /// EVENT — not on every vitals refresh. The P1 wiring called - /// ReportExhaustion() on EVERY movement-stats application - /// (every stamina regen/drain tick), and each call re-dispatches the - /// current movement state through the animation sink — truncating any - /// in-flight action animation (cast gestures wedged mid-play; the - /// diagnostic session showed 490 spurious stance re-queues). The - /// re-apply now fires only when the exhausted state (stamina == 0) - /// actually TRANSITIONS, matching retail's event semantics. Skill/ - /// burden changes still reach immediately - /// via — the next - /// natural dispatch picks up the new rates, exactly as retail. - /// - private void ApplyMovementStats(string reason) - { - PlayerMovementController? controller = _player.Controller.Controller; - if (!RuntimeMovementSkillProjection.ApplyTo( - _domain.Character.MovementSkills, - controller)) - { - return; - } - - RuntimeMovementSkillSnapshot snapshot = _domain.Character.MovementSkills.Snapshot; - if (_staminaExhaustion.Observe(snapshot.CurrentStamina)) - controller!.Motion.ReportExhaustion(); - - _log( - $"player: applied server movement {reason} " - + $"run={snapshot.RunSkill} jump={snapshot.JumpSkill} " - + $"burden={snapshot.Burden:F2} stamina={snapshot.CurrentStamina}"); + // reactively re-apply through the SAME seam skills already used + // (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime + // movement owner's typed application entry — see + // LiveMovementStatsApplier. + OnMovementStatsUpdated: () => _movementStats.Apply("stats")); } private LiveSessionCommandBindings CreateCommandBindings( diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 870e9d47..a2b4cafd 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -190,7 +190,7 @@ internal sealed class LiveEntityNetworkUpdateController // retail's first frame would (position snapped onto the floor, // contact plane + CONTACT/ON_WALKABLE committed below). A sweep that // finds no floor (true airborne spawn) leaves the body airborne. - if (!RemoteSpawnPlacementSettler.TrySettle( + if (!AcDream.Core.Physics.SpawnPlacementSettler.TrySettle( _physicsEngine, remote.Body, worldPos, @@ -1003,7 +1003,20 @@ internal sealed class LiveEntityNetworkUpdateController return; } if (parsed.Guid == _playerServerGuid) - _playerController?.ApplyPhysicsState(record.FinalPhysicsState); + { + // C3c-F1 (2026-08-02): route through the owner's + // lifecycle-deciding typed entry. The publication lifecycle — + // not this inbound handler — decides whether the push lands: + // a dormant first-entry controller drops it (the activation + // transaction re-reads the same canonical FinalPhysicsState + // itself; the accepted SetState is queued behind the initial + // residence so this value is unchanged), and a terminal + // controller treats it as a displaced push instead of faulting + // the session (the second connected-gate crash chain, + // logs/connected-world-gate-20260802-125907). + _ = _playerController?.ApplyServerPhysicsState( + record.FinalPhysicsState); + } if (!_liveEntities.TryGetWorldEntity(parsed.Guid, out var entity)) return; diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index 3624f934..440c2900 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -709,6 +709,17 @@ internal sealed class DatLiveEntityProjectionMaterializer } bool createdProjection = false; + // C3c route-1 flip: a fresh world Create materializes presentation- + // only — the sidecar exists but stays non-spatial until the + // residence-driven Runtime placement's completion receipt (or, for a + // record whose residence already completed before its sidecar could + // exist, the self-projection below). An already-materialized record + // keeps its sticky residence so unflipped same-incarnation + // transitions (an equipped child dropping to world) stay on their + // legacy path by construction. + LiveEntityMaterializationResidence residence = + retainedRecord?.MaterializationResidence + ?? LiveEntityMaterializationResidence.AwaitRuntimePlacement; WorldEntity? entity = _runtime.MaterializeLiveEntity( expectedCanonical, spawn.Position!.Value.LandblockId, @@ -734,7 +745,8 @@ internal sealed class DatLiveEntityProjectionMaterializer }, LiveEntityProjectionKind.World, initializeProjection: record => record.EffectProfile = profile, - out LiveEntityRecord? expectedRecord); + out LiveEntityRecord? expectedRecord, + residence); if (entity is null || expectedRecord is null || !_runtime.IsCurrentCreateIntegration( @@ -744,6 +756,27 @@ internal sealed class DatLiveEntityProjectionMaterializer { return false; } + if (residence is LiveEntityMaterializationResidence.AwaitRuntimePlacement + && expectedCanonical.FullCellId != 0u + && !_runtime.HasActiveInitialCreateResidence(expectedCanonical)) + { + // The residence-driven placement already committed before this + // sidecar existed (a deferred-parent child replayed during its + // parent's drain, or a recovery re-materialization) — its + // completion receipt is gone, so presentation self-projects from + // the committed canonical state through the presentation-only + // bucket path. + if (!_runtime.RebucketLiveEntity( + spawn.Guid, + expectedCanonical.FullCellId) + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + } if (!createdProjection) { diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index e408afa8..1be511df 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -524,6 +524,22 @@ public sealed class EquippedChildRenderController : IDisposable { return false; } + // C3c: a world-created (residence-managed) child converting to an + // attached projection exits the residence-managed presentation path + // at this same-incarnation kind transition. Attached children have + // no Runtime placement, and the flip's sticky-residence rule expects + // equipped children to carry LegacyImmediate so a later drop back to + // world stays on the legacy path by construction + // (DatLiveEntityProjectionMaterializer.MaterializeProjection's + // retained-residence comment). C3c-R1 review F1: the conversion is + // the owner's explicit API, which asserts no initial-create + // residence is still active, rather than a direct field write here. + if (retainedChild is not null + && _liveEntities.IsCurrentRecord(retainedChild)) + { + _liveEntities.ConvertMaterializationResidenceToLegacyImmediate( + retainedChild); + } WorldEntity? entity = _liveEntities.MaterializeLiveEntity( childCanonical, parentCellId, diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index 2963bbb4..47cbf35d 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -4,6 +4,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.World; using AcDream.Runtime.Entities; +using AcDream.Runtime.Session; namespace AcDream.App.World; @@ -180,6 +181,14 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded private readonly LiveEntityDeletionController _deletion; private readonly DormantLiveEntityStore _dormant; private readonly Action? _diagnostic; + /// + /// C3c: the graphical first-entry drive pump — pumped at the end of each + /// Create transaction so a fresh residence drives its conductor + /// synchronously (retail HandleCreateObject runs enter_world inline). + /// Optional so presentation-free hydration tests keep constructing this + /// controller without one. + /// + private readonly RuntimeFirstEntryDriveController? _firstEntry; private readonly Dictionary _projectionOperations = new(ReferenceEqualityComparer.Instance); @@ -203,7 +212,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded ILocalPlayerIdentitySource identity, LiveEntityDeletionController deletion, DormantLiveEntityStore? dormant = null, - Action? diagnostic = null) + Action? diagnostic = null, + RuntimeFirstEntryDriveController? firstEntry = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _entityObjects = entityObjects @@ -219,6 +229,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded _deletion = deletion ?? throw new ArgumentNullException(nameof(deletion)); _dormant = dormant ?? new DormantLiveEntityStore(); _diagnostic = diagnostic; + _firstEntry = firstEntry; } internal event Action? AppearanceApplied; @@ -259,7 +270,9 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded lock (_datLock) { LiveEntityRegistrationResult registration = - _runtime.RegisterLiveEntity(spawn); + _runtime.RegisterLiveEntity( + spawn, + isLocalPlayer: spawn.Guid == _identity.ServerGuid); InboundCreateResult result = registration.Inbound; if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration) @@ -380,6 +393,16 @@ AppearanceSynchronization: $"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.", cleanupFailure); } + + // C3c: pump the first-entry drive after the complete Create + // hydration transaction — the sidecar exists, so this entity's + // conductor can run mover-prep -> placement -> drain and its + // completion receipt can bind presentation synchronously, + // matching retail HandleCreateObject's inline enter_world. Any + // still-yielding sequence (missing prepared Setup, deferred + // destination cell, FIFO ahead of us) is retried by the + // per-frame placement retry phase. + _firstEntry?.DriveAll(); } } diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 9c818d22..c708c8d2 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -523,7 +523,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource /// public event Action? ProjectionVisibilityChanged; - public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming) + public LiveEntityRegistrationResult RegisterLiveEntity( + WorldSession.EntitySpawn incoming, + bool isLocalPlayer = false) { if (_isClearing || _sessionClearPendingFinalization || _isRegisteringResources) { @@ -533,9 +535,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource : "A live entity cannot register from inside atomic resource registration."); } + // C3c route-1 flip: every graphical initial Create enters the + // canonical initial-residence lease. The accepted wire frame stays on + // the canonical record with FullCell 0 until the authored Runtime + // SetPosition operation commits; the host's first-entry drive + // controller walks the conductors from the residence-begin + // notification. RuntimeEntityRegistrationResult registration = - _entityObjects.RegisterEntity( + _entityObjects.RegisterEntityWithInitialResidence( incoming, + isLocalPlayer, RetirePriorProjection); RuntimeEntityRecord? canonical = registration.Canonical; LiveEntityRecord? projection = canonical is null @@ -795,11 +804,32 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource || record.WorldEntity is not { } entity) return false; if (record.MaterializationResidence is - LiveEntityMaterializationResidence.AwaitRuntimePlacement) + LiveEntityMaterializationResidence.AwaitRuntimePlacement + && HasActiveInitialCreateResidence(record.Canonical)) { - // The private Runtime Place path below performs a presentation- - // only bucket update. This legacy API also commits canonical - // Runtime residence and cannot touch a cut-over incarnation. + // C3c: while the initial-create residence is ACTIVE, Runtime's + // SetPosition owner is the sole canonical position/cell/ + // object-clock authority and even the graphical bucket stays + // suppressed: the conductor's completion receipt (which reaches + // presentation through + // TryApplyInitialCreateCompletionPresentation, not this API) is + // the entity's first world-visible moment. Without this gate a + // re-entrant caller (e.g. a resource-registration observer) + // could install a bucket for a suppressed record before its + // placement ever committed. A STALE residence is lazily retired + // by this same query, after which legacy moves flow. + // + // C3c-R1 review R2: the gate is the EXACT-token residence + // activity view, never the sticky MaterializationResidence enum + // alone. Post-residence (the lease completed and was consumed) + // this method falls through to the FULL legacy branch below: + // the unflipped update routes (network position/state, remote + // and local teleports, streaming reprojection, hydration + // recovery) are the position authority again, and retail's + // prepare_to_enter_world (0x00511FA0) clock rebase must run on + // every root-workset membership edge — the earlier + // presentation-only shortcut skipped CommitRebucket and that + // clock edge for the entity's whole post-residence lifetime. return false; } @@ -930,6 +960,197 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return true; } + /// + /// C3c: the graphical-bucket-only projection of a conductor-owned + /// initial placement — called ONLY from + /// (the + /// completion receipt at the initial-create residence boundary), never + /// from the public (C3c-R1 review R2: + /// post-residence moves take the full legacy branch there). + /// Deliberately never calls CommitRebucket, + /// SuspendObjectClock, or ResetObjectClockForEnterWorld — + /// Runtime's SetPosition commit already owns all of those for the + /// residence-driven placement this receipt projects. May place into a + /// pending (not-yet-loaded) bucket exactly like the legacy Create path + /// did; the pending drain publishes visibility when the landblock loads. + /// + private bool RebucketLiveEntityPresentationOnly( + uint serverGuid, + LiveEntityRecord record, + WorldEntity entity, + uint spatialCellOrLandblockId) + { + RuntimeEntityKey key = RequireProjectionKey(record); + bool wasProjected = record.IsSpatiallyProjected; + bool wasVisible = record.IsSpatiallyVisible; + ulong projectionOperation = ++record.ProjectionMutationVersion; + record.IsSpatiallyProjected = true; + Exception? spatialNotificationFailure = null; + uint priorRebucketingGuid = _rebucketingGuid; + _rebucketingGuid = serverGuid; + BeginPresentationOnlySpatialMutation(key); + try + { + try + { + _spatial.RebucketLiveEntity( + key, + entity, + spatialCellOrLandblockId); + } + catch (AggregateException error) + { + spatialNotificationFailure = error; + } + } + finally + { + EndPresentationOnlySpatialMutation(key); + _rebucketingGuid = priorRebucketingGuid; + } + if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } + bool visible = _spatial.IsLiveEntityProjectionResident(key); + record.IsSpatiallyVisible = visible; + RefreshSpatialPresentationIndexes(record); + RefreshPresentation(record); + RefreshSpatialRuntimeIndexes(record); + Exception? runtimeNotificationFailure = null; + if (!wasProjected || wasVisible != visible) + { + try + { + PublishProjectionVisibilityChanged(record, visible); + } + catch (Exception error) + { + runtimeNotificationFailure = error; + } + } + if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure); + return false; + } + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure); + return true; + } + + /// + /// C3c: applies one initial-Create ExecutorCompleted receipt's + /// presentation — the graphical binding point for a residence-driven + /// initial placement. Runtime committed position, cell, body, clocks, + /// and worksets during the conductor's drain; this installs the + /// committed frame on the sidecar and moves its graphical bucket + /// (pending buckets allowed — the legacy Create path's own semantics). + /// Superseded facts (a later legacy-path move already advanced the + /// record past the receipt) are treated as already-projected: the + /// receipt is stale for presentation and must not snap the entity back. + /// + internal bool TryApplyInitialCreateCompletionPresentation( + in RuntimePlacementProjectionSnapshot projection) + { + RuntimePlacementProjectionToken token = projection.Token; + if (!token.IsValid + || token.SessionLifetimeVersion != _directory.SessionLifetimeVersion + || !_projections.TryGet(token.Entity, out LiveEntityRecord? record) + || !_directory.IsCurrent(record.Canonical) + || record.Canonical.Key != token.Entity + || record.WorldEntity is not { } entity) + { + // No sidecar (a deferred-child replay materializes later and + // self-projects from canonical state) or a displaced identity — + // acknowledge-only. + return true; + } + if (record.FullCellId != token.ExactCellId + || record.Canonical.PlacementCommitVersion + != token.PlacementCommitVersion) + { + // A newer move superseded this receipt's facts after the drain. + return true; + } + + entity.SetPosition(projection.WorldPosition); + entity.Rotation = projection.Orientation; + entity.ParentCellId = token.ExactCellId; + entity.EffectCellId = token.ExactCellId; + return RebucketLiveEntityPresentationOnly( + record.ServerGuid, + record, + entity, + token.ExactCellId); + } + + /// + /// C3c: true while the exact incarnation behind + /// holds an initial-create residence lease — the discriminator the + /// placement sink uses to leave conductor-owned Place/Withdraw receipts + /// at the FIFO head for the drive controller to consume. + /// + internal bool HasActiveInitialCreateResidence(RuntimeEntityKey key) => + _directory.TryGetByLocalId( + key.LocalEntityId, + out RuntimeEntityRecord canonical) + && _directory.IsCurrent(canonical) + && canonical.Key == key + && _entityObjects.TryGetInitialCreateResidence(canonical, out _); + + /// + /// C3c: true when the exact incarnation behind + /// holds an initial-create residence lease. Used by materialization to + /// decide whether presentation must await the conductor's completion + /// receipt or may self-project from already-committed canonical state. + /// + internal bool HasActiveInitialCreateResidence( + RuntimeEntityRecord canonical) => + _entityObjects.TryGetInitialCreateResidence(canonical, out _); + + /// + /// C3c-R1 review F1: the ONLY sanctioned mutation of the otherwise + /// sticky — a + /// world-created (residence-managed) entity converting to an attached + /// projection at a same-incarnation kind transition (the equipped-child + /// world→attached path). Attached children have no Runtime placement, + /// so the sticky-residence rule expects them to carry + /// . + /// Owned here so the invariant is asserted at the owner: converting + /// while the initial-create residence is still ACTIVE would let an + /// attached materialization race the conductor's pending placement. + /// + internal void ConvertMaterializationResidenceToLegacyImmediate( + LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + if (record.MaterializationResidence is not + LiveEntityMaterializationResidence.AwaitRuntimePlacement) + { + return; + } + if (HasActiveInitialCreateResidence(record.Canonical)) + { + throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8}/" + + $"{record.Canonical.Incarnation} cannot convert to " + + "LegacyImmediate residence while its initial-create " + + "residence lease is still active."); + } + record.MaterializationResidence = + LiveEntityMaterializationResidence.LegacyImmediate; + } + /// /// Applies one canonical Runtime placement receipt to the graphical /// sidecar only. Runtime has already committed identity, position, diff --git a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs index 9f062087..b26e43fd 100644 --- a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs +++ b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs @@ -66,6 +66,29 @@ internal sealed class RuntimePlacementPresentationSink public bool TryApply(in RuntimePlacementProjectionSnapshot projection) { + if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted) + { + // C3c: the initial-Create completion receipt is the graphical + // binding point for a residence-driven placement (the F1 + // acknowledge-and-ignore behavior applied only while + // PublishExecutorCompletion had zero production callers). + return TryApplyInitialCreateCompletion(in projection); + } + + if (projection.Kind is RuntimePlacementProjectionKind.Place + or RuntimePlacementProjectionKind.Withdraw + && _liveEntities.HasActiveInitialCreateResidence( + projection.Token.Entity)) + { + // C3c: a Place/Withdraw for an entity still holding its + // initial-create residence belongs to the first-entry conductor + // machinery, which acknowledges its own receipts at the exact + // FIFO head. Leave it there — the drive controller's pump + // consumes it; applying or acknowledging here would starve the + // conductor's own acknowledgement stage forever. + return false; + } + if (projection.Kind is RuntimePlacementProjectionKind.Place && !_transit.IsCurrentPlacementAuthority( projection.Token.Portal, @@ -76,19 +99,15 @@ internal sealed class RuntimePlacementPresentationSink if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection)) return false; - if (projection.Kind is RuntimePlacementProjectionKind.Discard - or RuntimePlacementProjectionKind.ExecutorCompleted) + if (projection.Kind is RuntimePlacementProjectionKind.Discard) { - // F1: ExecutorCompleted is acknowledge-and-ignore like Discard - - // no world/presentation mutation by definition. Must NOT fall - // through to the record-lookup gate below (that gate legitimately - // rejects for OTHER reasons, and this sink's caller + // Discard cancels only an unacknowledged observation - no + // world/presentation mutation. Must NOT fall through to the + // record-lookup gate below (that gate legitimately rejects for + // OTHER reasons, and this sink's caller // (RuntimePlacementProjectionSubscription) treats a false return - // as "leave at the FIFO head" - a rejected ExecutorCompleted - // would permanently wedge the whole ordered stream). Provably - // inert today: PublishExecutorCompletion has zero production - // callers - see - // RuntimePlacementPresentationSinkTests.ExecutorCompleted_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone. + // as "leave at the FIFO head" - a rejected Discard would + // permanently wedge the whole ordered stream). return true; } if (!_liveEntities.TryGetRecord( @@ -109,6 +128,37 @@ internal sealed class RuntimePlacementPresentationSink }; } + /// + /// C3c: binds one completed initial-Create drain's presentation. A + /// celless completion (a route that performed no SetPosition — a + /// deferred-parent child staying invisible until its parent replay, or a + /// positionless create) and a missing/superseded sidecar are + /// acknowledge-only; the sidecar's own materialization self-projects + /// from canonical state in those cases. Pending (not-yet-loaded) + /// destination buckets are allowed — the legacy Create path's own + /// semantics — so this receipt can never wedge the ordered stream behind + /// an unloaded graphical backend. + /// + private bool TryApplyInitialCreateCompletion( + in RuntimePlacementProjectionSnapshot projection) + { + if (projection.Token.ExactCellId == 0u) + return true; + if (!_liveEntities.TryApplyInitialCreateCompletionPresentation( + in projection)) + { + return false; + } + if (!_liveEntities.TryGetRecord( + projection.Token.Entity, + out LiveEntityRecord record) + || record.WorldEntity is not { } entity) + { + return true; + } + return TryPublishPlace(record, entity); + } + private bool TryPublishPlace(LiveEntityRecord record, WorldEntity entity) { if (!IsCurrent(record, entity)) diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index df86dbd4..e367b2c9 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -643,12 +643,17 @@ public sealed class ShadowObjectRegistry private static uint DeriveOutdoorSeed( Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId) { + // C3c-F3: only a genuinely-absent landblock id (0) has no seed — + // prefix 0x00000000 is landblock (0,0), the map corner, whose + // outdoor cells 0x00000001..0x40 are as real as any other block's. + // The old prefix-0 sentinel silently dropped every landblock-baked + // static in the corner block. + if (landblockId == 0u) return 0u; float localX = worldPos.X - worldOffsetX; float localY = worldPos.Y - worldOffsetY; int cx = (int)System.Math.Clamp(localX / 24f, 0f, 7f); int cy = (int)System.Math.Clamp(localY / 24f, 0f, 7f); uint lbPrefix = landblockId & 0xFFFF0000u; - if (lbPrefix == 0u) return 0u; // The clamp only anchors the SEED id; AddAllOutsideCells re-seats the // actual flood cells from the sphere centers via LandDefs.AdjustToOutside // (block-crossing), so an out-of-block position still floods correctly. @@ -2120,7 +2125,16 @@ public sealed class ShadowObjectRegistry return false; } - internal bool HasLogicalOwner(uint entityId) => + /// + /// True while owns a logical shadow + /// registration (suspended or live). Public since C3c: the graphical + /// host reports the truthful + /// local-player shadow disposition (authored payload vs proven + /// shapeless) into the Runtime first-entry activation, which + /// validates against this exact + /// registry state. + /// + public bool HasLogicalOwner(uint entityId) => _entityReg.ContainsKey(entityId); /// diff --git a/src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs b/src/AcDream.Core/Physics/SpawnPlacementSettler.cs similarity index 61% rename from src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs rename to src/AcDream.Core/Physics/SpawnPlacementSettler.cs index 6ea506d0..86263c87 100644 --- a/src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs +++ b/src/AcDream.Core/Physics/SpawnPlacementSettler.cs @@ -1,15 +1,27 @@ using System.Numerics; -using AcDream.Core.Physics; -namespace AcDream.App.Physics; +namespace AcDream.Core.Physics; /// /// Performs the compressed first-gravity-frame settle used to establish -/// retail Contact/OnWalkable state for a newly materialized remote body. +/// retail Contact/OnWalkable state for a newly placed body. +/// +/// Retail gains spawn contact from the FIRST GRAVITY FRAME, not the +/// placement itself: CPhysicsObj::enter_world (0x00516170) runs +/// SetPosition (find_placement validates the spot but records no +/// touch) and every retail CPhysicsObj then simulates, falls the few +/// centimetres onto the floor, and the transition's touch grants the +/// contact plane + CONTACT/ON_WALKABLE. Bodies that do not run that first +/// ordinary frame at placement (stationary remotes — #270 — and the local +/// player's Runtime first-entry activation — C3c-F5) compress the settle +/// here: a short downward sweep from the placed position whose touch +/// handler produces exactly the state retail's first frame would. A sweep +/// that finds no floor (true airborne spawn) leaves the body airborne, +/// exactly like retail's fall. /// -internal static class RemoteSpawnPlacementSettler +public static class SpawnPlacementSettler { - internal const float SettleDistance = 0.5f; + public const float SettleDistance = 0.5f; public static bool TrySettle( PhysicsEngine physicsEngine, diff --git a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs index 3e325cdc..e52dd6ab 100644 --- a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs +++ b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs @@ -34,23 +34,39 @@ internal sealed class HeadlessRuntimePlacementProjectionSink if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted) { // F1: acknowledge-and-ignore, same as Discard - ExecutorCompleted - // is not a placement to project (no world/presentation mutation - // by definition; the executor's own drain already committed - // every Place/Withdraw this receipt follows). It must NOT fall - // through to the record-lookup gate below: that gate can validly - // reject an unrelated entity/session mismatch, and this sink's - // caller (RuntimePlacementProjectionSubscription) treats a false - // return as "leave at the FIFO head" - a rejected ExecutorCompleted - // would permanently wedge the entire ordered placement stream - // behind it. Currently provably inert: PublishExecutorCompletion - // has zero production callers (Execute/RegisterEntityWithInitialResidence - // are both unreached in production) - see - // HeadlessSessionHostTests.ExecutorCompletedReceiptIsAcknowledgeOnlyRegardlessOfRecordValidity. + // is not a placement to project (a headless host has no + // presentation to bind off the completed initial drain; the + // executor's own drain already committed every canonical fact). + // It must NOT fall through to the record-lookup gate below: that + // gate can validly reject an unrelated entity/session mismatch, + // and this sink's caller (RuntimePlacementProjectionSubscription) + // treats a false return as "leave at the FIFO head" - a rejected + // ExecutorCompleted would permanently wedge the entire ordered + // placement stream behind it. return true; } RuntimePlacementProjectionToken token = projection.Token; RuntimeEntityDirectory directory = _runtime.EntityObjects.Entities; + if (projection.Kind is RuntimePlacementProjectionKind.Place + or RuntimePlacementProjectionKind.Withdraw + && token.IsValid + && directory.TryGetByLocalId( + token.Entity.LocalEntityId, + out RuntimeEntityRecord residenceCandidate) + && directory.IsCurrent(residenceCandidate) + && residenceCandidate.Key == token.Entity + && _runtime.EntityObjects.TryGetInitialCreateResidence( + residenceCandidate, + out _)) + { + // C3c: a Place/Withdraw for an entity still holding its + // initial-create residence belongs to the first-entry conductor + // machinery, which acknowledges its own receipts at the exact + // FIFO head. Leave it there for the drive pump; validating or + // acknowledging it here would starve the conductor forever. + return false; + } if (!token.IsValid || token.SessionLifetimeVersion != directory.SessionLifetimeVersion diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs index a07d56a9..afb0c400 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs @@ -15,6 +15,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting private readonly ILiveSessionEventRouting _events; private readonly GameRuntime _runtime; private readonly IRuntimePlacementProjectionSink _placements; + private readonly RuntimeFirstEntryDriveController? _firstEntry; private RuntimePlacementProjectionSubscription? _subscription; private bool _attachStarted; private bool _eventsDisposed; @@ -23,12 +24,14 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting internal HeadlessSessionEventRoute( ILiveSessionEventRouting events, GameRuntime runtime, - IRuntimePlacementProjectionSink placements) + IRuntimePlacementProjectionSink placements, + RuntimeFirstEntryDriveController? firstEntry = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _placements = placements ?? throw new ArgumentNullException(nameof(placements)); + _firstEntry = firstEntry; } public void Attach() @@ -41,6 +44,10 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting // succeeds and throws, LiveSessionHost's retryable rollback still // invokes Dispose on the underlying route. _attachStarted = true; + // C3c-R1 review F6: assert (not assume) that the prior route + // detached — session reset precedes a new route — before this route + // takes ownership of the shared drive controller's tracked entries. + _firstEntry?.AttachRoute(this); _events.Attach(); _subscription = new RuntimePlacementProjectionSubscription( _runtime, @@ -56,6 +63,12 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting // network route. A still-pending FIFO head remains Runtime-owned for // the replacement route to drain. Interlocked.Exchange(ref _subscription, null)?.Dispose(); + // C3c: the drive controller's tracked entries die with this exact + // route; Runtime's retirement/session-clear fan-out owns + // conductor/residence convergence independently. C3c-R1 review F6: + // route-scoped — a route that never attached cannot clear a live + // route's entries. + _firstEntry?.DetachRoute(this); if (!_eventsDisposed) { _events.Dispose(); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 5c55b088..8d5ca3b0 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -120,6 +120,12 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? _contentLease; + /// C3c: one per-host first-entry drive controller (lazy — its + /// residence-begin subscription binds once against the persistent + /// Runtime lifetime) plus the active world projection it pumps + /// through. + private RuntimeFirstEntryDriveController? _firstEntryDrive; + private HeadlessSessionWorldProjection? _worldProjection; private int _disposeStage; private long _reconnectDeadline; private bool _reconnectPending; @@ -295,6 +301,10 @@ internal sealed class HeadlessSessionHost : IDisposable _localPlayerFrame.AdvanceBeforeNetwork( checked((float)deltaSeconds)); Runtime.Session.Tick(); + // C3c: pump pending first-entry sequences after the network drain — + // collision-generation progress and freshly accepted Creates both + // surface here, mirroring the graphical per-frame retry phase. + _worldProjection?.PumpFirstEntry(); _localPlayerFrame.RunPostNetworkCommandPhase(); Runtime.ActionOwner.CombatAttack.Tick(); _policy.Tick(Runtime, Commands); @@ -525,10 +535,34 @@ internal sealed class HeadlessSessionHost : IDisposable private ILiveSessionEventRouting CreateEventRoute( AcDream.Core.Net.WorldSession session) { - IRuntimeDirectWorldProjection? worldProjection = - _contentLease is { } content - ? new HeadlessSessionWorldProjection(Runtime, content) - : null; + IRuntimeDirectWorldProjection? worldProjection = null; + if (_contentLease is { } content) + { + // C3c: one drive controller per host — the residence-begin + // notification binds once against the persistent Runtime + // lifetime; reconnects reuse it (its tracked entries are cleared + // with each retiring route). + _firstEntryDrive ??= new RuntimeFirstEntryDriveController( + Runtime.EntityObjects, + Runtime.Clock, + content.PreparedCollision, + () => PlayerMovementConstructionOptions.From( + Runtime.CharacterOwner.MovementSkills.Snapshot), + // A headless host registers no shadow payloads — the local + // player is provably shapeless in the shadow registry, with + // the same default approach cylinder the deleted + // hand-resolve used. + static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + var projection = new HeadlessSessionWorldProjection( + Runtime, + content, + _firstEntryDrive); + _worldProjection = projection; + worldProjection = projection; + } var entities = new RuntimeLiveEntitySessionController( Runtime, session, @@ -583,7 +617,8 @@ internal sealed class HeadlessSessionHost : IDisposable return new HeadlessSessionEventRoute( route, Runtime, - new HeadlessRuntimePlacementProjectionSink(Runtime)); + new HeadlessRuntimePlacementProjectionSink(Runtime), + _firstEntryDrive); } private static LiveSessionCharacterSelector MapCharacterSelector( diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index 407d7a62..4ee0a614 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -16,6 +16,15 @@ internal interface IHeadlessCollisionNeighborhood void CenterOn(uint fullCellId); bool IsReady(uint fullCellId); + + /// + /// C3c-R1 review F7: true when 's landblock + /// is one this neighborhood can ever collision-publish — inside the 3x3 + /// window around the requested center (or no center has been requested + /// yet). A remote Create outside the window must not open a deferred + /// placement: its collision-generation wake could never fire. + /// + bool IsWithinServiceWindow(uint fullCellId); } internal readonly record struct HeadlessCollisionGenerationAdvance( @@ -233,6 +242,20 @@ internal sealed class HeadlessCollisionNeighborhood AdvanceWork(); } + public bool IsWithinServiceWindow(uint fullCellId) + { + if (_requestedCenterLandblock == 0u) + return true; + uint target = CanonicalLandblock(fullCellId); + int dx = Math.Abs( + (int)((target >> 24) & 0xFFu) + - (int)((_requestedCenterLandblock >> 24) & 0xFFu)); + int dy = Math.Abs( + (int)((target >> 16) & 0xFFu) + - (int)((_requestedCenterLandblock >> 16) & 0xFFu)); + return dx <= 1 && dy <= 1; + } + public bool IsReady(uint fullCellId) { uint center = CanonicalLandblock(fullCellId); @@ -480,36 +503,72 @@ internal sealed class HeadlessSessionWorldProjection private readonly GameRuntime _runtime; private readonly IHeadlessCollisionNeighborhood _collision; - private readonly IPreparedCollisionSource? _preparedCollision; + private readonly RuntimeFirstEntryDriveController? _firstEntry; + private uint _requestedLocalPlayerCell; internal HeadlessSessionWorldProjection( GameRuntime runtime, - HeadlessProcessContentOwner.HeadlessProcessContentLease content) + HeadlessProcessContentOwner.HeadlessProcessContentLease content, + RuntimeFirstEntryDriveController? firstEntry = null) : this( runtime, new HeadlessCollisionNeighborhood(runtime, content), - content.PreparedCollision) + firstEntry) { } internal HeadlessSessionWorldProjection( GameRuntime runtime, IHeadlessCollisionNeighborhood collision, - IPreparedCollisionSource? preparedCollision = null) + RuntimeFirstEntryDriveController? firstEntry = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _collision = collision ?? throw new ArgumentNullException(nameof(collision)); - _preparedCollision = preparedCollision; + _firstEntry = firstEntry; } public void ProjectSpawn( RuntimeEntityRecord record, bool isLocalPlayer) { - if (isLocalPlayer) - SynchronizeLocalPlayer(record); + // C3c route-8 flip: the first-entry conductors own mover + // preparation, body/controller construction, and placement for every + // Create. The host's spawn projection centers the collision + // neighborhood on the local player's wire cell (the activation + // defers until its collision generation commits) and pumps the + // drive; remote leases ride the same pump. + if (isLocalPlayer + && record.ServerGuid == _runtime.PlayerIdentity.ServerGuid + // C3c-R1 review F4: LandblockId is the RAW wire value; 0 is the + // absent-id sentinel and the F3 admission guards + // (RuntimePhysicsState.BeginCollisionAdmission) now throw on it, + // which would make one absent-position Create session-fatal. + // Skip the centering; the conductor pumps regardless. + && record.Snapshot.Position is { LandblockId: not 0u } position) + { + _requestedLocalPlayerCell = position.LandblockId; + _collision.CenterOn(position.LandblockId); + } + else if (!isLocalPlayer + && record.Snapshot.Position is + { LandblockId: not 0u } remotePosition + && !_collision.IsWithinServiceWindow(remotePosition.LandblockId)) + { + // C3c-R1 review F7: a remote/projectile Create outside the + // neighborhood's service window would submit a placement whose + // DeferredCell park can never wake (the far landblock is never + // collision-published here), pinning its residence and this + // pump's entry forever. Convert to the celless completion route + // BEFORE the pump: the conductor completes with FullCell 0 and + // the accepted wire frame stays on the canonical snapshot — the + // exact pre-flip accepted-frame behavior for far remotes. A + // later fresh Position event owns any subsequent placement. + _ = _runtime.EntityObjects + .TryConvertInitialResidenceToCellessRoute(record); + } + _firstEntry?.DriveAll(); } public void ProjectPosition( @@ -522,7 +581,17 @@ internal sealed class HeadlessSessionWorldProjection if (_runtime.MovementOwner.Controller is null) { - SynchronizeLocalPlayer(record); + // C3c: the initial-resolve hand-copy is gone — a Position + // arriving before the conductor's publication commit only pumps + // the drive (the conductor re-reads the accepted snapshot + // itself). C3c-R1 review F4: guard the raw wire LandblockId — + // 0 is the absent-id sentinel the F3 admission guards throw on. + if (record.Snapshot.Position is { LandblockId: not 0u } position) + { + _requestedLocalPlayerCell = position.LandblockId; + _collision.CenterOn(position.LandblockId); + } + _firstEntry?.DriveAll(); return; } @@ -530,6 +599,19 @@ internal sealed class HeadlessSessionWorldProjection BlipLocalPlayer(record); } + /// + /// C3c: the host tick's first-entry pump — advances the collision + /// neighborhood toward the requested local-player cell (its publication + /// work progresses on IsReady polls) and drives every pending + /// conductor sequence. + /// + internal void PumpFirstEntry() + { + if (_requestedLocalPlayerCell != 0u) + _ = _collision.IsReady(_requestedLocalPlayerCell); + _firstEntry?.DriveAll(); + } + public void BeginTeleport() { if (_runtime.MovementOwner.Controller is { } controller) @@ -545,7 +627,7 @@ internal sealed class HeadlessSessionWorldProjection destination.EntityGuid, out RuntimeEntityRecord record)) { - SynchronizeLocalPlayer(record); + ResynchronizeLocalPlayerForPortalArrival(record); } if (_runtime.MovementOwner.Controller is { } controller) controller.State = PlayerState.InWorld; @@ -563,19 +645,27 @@ internal sealed class HeadlessSessionWorldProjection IsCollisionReady: ready); } - private void SynchronizeLocalPlayer(RuntimeEntityRecord record) + /// + /// TODO-C4 (route 3): portal-arrival re-synchronization only. The + /// route-1/8 initial-entry hand-copy (controller construction + first + /// resolve/placement) was deleted at C3c — the first-entry conductor's + /// publication chain owns it — but the portal route is unflipped, so its + /// arrival re-resolve keeps today's exact behavior against the + /// already-published controller until C4 routes it through + /// RuntimePortalPlacementAuthority. + /// + private void ResynchronizeLocalPlayerForPortalArrival( + RuntimeEntityRecord record) { if (record.ServerGuid != _runtime.PlayerIdentity.ServerGuid - || record.Snapshot.Position is not { } position) + || record.Snapshot.Position is not { } position + || _runtime.MovementOwner.Controller is not { } controller) { return; } _collision.CenterOn(position.LandblockId); - PlayerMovementController controller = - _runtime.MovementOwner.Controller - ?? CreateController(record); Vector3 wirePosition = new( position.PositionX, position.PositionY, @@ -636,57 +726,4 @@ internal sealed class HeadlessSessionWorldProjection wirePosition); } - private PlayerMovementController CreateController( - RuntimeEntityRecord record) - { - var controller = new PlayerMovementController( - _runtime.EntityObjects.Physics.Engine, - record.ObjectClock, - PlayerMovementConstructionOptions.From( - _runtime.CharacterOwner.MovementSkills.Snapshot)); - controller.ApplyPhysicsState(record.FinalPhysicsState); - controller.LocalEntityId = record.LocalEntityId ?? 0u; - ApplySetupStepHeights(record, controller); - RuntimeMovementSkillProjection.ApplyTo( - _runtime.CharacterOwner.MovementSkills, - controller); - _runtime.MovementOwner.Controller = controller; - return controller; - } - - private void ApplySetupStepHeights( - RuntimeEntityRecord record, - PlayerMovementController controller) - { - if (record.Snapshot.SetupTableId is not { } setupId - || (setupId & 0xFF000000u) != 0x02000000u - || _preparedCollision is null) - { - return; - } - - PreparedCollisionReadResult read = - _preparedCollision.ReadSetupCollision(setupId); - if (read.Status != PreparedAssetReadStatus.Loaded - || read.Data is not { } setup) - { - throw new InvalidDataException( - $"Player Setup collision 0x{setupId:X8} is {read.Status}."); - } - _runtime.EntityObjects.Physics.DataCache.CacheSetup( - setupId, - setup); - controller.StepUpHeight = setup.StepUpHeight > 0f - ? setup.StepUpHeight - : 0.4f; - controller.StepDownHeight = setup.StepDownHeight > 0f - ? setup.StepDownHeight - : 0.4f; - // TS-46 (2026-07-30): the prepared package already carries the - // Setup's verbatim sphere list — no raw-DAT read needed here (unlike - // the graphical PlayerModeController.ApplyStepHeights, which reads - // DatReaderWriter.DBObjs.Setup directly). Empty falls back to - // ResolveWithTransition's legacy scalar reconstruction. - controller.SphereList = setup.Spheres; - } } diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index f9c3f5c9..f8a47c86 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -70,7 +70,16 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( /// keys - the remote/projectile Create-time body-construction conductor. /// Dormant like its C3a sibling; converges to zero the same way. /// - int RemoteFirstEntryActiveCount = 0) + int RemoteFirstEntryActiveCount = 0, + /// + /// C3c-R1 review F5: outstanding host first-entry drive entries + /// (RuntimeFirstEntryDriveController pending keys, summed over + /// every drive registered against this lifetime via + /// ). + /// Previously outside every ledger; gated by + /// like the conductor counts it pumps. + /// + int FirstEntryDrivePendingCount = 0) { public bool IsConverged => IsDisposed @@ -94,6 +103,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && PendingCompletionReceiptCount == 0 && LocalPlayerFirstEntryActiveCount == 0 && RemoteFirstEntryActiveCount == 0 + && FirstEntryDrivePendingCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -137,6 +147,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { private bool _sessionClearInProgress; private bool _disposed; + /// C3c: see . + private Action? _initialResidenceBegan; + /// C3c-R1 review F5: see . + private readonly List> _firstEntryDriveOwnership = []; public RuntimeEntityObjectLifetime( uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, @@ -437,7 +451,31 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.LastReplayFailure is not null, InitialCreateExecution.PendingCompletionReceiptCount, LocalPlayerFirstEntry.CaptureOwnership().ActiveCount, - RemoteFirstEntry.CaptureOwnership().ActiveCount); + RemoteFirstEntry.CaptureOwnership().ActiveCount, + CaptureFirstEntryDrivePendingCount()); + } + + private int CaptureFirstEntryDrivePendingCount() + { + int total = 0; + for (int i = 0; i < _firstEntryDriveOwnership.Count; i++) + total = checked(total + _firstEntryDriveOwnership[i]()); + return total; + } + + /// + /// C3c-R1 review F5: registers one host first-entry drive controller's + /// pending-count provider into this lifetime's ownership snapshot, so + /// tracked-but-undriven entries can never sit outside every ledger. The + /// drive controller registers itself at construction (it already binds + /// there); multiple + /// registrations sum, mirroring the multicast notification shape. + /// + public void RegisterFirstEntryDriveOwnership(Func pendingCount) + { + ArgumentNullException.ThrowIfNull(pendingCount); + EnsureNotDisposed(); + _firstEntryDriveOwnership.Add(pendingCount); } public void BindEventContext( @@ -451,6 +489,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.BindGeneration(generation); } + /// + /// C3c: registers one host callback fired for every FRESH initial-create + /// residence begin (never for a same-generation FIFO append). Multicast, + /// mirroring . + /// The callback runs synchronously inside the registration transaction — + /// subscribers must only record the entity for a later drive pump, never + /// call a conductor's Advance re-entrantly from it. + /// + public void BindInitialResidenceBeginNotification( + Action began) + { + ArgumentNullException.ThrowIfNull(began); + EnsureNotDisposed(); + _initialResidenceBegan += began; + } + /// /// C0-2: forwards to , /// the same fan-out shape already uses for @@ -2270,6 +2324,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return InitialCreateResidences.TryGetCurrent(canonical, out lease); } + /// + /// C3c-R1 review F7: host seam for a bounded-collision-neighborhood + /// host to convert a remote/projectile Create's active residence to the + /// celless completion route when its destination landblock will never + /// be collision-published (a headless far remote). See + /// . + /// + public bool TryConvertInitialResidenceToCellessRoute( + RuntimeEntityRecord canonical) + { + EnsureNotDisposed(); + return InitialCreateResidences.TryConvertToCellessRoute(canonical); + } + internal RuntimeInitialCreateResidenceCompletionStatus CompleteInitialCreateResidence( RuntimeEntityRecord canonical, @@ -2339,7 +2407,19 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable canonical, accepted, isLocalPlayer); - return lease.IsValid; + if (!lease.IsValid) + return false; + // C3c: host drive notification. Fires for EVERY fresh residence + // begin through this single choke point — wire-dispatch Creates AND + // the executor's deferred-child replays (which register through this + // class's own bound delegate, never through a host runtime). The + // subscriber must only RECORD the key for a later drive pump — this + // fires mid-registration, before Registered publishes, and a + // synchronous Advance here would interleave with the enclosing + // transaction (and, for a replayed child, with the parent's own + // in-flight Execute). + _initialResidenceBegan?.Invoke(canonical); + return true; } private Exception FailInitialResidenceRegistration( diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs index 74e3fbb1..14230cda 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs @@ -1007,6 +1007,52 @@ internal sealed class RuntimeInitialCreateResidenceState return _completed.Remove(token.Entity); } + /// + /// C3c-R1 review F7: converts an ACTIVE, not-yet-placed + /// SetPosition-performing residence to the celless + /// (AwaitFreshPosition) route shape, forgetting its authored placement + /// operation. The residence entry itself stays active — the conductor's + /// next pump takes the existing celless skip-to-Execute path and + /// completes with FullCell 0, exactly like a Parented/PickedUp lease. + /// The retirement fan-out is fired to reset conductor/executor progress + /// for the key (its subscribers are pure progress reapers: + /// executor DiscardProgress + both conductors' Forget); + /// the entry itself is deliberately NOT retired. Refused once any + /// placement has committed (FullCellId != 0) — the entity is not + /// a far remote then. + /// + internal bool TryConvertToCellessRoute(RuntimeEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key + || !_entries.TryGetValue(key, out Entry? entry) + || !ReferenceEquals(entry.Record, record)) + { + return false; + } + if (!IsCurrent(entry)) + { + Retire(entry); + return false; + } + RuntimeInitialCreateResidenceLease lease = entry.Lease; + if (!lease.Route.PerformsSetPosition) + return true; + if (record.FullCellId != 0u) + return false; + RuntimePlacementCancellationReceipt cancellation = + _setPosition.ForgetExactPlacement(lease.Placement); + entry.Lease = lease with + { + Route = RuntimeAuthoritativePositionRouteClassifier + .ToCellessCreateRoute(lease.Route), + Placement = default, + }; + _setPosition.PublishCancellation(cancellation); + NotifyRetirement(key); + return true; + } + internal bool Forget( RuntimeEntityRecord record, out RuntimeInitialCreateResidenceLease lease, diff --git a/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs index 9a8ae39e..9563881d 100644 --- a/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs @@ -113,11 +113,13 @@ internal readonly record struct RuntimeRemoteFirstEntryOwnershipSnapshot( /// DormantLocalActivation set, so the ordinary submission tail is the /// correct — and only — commit route. /// -/// Dormant by design: fully -/// constructs and wires this class (construction, retirement fan-out, bulk -/// session-clear cleanup, ownership fold) exactly like the C3a conductor, -/// but nothing calls in production — C3c wires the -/// hosts. +/// PRODUCTION-DRIVEN since the C3c flip: +/// fully constructs and wires this class (construction, retirement fan-out, +/// bulk session-clear cleanup, ownership fold) exactly like the C3a +/// conductor, and the host first-entry drive +/// (RuntimeFirstEntryDriveController) calls +/// for every remote/projectile initial-create residence on both the +/// graphical and headless hosts. /// internal sealed class RuntimeRemoteFirstEntryState { diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 44a5bf5f..1fba5d08 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -263,6 +263,13 @@ public sealed class GameRuntime context.EntityObjects.Physics, context.Movement, context.PlayerIdentity)); + // C3c: the C3a conductor's "first act" — bind the publication + // owner the conductor was constructed without (it is built by + // RuntimeEntityObjectLifetime BEFORE + // RuntimeLocalPlayerPhysicsPublicationState exists; see the F2 + // late-bind note on RuntimeLocalPlayerFirstEntryState's ctor). + context.EntityObjects.LocalPlayerFirstEntry.BindPublication( + context.Movement.PhysicsPublication); context.EntityObjects.BindEventContext( () => generationReset.ActiveRetiringGeneration diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index cc2e8fd5..dd1abd03 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -368,6 +368,38 @@ public sealed class PlayerMovementController _body.calc_acceleration(); } + /// + /// C3c-F1 (2026-08-02): the lifecycle-deciding inbound-SetState entry + /// for the local player. Live states apply the exact + /// body; the dormant window drops the + /// push because the activation transaction owns the dormant body's + /// physics state exclusively ( + /// re-reads the canonical record's FinalPhysicsState at both activation + /// phases, and while the accepted SetState is queued behind the initial + /// residence the App-side push carries that same unchanged record value + /// — the drop is value-preserving by construction); terminal states are + /// displaced pushes (J3.6 displaced-callback-rejection), never a fault. + /// + internal RuntimeServerPhysicsStateApplication ApplyServerPhysicsState( + PhysicsStateFlags state) + { + switch (_publicationLifecycle) + { + case PlayerMovementControllerPublicationLifecycle.StandalonePublished: + case PlayerMovementControllerPublicationLifecycle.CandidatePreparing: + case PlayerMovementControllerPublicationLifecycle.RuntimePublished: + _body.State = state; + _body.calc_acceleration(); + return RuntimeServerPhysicsStateApplication.AppliedLive; + case PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant: + return RuntimeServerPhysicsStateApplication + .DroppedDormantActivationOwned; + default: + return RuntimeServerPhysicsStateApplication + .DroppedDisplacedController; + } + } + public bool IsAirborne => !_body.OnWalkable; /// @@ -1292,6 +1324,114 @@ public sealed class PlayerMovementController lastPkAttackTimestamp); } + /// + /// C3c-F1 (2026-08-02): the lifecycle-deciding half of the Runtime + /// movement-stats application seam + /// (). + /// The publication owner — not any App caller — decides whether a + /// server stat recompute may land: + /// + /// , + /// , and + /// + /// apply immediately — byte-identical to the deleted + /// RuntimeMovementSkillProjection.ApplyTo direct path. + /// + /// ALSO applies immediately: the dormant window (publication committed, + /// activation deferred on cell streaming — + /// RuntimeLocalPlayerFirstEntryState.AdvanceCore's + /// AwaitingActivation loop) spans inbound pumps, and this exact instance + /// is the controller that ActivateRuntimePublication later makes + /// live, so the write must land here (same discipline as + /// / + /// : accepted server facts + /// arriving mid-dormancy land on the dormant owner). These writes touch + /// only fields and the mover-flag latch — + /// no body/world/currency state the activation envelope validates. + /// , + /// , and + /// + /// report the typed displaced-write outcome (J3.6 + /// displaced-callback-rejection): a stat write against a terminal + /// controller is meaningless by design — the next login re-derives from + /// PlayerDescription. A sealed candidate is additionally unreachable + /// through the seam in production: it is never installed into + /// (Prepare requires the + /// movement owner empty and Commit installs it already-dormant in the + /// same synchronous Advance step). + /// + /// + internal RuntimeMovementStatsApplication ApplyCharacterMovementStats( + in RuntimeMovementSkillSnapshot snapshot) + { + switch (_publicationLifecycle) + { + case PlayerMovementControllerPublicationLifecycle.StandalonePublished: + case PlayerMovementControllerPublicationLifecycle.CandidatePreparing: + case PlayerMovementControllerPublicationLifecycle.RuntimePublished: + ApplyCharacterMovementStatsCore(snapshot); + return RuntimeMovementStatsApplication.AppliedLive; + case PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant: + ApplyCharacterMovementStatsCore(snapshot); + return RuntimeMovementStatsApplication.AppliedDormant; + default: + return RuntimeMovementStatsApplication.DroppedDisplacedController; + } + } + + /// + /// The exact application body of the deleted + /// RuntimeMovementSkillProjection.ApplyTo (same fields, same + /// order, same conversions) — moved behind the lifecycle switch so the + /// dormant window can share it without routing through the + /// -gated public setters. + /// Campaign P Slice P1 (2026-07-30): burden/stamina ride the SAME seam + /// run/jump skill already used — see the pseudocode doc §9. TS-23 + /// (Campaign P Slice P3, 2026-07-30): the player's own + /// PK/PKLite/Impenetrable collision-exemption bits and the + /// PlayerKillerStatus/LastPkAttackTimestamp pair the jump-cost PK-timer + /// bump reads — see EntityCollisionFlagsExt.ToMoverState and + /// PlayerWeenie.JumpStaminaCost. + /// + private void ApplyCharacterMovementStatsCore( + in RuntimeMovementSkillSnapshot snapshot) + { + _weenie.SetSkills(snapshot.RunSkill, snapshot.JumpSkill); + _weenie.SetBurden(snapshot.Burden); + _weenie.SetStamina( + snapshot.CurrentStamina < 0 ? null : (uint)snapshot.CurrentStamina); + _ownPvpFlags = EntityCollisionFlagsExt + .FromPwdBitfield(snapshot.OwnPwdBitfield) + .ToMoverState(); + _weenie.SetPlayerKillerStatus( + snapshot.PlayerKillerStatus < 0 ? null : snapshot.PlayerKillerStatus, + snapshot.LastPkAttackTimestamp); + } + + /// + /// C3c-F1: the stamina-exhaustion EVENT dispatch + /// (retail CommandInterpreter::HandleExhaustion @ 0x006b3c70 → + /// CPhysicsObj::report_exhaustion), routed through the owner so + /// App never touches the gated surface. Fires only + /// on a live controller: a dormant owner has no in-flight movement to + /// re-dispatch (retail's handler is a no-op for a player not in world; + /// activation dispatches movement fresh from the already-current + /// stamina gate), and a terminal owner is a + /// displaced callback. + /// + internal bool ReportExhaustionAtMovementBoundary() + { + if (_publicationLifecycle + is PlayerMovementControllerPublicationLifecycle.StandalonePublished + or PlayerMovementControllerPublicationLifecycle.CandidatePreparing + or PlayerMovementControllerPublicationLifecycle.RuntimePublished) + { + _motion.ReportExhaustion(); + return true; + } + return false; + } + /// /// R3-W2 (r3-port-plan.md §4): the player's /// — GameWindow binds the player sequencer's MotionDone seam to it so the @@ -1651,15 +1791,39 @@ public sealed class PlayerMovementController RearmConstraintLeashAtCurrentPosition(); } + /// + /// C3c-R1: arms the login-entry constraint leash from the Runtime + /// publication chain. The flip deleted the only login-path caller of + /// (the App-side + /// call in the old + /// player-mode-entry commit); the dormant activation's final commit + /// (RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit) + /// is the accepted-position event that replaces it — retail arms at + /// every accepted-position event (SmartBox::HandleReceivedPosition + /// 0x00453FD0). The final commit has already activated this controller + /// (ActivateRuntimePublication), so the published guard doubles + /// as a stale-caller check. Like the pre-flip commit path, no + /// UnConstrain teardown is needed: nothing can have armed the leash on + /// a controller whose was created by its + /// own publication candidate. + /// + internal void ArmConstraintLeashAtCommittedPlacement() + { + EnsurePublishedForRuntimeOperation(); + RearmConstraintLeashAtCurrentPosition(); + } + /// /// #167 (Campaign P P5): retail SmartBox::HandleReceivedPosition /// (0x00453fd0) "Player, teleport-newer" branch re-arms the leash /// immediately after TeleportPlayer's teardown, anchored to the /// RECEIVED position (here, the body's just-snapped current position). - /// Shared by the teleport path (after UnConstrain) and the deferred + /// Shared by the teleport path (after UnConstrain), the deferred /// player-mode-entry commit path (), /// which never ran UnConstrain because nothing could have armed the - /// leash before the controller had a . + /// leash before the controller had a , + /// and the C3c first-entry placement commit + /// (). /// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2. /// private void RearmConstraintLeashAtCurrentPosition() diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs index f366c733..2328da2d 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs @@ -131,12 +131,14 @@ internal readonly record struct RuntimeLocalPlayerFirstEntryOwnershipSnapshot( /// half instead /// and never the fused method. /// -/// Dormant by design: fully -/// constructs and wires this class (construction, publication binding, +/// PRODUCTION-DRIVEN since the C3c flip: +/// fully constructs and wires this class (construction, publication binding, /// retirement fan-out, bulk session-clear cleanup, ownership fold) exactly -/// like every other owner it builds, but nothing calls -/// in production — a later slice wires a host to drive -/// it. +/// like every other owner it builds, and the host first-entry drive +/// (RuntimeFirstEntryDriveController, pumped by the graphical +/// hydration/frame-retry cadence and the headless spawn/position/tick +/// cadence) calls for every local-player +/// initial-create residence. /// internal sealed class RuntimeLocalPlayerFirstEntryState { diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs index 942e7249..89f00213 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs @@ -14,6 +14,70 @@ public interface IRuntimeLocalPlayerMotionSource MotionInterpreter? Motion { get; } } +/// +/// C3c-F1 (2026-08-02): typed outcome of routing a server movement-stat +/// recompute through the Runtime movement owner. The dropped outcomes are +/// the J3.6 displaced-callback-rejection pattern — never an exception and +/// never a silent void: the caller logs them under its existing +/// diagnostics. A skill write against a dead session is meaningless by +/// design; the next login re-derives everything from PlayerDescription. +/// +public enum RuntimeMovementStatsApplication +{ + /// Applied to the live (published/standalone) controller — + /// byte-identical to the pre-F1 direct application path. + AppliedLive, + + /// Applied to the Runtime-owned dormant controller during the + /// committed-but-not-yet-activated first-entry window. The same + /// instance goes live at activation, so the values are already current + /// when movement starts. + AppliedDormant, + + /// No controller is installed (pre-first-entry, mid-candidate + /// construction, or after session teardown cleared the owner). + DroppedNoController, + + /// The skill snapshot has no authoritative run/jump values yet + /// (PlayerDescription not processed) — same silent skip as the pre-F1 + /// path. + DroppedIncompleteSnapshot, + + /// The installed controller is terminal (sealed, retired, or + /// discarded): a displaced post-teardown write, reported instead of + /// faulting the session. + DroppedDisplacedController, +} + +/// +/// C3c-F1 (2026-08-02): typed outcome of routing an inbound server +/// PhysicsState push through the local movement controller's publication +/// lifecycle. Same displaced-callback-rejection family as +/// , with one deliberate +/// difference: the dormant window DROPS the push rather than applying it, +/// because the activation transaction owns the dormant body's physics +/// state exclusively (it re-reads the canonical record's FinalPhysicsState +/// through RefreshDormantRuntimePhysicsState at both activation +/// phases), and the App-side push carries that exact same unchanged record +/// value while the accepted SetState itself is queued behind the initial +/// residence — dropping it is value-preserving by construction. +/// +public enum RuntimeServerPhysicsStateApplication +{ + /// Applied to the live (published/standalone) controller — + /// byte-identical to the direct ApplyPhysicsState path. + AppliedLive, + + /// The controller is Runtime-owned dormant: the activation + /// pipeline is the sole authority for the dormant body's physics state + /// and re-reads the canonical value itself. + DroppedDormantActivationOwned, + + /// The installed controller is terminal — a displaced + /// post-teardown push. + DroppedDisplacedController, +} + /// /// Canonical local movement lifetime and intent owner. Graphical input, /// presentation, diagnostics, and future no-window hosts borrow this exact @@ -37,7 +101,13 @@ public sealed class RuntimeLocalPlayerMovementState public PlayerMovementController? Controller { get => _controller; - set + // C3c seal: the public write escape hatch is closed. Production + // controller installation flows only through the publication + // lifecycle (CommitRuntimeOwnedController via + // RuntimeLocalPlayerPhysicsPublicationState.Commit) and teardown + // through ResetSession/Dispose/DiscardActivation. The setter stays + // reachable for tests via InternalsVisibleTo only. + internal set { ObjectDisposedException.ThrowIf(_disposed, this); if (ReferenceEquals(_controller, value)) @@ -189,6 +259,44 @@ public sealed class RuntimeLocalPlayerMovementState return true; } + /// + /// C3c-F1 (2026-08-02): the ONLY route by which server-authoritative + /// movement stats (run/jump skill, burden, stamina, PK status — the + /// exact field set of the deleted + /// RuntimeMovementSkillProjection.ApplyTo) reach the local + /// movement controller. App holds no controller reference for stat + /// application and performs no direct configuration mutation; the + /// owner's publication lifecycle decides whether the write lands + /// (live/dormant) or is reported as a typed displaced drop (terminal) — + /// the fix for the connected-gate post-logout ingest crash at + /// PlayerMovementController.EnsureConfigurationMutable. + /// Deliberately tolerant of a disposed owner: a recompute displaced + /// past teardown observes + /// instead of faulting the session. + /// + public RuntimeMovementStatsApplication ApplyCharacterMovementStats( + RuntimeMovementSkillState skills) + { + ArgumentNullException.ThrowIfNull(skills); + if (_controller is not { } controller) + return RuntimeMovementStatsApplication.DroppedNoController; + RuntimeMovementSkillSnapshot snapshot = skills.Snapshot; + if (!snapshot.IsComplete) + return RuntimeMovementStatsApplication.DroppedIncompleteSnapshot; + return controller.ApplyCharacterMovementStats(snapshot); + } + + /// + /// C3c-F1: routes the stamina-exhaustion EVENT (retail + /// CommandInterpreter::HandleExhaustion) through the owner so the + /// App edge-tracker never touches the gated controller motion surface. + /// Returns false when no live controller can dispatch it (absent, + /// dormant, terminal, or disposed owner) — displaced-callback-tolerant + /// for the same reason as . + /// + public bool ReportExhaustion() => + _controller?.ReportExhaustionAtMovementBoundary() == true; + /// /// Direct-host projection of the same combat readiness query used by the /// graphical attack adapter. A host without a constructed local movement diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs index 8a631030..4080bc11 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -278,13 +278,43 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable getObjectA: id => _physics.TryGetPhysicsHost(id, out var host) ? host : null, - handleUpdateTarget: movement.HandleUpdateTarget, + // C3c: the [autowalk-target]/[autowalk-end] probes moved here + // with controller construction (previously App-side in + // PlayerModeController.BuildControllerAndCamera); they stay on + // the PhysicsDiagnostics owner exactly as before. + handleUpdateTarget: info => + { + if (PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + Console.WriteLine( + $"[autowalk-target] object=0x{info.ObjectId:X8} " + + $"status={info.Status} context={info.ContextId} " + + $"target=({info.TargetPosition.Frame.Origin.X:F2}," + + $"{info.TargetPosition.Frame.Origin.Y:F2}," + + $"{info.TargetPosition.Frame.Origin.Z:F2})"); + } + movement.HandleUpdateTarget(info); + }, interruptCurrentMovement: () => - movement.CancelMoveTo(WeenieError.ActionCancelled)); + { + if (PhysicsDiagnostics.ProbeAutoWalkEnabled + && movement.IsMovingTo()) + { + Console.WriteLine("[autowalk-end] reason=interrupt"); + } + movement.CancelMoveTo(WeenieError.ActionCancelled); + }); movement.MakeMoveToManager(); motion.UnstickFromObject = physicsHost.PositionManager.UnStick; motion.InterruptCurrentMovement = () => + { + if (PhysicsDiagnostics.ProbeAutoWalkEnabled + && movement.IsMovingTo()) + { + Console.WriteLine("[autowalk-end] reason=interrupt"); + } movement.CancelMoveTo(WeenieError.ActionCancelled); + }; controller.PositionManager = physicsHost.PositionManager; // This checkpoint publishes ownership only. The subsequent canonical // SetPosition transaction is the sole authority which may enter the @@ -687,11 +717,106 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable _physics.SetPosition.DispatchDormantLocalActivationShadow(committed); if (!IsCommittedActivationSuffixCurrent(activation, committed)) return committed.Status; + ArmFirstEntryConstraintLeash(activation); + SettleFirstEntryGroundContact(activation); _physics.SetPosition.DispatchDormantLocalActivationPlacement(committed); projection = committed.Projection.Token; return committed.Status; } + /// + /// C3c-R1: the login-entry constraint-leash arm the flip deleted with + /// the App-side CommitPreparedPosition caller. Ordering, with + /// file:line justification: + /// + /// NOT at PreparePreparePositionForCommit (:219) + /// runs with publishSharedState: false and the controller's + /// PositionManager binds only later at :318, so the leash cannot + /// exist there (nor should it: the position is not accepted yet). + /// NOT at publication Commit — the activation's placement + /// evaluation (retail find-placement ring search) may still move or + /// reject the position. + /// HERE, after TryApplyDormantLocalActivationFinalCommit + /// (RuntimeSetPositionState.cs:2494-2516 commits the final cell, + /// activates the controller, and publishes the shared current cell) and + /// inside the same IsCommittedActivationSuffixCurrent gate the + /// settle uses — a stale suffix skips the arm exactly like the settle + /// (never armed on stale authority). + /// BEFORE — retail + /// arms anchored to the RECEIVED position + /// (SmartBox::HandleReceivedPosition 0x00453FD0) and only then + /// simulates the first gravity frame, which the settle compresses; the + /// anchor is therefore the committed placement, not the post-settle + /// pose. + /// Exactly once — _activation is nulled at :716 before + /// this suffix, so a resumed AwaitingFinalShadowPreparation + /// retry can never re-enter it after a successful final commit. + /// + /// + private void ArmFirstEntryConstraintLeash(Activation activation) + { + // Same containment as the settle below: the placement commit has + // already succeeded; a leash-arm failure must not unwind the suffix. + try + { + activation.Controller.ArmConstraintLeashAtCommittedPlacement(); + } + catch + { + _activationDispatchFailureCount++; + } + } + + /// + /// C3c-F5: retail seeds the LOCAL player's ground contact from the first + /// gravity frame after enter_world, never from the placement + /// itself — SmartBox::HandleCreateObject (0x00454C80) runs + /// init_player (0x00455010) then CPhysicsObj::enter_world + /// (0x00455095 → 0x00516170), whose SetPosition validates the + /// spot but records no touch and whose tail only sets ACTIVE (0x80). + /// Every retail CPhysicsObj then simulates, falls the few centimetres + /// onto the floor, and the transition's touch grants the contact plane + /// + CONTACT/ON_WALKABLE. The dormant activation's just-finished commit + /// is the faithful SetPosition port, so a fresh login body would start + /// airborne here; this compresses the settle exactly like the #270 + /// remote-spawn seed (the shared ): + /// a short downward sweep whose real touch produces the state retail's + /// first frame would. No floor within reach (a genuine airborne spawn) + /// leaves the body airborne — the ordinary per-tick gravity fall owns + /// it from there. The body transients this commits ARE the controller's + /// grounded state (PlayerMovementController.CanSendPositionEvent + /// reads InContact && OnWalkable off the same body) and + /// the outbound wire contact bit (LocalPlayerOutboundController + /// serializes that predicate) — the flag ACE's "You can't do that while + /// in the air!" gate reads. + /// + private void SettleFirstEntryGroundContact(Activation activation) + { + // Same post-commit callback-dispatch containment as the ground-edge + // dispatch in CommitActivation: the placement commit has already + // succeeded; a HitGround-side failure must not unwind the suffix. + try + { + _ = SpawnPlacementSettler.TrySettle( + _physics.Engine, + activation.Body, + activation.Body.Position, + activation.Body.CellPosition.ObjCellId, + activation.ActivationPreparation.Radius, + activation.ActivationPreparation.Height, + ObjectInfoState.IsPlayer + | ObjectInfoState.EdgeSlide + | activation.Controller.OwnPvpFlags, + activation.Controller.LocalEntityId, + activation.Movement.HitGround, + activation.Motion.LeaveGround); + } + catch + { + _activationDispatchFailureCount++; + } + } + private bool IsActivationPrephaseEnvelopeCurrent( Activation activation, in RuntimeDormantSetPositionCommitReceipt receipt) => diff --git a/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs b/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs deleted file mode 100644 index 65d77146..00000000 --- a/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs +++ /dev/null @@ -1,41 +0,0 @@ -using AcDream.Core.Physics; - -namespace AcDream.Runtime.Gameplay; - -/// -/// Applies the exact server-owned run/jump snapshot to either host's one local -/// movement controller. This lives beside the canonical skill owner so -/// graphical and no-window construction cannot drift. -/// -public static class RuntimeMovementSkillProjection -{ - public static bool ApplyTo( - RuntimeMovementSkillState skills, - PlayerMovementController? controller) - { - ArgumentNullException.ThrowIfNull(skills); - RuntimeMovementSkillSnapshot snapshot = skills.Snapshot; - if (controller is null || !snapshot.IsComplete) - return false; - - controller.SetCharacterSkills( - snapshot.RunSkill, - snapshot.JumpSkill); - // Campaign P Slice P1 (2026-07-30): burden/stamina ride the SAME - // seam run/jump skill already used — see the pseudocode doc §9. - controller.SetCharacterBurden(snapshot.Burden); - controller.SetCharacterStamina(snapshot.CurrentStamina); - // TS-23 (Campaign P Slice P3, 2026-07-30): the player's own - // PK/PKLite/Impenetrable collision-exemption bits and the - // PlayerKillerStatus/LastPkAttackTimestamp pair the jump-cost - // PK-timer bump reads — see EntityCollisionFlagsExt.ToMoverState - // and PlayerWeenie.JumpStaminaCost. - controller.OwnPvpFlags = - EntityCollisionFlagsExt.FromPwdBitfield(snapshot.OwnPwdBitfield) - .ToMoverState(); - controller.SetCharacterPkStatus( - snapshot.PlayerKillerStatus, - snapshot.LastPkAttackTimestamp); - return true; - } -} diff --git a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs index 3d90357b..60e8ae03 100644 --- a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs +++ b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs @@ -272,6 +272,39 @@ internal static class RuntimeAuthoritativePositionRouteClassifier reporting); } + /// + /// C3c-R1 review F7: converts an already-classified SetPosition-performing + /// initial-Create route into the EXACT celless (AwaitFreshPosition) shape + /// the Parented/PickedUp branch of produces, + /// preserving the route's authority, operation kind, and collision-batch + /// eligibility. A host with a bounded collision neighborhood (headless) + /// applies this to a remote/projectile Create whose destination landblock + /// that neighborhood will never publish — the parked placement's + /// collision-generation wake could otherwise never fire. The residence + /// then completes celless (FullCell stays 0, the accepted wire frame + /// stays on the canonical snapshot), mirroring the pre-flip direct-host + /// accepted-frame behavior for far remotes; a later fresh Position event + /// owns any subsequent placement. + /// + internal static RuntimeAuthoritativePositionRoute ToCellessCreateRoute( + in RuntimeAuthoritativePositionRoute route) => + new( + route.Authority, + RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, + route.OperationKind, + PhysicsSetPositionFlags.None, + 0u, + UnparentBeforeRouting: false, + ApplyPlacementFrameBeforeRouting: false, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainPhase: RuntimePositionConstrainPhase.None, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + route.CollisionBatchEligible); + internal static RuntimeAuthoritativePositionRoute ClassifyAcceptedPosition( in RuntimeAcceptedPositionRouteRequest request) { diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 52f12009..c6fbc561 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -1987,9 +1987,13 @@ public sealed class RuntimePhysicsState : IDisposable { EnsureNotDisposed(); EnsureCollisionMutationThread(); - uint canonical = CanonicalLandblock(landblockId); - if (canonical == 0u) + // C3c-F3: the old `canonical == 0u` check was dead (CanonicalLandblock + // ORs in 0xFFFF, so it never returns 0) — the real absent-id guard is + // on the raw input. Landblock (0,0) canonicalizes to 0x0000FFFF and + // is fully legal here. + if (landblockId == 0u) throw new ArgumentOutOfRangeException(nameof(landblockId)); + uint canonical = CanonicalLandblock(landblockId); return SetPosition.BeginCollisionPrefixQuiescence( canonical, collisionGeneration, @@ -2034,6 +2038,13 @@ public sealed class RuntimePhysicsState : IDisposable { EnsureNotDisposed(); EnsureCollisionMutationThread(); + // C3c-F3: an absent landblock id (0) canonicalizes to 0x0000FFFF — + // the REAL map-corner landblock — so it must be rejected at the + // admission entrance. The prefix-0 sentinel used to (accidentally, + // and only at commit time) catch this caller bug; with prefix + // 0x00000000 now legal, the explicit guard is the only protection. + if (landblockId == 0u) + throw new ArgumentOutOfRangeException(nameof(landblockId)); uint canonical = CanonicalLandblock(landblockId); if (_collisionPrefixMutations.ContainsKey(canonical)) { @@ -2622,9 +2633,13 @@ public sealed class RuntimePhysicsState : IDisposable { EnsureNotDisposed(); EnsureCollisionMutationThread(); - uint canonical = CanonicalLandblock(landblockId); - if (canonical == 0u) + // C3c-F3: absent-id guard on the raw input — the old + // `canonical == 0u` test was dead (CanonicalLandblock never returns + // 0), and the corner landblock (canonical 0x0000FFFF) retires like + // any other. + if (landblockId == 0u) throw new ArgumentOutOfRangeException(nameof(landblockId)); + uint canonical = CanonicalLandblock(landblockId); if (kind is RuntimeCollisionPrefixMutationKind.Activation) throw new ArgumentOutOfRangeException(nameof(kind)); @@ -2944,6 +2959,27 @@ public sealed class RuntimePhysicsState : IDisposable : 1UL; } + /// + /// True when a collision evaluation may read this cell's landblock right + /// now — no admission is in flight for it and its prefix is not quiescing. + /// enforces exactly this + /// per queried prefix, so any owner that is about to DEPEND on a + /// successful seal must consult the same predicate first. C3c-F2: the + /// dormant local-player activation rearm did not, so a collision-generation + /// commit that reentered the first-entry pump before its own admission + /// retired rearmed the parked lease out of AwaitingCell, immediately failed + /// this seal, and — no longer being AwaitingCell — was reported as + /// RejectedAuthority (terminal) instead of "still waiting". That dropped + /// the login conductor for the whole session. + /// + internal bool IsCollisionEvaluationPrefixAdmissible(uint exactCellId) + { + uint landblockId = CanonicalLandblock(exactCellId); + return landblockId != 0u + && !_collisionAdmissions.ContainsKey(landblockId) + && !SetPosition.IsCollisionPrefixQuiescing(landblockId); + } + /// /// Exact collision-prefix generation authority used by private /// SetPosition evaluations. Beginning a replacement generation advances @@ -3021,8 +3057,7 @@ public sealed class RuntimePhysicsState : IDisposable } foreach (uint prefix in prefixes) { - if (_collisionAdmissions.ContainsKey(prefix) - || SetPosition.IsCollisionPrefixQuiescing(prefix)) + if (!IsCollisionEvaluationPrefixAdmissible(prefix)) return false; } diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 263c8f5b..39378bda 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -109,8 +109,14 @@ internal readonly record struct RuntimeCollisionPrefixQuiescenceToken( ulong CollisionGeneration, ulong OperationId) { - internal bool IsValid => LandblockPrefix != 0u - && (LandblockPrefix & 0xFFFFu) == 0u + // C3c-F3: presence is discriminated by OperationId (allocated from a + // monotonic counter starting at 1, so a default token always carries 0) + // and CollisionGeneration (generations also start at 1) — NOT by + // LandblockPrefix != 0. Prefix 0x00000000 is the legitimate prefix of + // landblock (0,0) (id 0x0000FFFF, Dereth's map corner); the old + // prefix-based term made every real corner-landblock token read as + // invalid, wedging TryGetCurrentQuiescence and every release path. + internal bool IsValid => (LandblockPrefix & 0xFFFFu) == 0u && CollisionGeneration != 0UL && OperationId != 0UL; } @@ -778,9 +784,14 @@ internal sealed class RuntimeSetPositionState : IDisposable EnsureNotDisposed(); if (collisionGeneration == 0UL) throw new ArgumentOutOfRangeException(nameof(collisionGeneration)); - uint prefix = landblockId & 0xFFFF0000u; - if (prefix == 0u) + // C3c-F3: reject only the genuinely-absent landblock id (0). Prefix + // 0x00000000 is landblock (0,0) — the map corner — so a prefix == 0 + // test can no longer stand in for "no landblock"; that sentinel + // collision crashed every collision publication whose streaming + // window reached the corner (connected-gate 20260802-135444). + if (landblockId == 0u) throw new ArgumentOutOfRangeException(nameof(landblockId)); + uint prefix = landblockId & 0xFFFF0000u; if (_collisionPrefixQuiescence.TryGetValue( prefix, @@ -1848,6 +1859,45 @@ internal sealed class RuntimeSetPositionState : IDisposable && operation.WakeableLostCell; } + /// + /// C3c-F2: the identity check below is against + /// — the + /// generation the collision world currently HOLDS — not against + /// ExpectedCollisionGeneration, which means two different things + /// at the two ends of this wait. At park time (this class's own + /// TryPrepareDormantLocalActivationCommit) an admission for the + /// destination landblock is in flight, so Expected == that admission's + /// generation G and the lease correctly parks against G. The wake that + /// sets CollisionGenerationReady is + /// CommitCollisionGeneration(lb, G, ready), and the very next + /// statement in RuntimePhysicsState retires the admission + /// (AdvanceCommittedActivation) while leaving the committed generation at + /// G — from that instant Expected returns G+1, a generation that does not + /// exist and may never be begun. Comparing the parked G against Expected + /// therefore refused every login rearm forever (the connected-gate + /// DeferredCell wedge: controller never published, world never visible). + /// The committed-authority comparison keeps every staleness guarantee: a + /// superseding BeginCollisionAdmission or a CancelCollisionGeneration + /// moves the authority off G and this lease still refuses to rearm. + /// + /// + /// The trailing + /// + /// term is the second half of the same C3c-F2 defect and is what the live + /// probe caught: the collision-generation commit reenters the host's + /// first-entry pump BEFORE its own admission is retired + /// (RuntimePhysicsState.cs:2503 commits the generation, :2552-2558 retires + /// the admission). Rearming inside that window moves the lease out of + /// AwaitingCell and the very next evaluation fails + /// TrySealCollisionEvaluationAuthority on the still-registered + /// admission — at which point EvaluateActivation can no longer report + /// DeferredCell (the operation is no longer AwaitingCell) and returns + /// RejectedAuthority, which is TERMINAL for the conductor. Refusing the + /// rearm until the prefix is evaluable keeps the lease parked and + /// retryable, exactly as the remote wake path already does with + /// TryGetBlockingQuiescence (:4069-4095). + /// + /// private bool TryRearmDeferredDormantLocalActivation( RuntimeEntityRecord record, PhysicsBody body, @@ -1868,8 +1918,10 @@ internal sealed class RuntimeSetPositionState : IDisposable || !operation.CollisionGenerationReady || operation.ProjectionSequence != 0UL || operation.CollisionGeneration != _physics - .ExpectedCollisionGeneration(operation.ExactCellId) - || !_physics.Engine.IsSpawnCellReady(operation.ExactCellId)) + .CollisionGenerationAuthority(operation.ExactCellId) + || !_physics.Engine.IsSpawnCellReady(operation.ExactCellId) + || !_physics.IsCollisionEvaluationPrefixAdmissible( + operation.ExactCellId)) { return false; } @@ -3366,14 +3418,19 @@ internal sealed class RuntimeSetPositionState : IDisposable command); CollisionPrefixQuiescence? quiescence = _collisionPrefixQuiescence.GetValueOrDefault(prefix); + // C3c-F3: pass the overrides through as genuinely optional — + // `quiescence?.` yields null (absent) with no quiescence and the + // token's exact values (present, prefix 0x00000000 included) + // with one. The old `?? 0u` collapse made a corner-landblock + // quiescence indistinguishable from "no quiescence". RuntimeSetPositionOutcome parked = ParkDeferred( operation, result, publishImmediately: false, collisionGenerationOverride: - quiescence?.Token.CollisionGeneration ?? 0UL, + quiescence?.Token.CollisionGeneration, collisionPrefixOverride: - quiescence?.Token.LandblockPrefix ?? 0u); + quiescence?.Token.LandblockPrefix); if (_pendingProjection.TryGetValue( parked.Projection.Sequence, out RuntimePlacementProjectionSnapshot staged)) @@ -3929,12 +3986,22 @@ internal sealed class RuntimeSetPositionState : IDisposable _operationPool.Clear(); } + /// + /// C3c-F3: the quiescence-override pair is nullable — null means "no + /// quiescence holds this park", a present value means "parked under that + /// quiescence's exact prefix/generation". Nullable uint is the chosen + /// has-prefix representation for the whole chain because the previous + /// 0-sentinel collided with landblock (0,0)'s legitimate prefix + /// 0x00000000: a corner-landblock quiescence override read as "absent", + /// so derived false and + /// the parked operation skipped the QuiescenceHeld stage entirely. + /// private RuntimeSetPositionOutcome ParkDeferred( Operation operation, in PhysicsSetPositionResult result, bool publishImmediately = true, - ulong collisionGenerationOverride = 0UL, - uint collisionPrefixOverride = 0u) + ulong? collisionGenerationOverride = null, + uint? collisionPrefixOverride = null) { PhysicsBody body = operation.Body!; body.Orientation = result.Orientation; @@ -3969,13 +4036,11 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.WakeableLostCell = true; operation.EnteringWorldFromCelllessResidence = true; ArmLostFamilyDeadlines(operation); - operation.CollisionGeneration = collisionGenerationOverride != 0UL - ? collisionGenerationOverride - : _physics.ExpectedCollisionGeneration(result.CellId); - operation.CollisionPrefix = collisionPrefixOverride != 0u - ? collisionPrefixOverride - : result.CellId & 0xFFFF0000u; - operation.CollisionQuiescenceHeld = collisionPrefixOverride != 0u; + operation.CollisionGeneration = collisionGenerationOverride + ?? _physics.ExpectedCollisionGeneration(result.CellId); + operation.CollisionPrefix = collisionPrefixOverride + ?? result.CellId & 0xFFFF0000u; + operation.CollisionQuiescenceHeld = collisionPrefixOverride.HasValue; operation.Command = operation.Command with { Physics = operation.Command.Physics with diff --git a/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs new file mode 100644 index 00000000..43acaf53 --- /dev/null +++ b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs @@ -0,0 +1,354 @@ +using AcDream.Content; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Session; + +/// +/// C3c: the host-driven pump that walks every initial-Create residence +/// through its first-entry conductor. One instance per host session route; +/// graphical and no-window hosts construct it with their own prepared +/// collision source and local-player activation-preparation provider and +/// call from their own cadence (post-Create +/// hydration and the per-frame placement retry phase for the graphical +/// host; spawn/position projection and the session tick for headless). +/// +/// The controller owns NO placement state — it records which entities hold +/// a fresh residence lease (via +/// ) +/// and repeatedly calls the conductors, which re-validate all currency +/// themselves. Terminal yields (Completed/RejectedToken/RejectedAuthority) +/// drop the entry; every Awaiting*/Contention yield keeps it for the next +/// pump. +/// +/// Continuation placements (the executor's AwaitingContinuationPlacement +/// yield) are completed here through the C0 fused +/// +/// — legal for a continuation operation, which never has +/// DormantLocalActivation set — followed by head acknowledgement. The +/// production sink may consume the resulting Place first (the residence is +/// already consumed by then, so the sink's residence gate does not fire); +/// a failed acknowledgement after that is benign — the executor's +/// ResumePendingPlacement keys off the retained acknowledged completion, +/// not off who acknowledged. +/// +internal sealed class RuntimeFirstEntryDriveController +{ + /// + /// Bounded chase of synchronous progress inside one entity's drive — + /// enough for mover-prep + placement + acknowledgement + a handful of + /// continuation placements in a single pump without risking an unbounded + /// loop against a livelocked yield. + /// + private const int MaxSynchronousStepsPerEntity = 16; + + private sealed class Pending + { + internal required RuntimeEntityRecord Record { get; init; } + internal required RuntimeInitialCreateResidenceToken Token { get; init; } + internal required bool IsLocalPlayer { get; init; } + } + + private readonly RuntimeEntityObjectLifetime _entityObjects; + private readonly IGameRuntimeClock _clock; + private readonly IPreparedCollisionSource _collisionSource; + private readonly Func _localOptions; + private readonly Func _localActivation; + private readonly Dictionary _pending = []; + private readonly List _driveScratch = []; + private bool _driving; + /// C3c-R1 review F6: see . + private object? _routeOwner; + + internal RuntimeFirstEntryDriveController( + RuntimeEntityObjectLifetime entityObjects, + IGameRuntimeClock clock, + IPreparedCollisionSource collisionSource, + Func localOptions, + Func localActivation) + { + _entityObjects = entityObjects + ?? throw new ArgumentNullException(nameof(entityObjects)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _collisionSource = collisionSource + ?? throw new ArgumentNullException(nameof(collisionSource)); + _localOptions = localOptions + ?? throw new ArgumentNullException(nameof(localOptions)); + _localActivation = localActivation + ?? throw new ArgumentNullException(nameof(localActivation)); + _entityObjects.BindInitialResidenceBeginNotification( + NoteResidenceBegan); + // C3c-R1 review F5: tracked-but-undriven entries fold into the + // entity-object ownership snapshot instead of sitting outside every + // ledger. + _entityObjects.RegisterFirstEntryDriveOwnership(() => _pending.Count); + } + + internal int PendingCount => _pending.Count; + + /// + /// Records a fresh residence for a later pump. Runs synchronously inside + /// the registration transaction (including the executor's deferred-child + /// replays, which re-enter registration mid-Execute), so it must never + /// call Advance here — only capture the exact key/token/dispatch facts. + /// + private void NoteResidenceBegan(RuntimeEntityRecord record) + { + if (record.Key is not { } key + || !_entityObjects.TryGetInitialCreateResidence( + record, + out RuntimeInitialCreateResidenceLease lease)) + { + return; + } + + _pending[key] = new Pending + { + Record = record, + Token = lease.Token, + // Dispatch is decided ONCE from the lease's classified route — + // TryGetCurrent fails mid-drain (the residence moves to its + // completed table at Complete), so the lease cannot be + // re-fetched on a later pump. + IsLocalPlayer = lease.Route.OperationKind + is RuntimeSetPositionOperationKind.InitialLogin, + }; + } + + /// + /// Drives every tracked first-entry sequence one bounded step. Safe to + /// call from any host cadence point; re-entrant calls (a conductor's own + /// synchronous callbacks reaching a host pump) fail closed into the next + /// outer pump instead of interleaving. + /// + internal void DriveAll() + { + if (_driving || _pending.Count == 0) + return; + _driving = true; + try + { + _driveScratch.Clear(); + foreach (RuntimeEntityKey key in _pending.Keys) + _driveScratch.Add(key); + foreach (RuntimeEntityKey key in _driveScratch) + { + if (_pending.TryGetValue(key, out Pending? pending)) + DriveOne(key, pending); + } + } + finally + { + _driving = false; + } + } + + /// + /// C3c-R1 review F6: the explicit one-route-at-a-time latch. A drive + /// controller outlives its session routes (hosts reuse it across + /// reconnects), and route teardown clears the tracked entries — so the + /// "session reset precedes a new route" ordering the hosts rely on is + /// asserted here instead of silently assumed: a second route attaching + /// before the prior route detached would otherwise let the OLD route's + /// dispose wipe the NEW route's tracked entries. + /// + internal void AttachRoute(object route) + { + ArgumentNullException.ThrowIfNull(route); + if (_routeOwner is not null && !ReferenceEquals(_routeOwner, route)) + { + throw new InvalidOperationException( + "A first-entry drive controller serves one session route at " + + "a time; the prior route must be disposed (session reset " + + "precedes a new route) before a replacement attaches."); + } + _routeOwner = route; + } + + /// + /// Route-scoped teardown: clears every tracked entry, but ONLY when + /// is the attached owner — a route that never + /// attached (construction rollback) or was displaced must not clear the + /// live route's entries. The conductors and residence own their own + /// convergence independently (retirement fan-out + session clear). + /// + internal void DetachRoute(object route) + { + ArgumentNullException.ThrowIfNull(route); + if (!ReferenceEquals(_routeOwner, route)) + return; + _routeOwner = null; + _pending.Clear(); + } + + private void DriveOne(RuntimeEntityKey key, Pending pending) + { + for (int step = 0; step < MaxSynchronousStepsPerEntity; step++) + { + if (pending.Record.Key != key) + { + // Post-teardown key release; the retirement fan-out already + // reaped the conductors' own progress. + _pending.Remove(key); + return; + } + + bool terminal; + bool awaitingContinuationPlacement; + if (pending.IsLocalPlayer) + { + RuntimeLocalPlayerFirstEntryStatus status = + _entityObjects.LocalPlayerFirstEntry.Advance( + pending.Record, + pending.Token, + _localOptions(), + _localActivation(pending.Record), + _collisionSource, + _clock.SimulationTimeSeconds, + inputs: default, + out _); + terminal = status + is RuntimeLocalPlayerFirstEntryStatus.Completed + or RuntimeLocalPlayerFirstEntryStatus.RejectedToken + or RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + awaitingContinuationPlacement = status + is RuntimeLocalPlayerFirstEntryStatus + .AwaitingContinuationPlacement; + } + else + { + RuntimeRemoteFirstEntryStatus status = + _entityObjects.RemoteFirstEntry.Advance( + pending.Record, + pending.Token, + _collisionSource, + _clock.SimulationTimeSeconds, + inputs: default, + out _, + out _); + terminal = status + is RuntimeRemoteFirstEntryStatus.Completed + or RuntimeRemoteFirstEntryStatus.RejectedToken + or RuntimeRemoteFirstEntryStatus.RejectedAuthority; + awaitingContinuationPlacement = status + is RuntimeRemoteFirstEntryStatus + .AwaitingContinuationPlacement; + } + + if (terminal) + { + _pending.Remove(key); + return; + } + if (!awaitingContinuationPlacement) + { + // AwaitingCollisionSource / AwaitingActivation / + // AwaitingPlacement / AwaitingReceiptAcknowledgement / + // Contention — nothing more this pump can do synchronously. + return; + } + if (!TryCompleteContinuationPlacement(key, pending.Record)) + return; + // A continuation placement progressed — re-Advance so the + // executor can consume the acknowledged completion and keep + // draining. + } + } + + /// + /// Completes (or makes bounded progress on) the executor's pending + /// continuation placement for . Returns true when + /// enough progress happened that re-calling Advance can observe it. + /// + private bool TryCompleteContinuationPlacement( + RuntimeEntityKey key, + RuntimeEntityRecord record) + { + RuntimeSetPositionState setPosition = + _entityObjects.Physics.SetPosition; + + // A receipt of OURS already at the FIFO head (a Place from a prior + // submit attempt, or the Withdraw of a deferred park) is consumed + // first — acknowledgement is what re-arms a parked operation and what + // ResumePendingPlacement's retained-completion check requires. + bool acknowledgedSomething = false; + while (setPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head) + && head.Token.Entity == key + && head.Kind is RuntimePlacementProjectionKind.Place + or RuntimePlacementProjectionKind.Withdraw) + { + if (!setPosition.AcknowledgeProjection(head.Token)) + break; + acknowledgedSomething = true; + } + + if (!_entityObjects.InitialCreateExecution + .TryGetPendingContinuationPlacement( + key, + out RuntimeEntityPlacementToken placement)) + { + // Flavor 2 (transient operation-slot contention): no token was + // ever begun; the only correct action is a later Execute retry. + return acknowledgedSomething; + } + if (!_entityObjects.InitialCreateExecution + .TryGetPendingContinuationRoute( + key, + out RuntimeAuthoritativePositionRoute route)) + { + return acknowledgedSomething; + } + + RuntimeSetPositionMoverPreparationStatus status = + setPosition.TryPrepareAndSubmitAuthoredPlacement( + record, + placement, + route.OperationKind, + route.SetPositionFlags, + _collisionSource, + _clock.SimulationTimeSeconds, + out RuntimeSetPositionOutcome outcome); + if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) + { + // RetrySetupUnavailable retries on a later pump; a rejected + // preparation for an already-submitted-and-awaiting operation is + // driven purely by the head acknowledgements above. + return acknowledgedSomething; + } + + switch (outcome.Status) + { + case RuntimeSetPositionStatus.CommittedHostAcknowledgementPending: + // The synchronous publish may already have let the production + // sink apply-and-acknowledge this exact receipt (the + // residence is consumed by drain time, so the sink's + // residence gate no longer declines it). A false return here + // is therefore benign; the retained acknowledged completion + // is what the executor consumes either way. + _ = setPosition.AcknowledgeProjection(outcome.Projection); + return true; + case RuntimeSetPositionStatus.DeferredCell: + // Parked with a published Withdraw; consume it if it is + // already the head so the collision-generation wake can + // resubmit. + while (setPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot parked) + && parked.Token.Entity == key + && parked.Kind is RuntimePlacementProjectionKind.Withdraw) + { + if (!setPosition.AcknowledgeProjection(parked.Token)) + break; + acknowledgedSomething = true; + } + return acknowledgedSomething; + default: + // Rejected/Cancelled — authority moved; the next Advance + // observes it and abandons through the conductor's own path. + return true; + } + } +} diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index 4fada9f0..55140db2 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -72,8 +72,28 @@ public sealed class RuntimeLiveEntitySessionController private void OnSpawned(WorldSession.EntitySpawn spawn) { - RuntimeEntityRegistrationResult registration = - Entities.RegisterEntity(spawn); + // C3c route-8 flip: every direct-host Create enters the SAME initial + // residence lease graphical route 1 uses; the conductor drive (via + // IRuntimeDirectWorldProjection.ProjectSpawn and the host's pump) + // owns mover preparation, body/controller construction, placement, + // and the FIFO drain from here. + // + // C3c-R1 review R3: a CONTENT-LESS host (a validated-legal headless + // configuration — HeadlessConfigurationLoader.ValidateContent + // accepts a null process.content) constructs no world projection + // and therefore no first-entry drive; opening a residence with no + // drive to pump it would park every Create (and every position/ + // state packet queued behind its pending residence) forever. That + // configuration keeps the exact pre-flip legacy registration: + // presentation-free RegisterEntity plus the direct accepted-frame + // commit below. C4/C5 revisit: unify once the direct-host conductor + // drive no longer requires prepared content. + RuntimeEntityRegistrationResult registration = _worldProjection is null + ? Entities.RegisterEntity(spawn) + : Entities.RegisterEntityWithInitialResidence( + spawn, + isLocalPlayer: spawn.Guid + == _runtime.PlayerIdentity.ServerGuid); if (registration.Canonical is not { } canonical) return; diff --git a/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs b/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs new file mode 100644 index 00000000..056140cc --- /dev/null +++ b/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs @@ -0,0 +1,59 @@ +namespace AcDream.App.Tests.Input; + +/// +/// C3c-F2 (2026-08-02): source pin for the production player-mode auto-entry +/// precondition. PlayerModeAutoEntry is a ONE-SHOT that disarms before +/// invoking its callback, and the production callback completes the world +/// reveal, so an attempt made before the Runtime first-entry conductor has +/// committed permanently seals the reveal with the player never in world — +/// the second link of the connected-gate login wedge +/// (logs/connected-world-gate-20260802-130455: one "not committed yet" line, +/// then event=complete with materialized=False and 5,577 +/// readiness-after-terminal rejections). The production context therefore has +/// to report the same commit PlayerModeController.TryEnter requires. +/// The guard's own one-shot/latch behavior is covered behaviorally by +/// AcDream.Core.Tests.Input.AutoEnterPlayerModeTests; only the production +/// context's dependency graph (a ~15-dependency PlayerModeController) has no +/// focused harness, hence the source pin. +/// +public sealed class C3cF2AutoEntryWiringTests +{ + [Fact] + public void ProductionAutoEntryRequiresTheRuntimePublishedController() + { + string source = ReadSource("Input", "PlayerModeAutoEntry.cs"); + + Assert.DoesNotContain( + "public bool IsPlayerControllerReady => true;", + source, + StringComparison.Ordinal); + Assert.Contains( + "IsRuntimePublished: true", + source, + StringComparison.Ordinal); + Assert.Contains( + "record.PhysicsHost is EntityPhysicsHost", + source, + StringComparison.Ordinal); + } + + private static string ReadSource(params string[] relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return File.ReadAllText(Path.Combine( + directory.FullName, + "src", + "AcDream.App", + Path.Combine(relativePath))); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs b/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs index b1292cf3..635459f7 100644 --- a/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs +++ b/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs @@ -12,24 +12,200 @@ namespace AcDream.App.Tests; /// internal static class LiveEntityRuntimeFixture { + /// + /// C3c: initial-Create registration now begins the canonical residence + /// lease, whose admission requires a live session generation. Focused + /// App fixtures bind a fixed non-zero token exactly like the Runtime + /// conductor fixtures do. + /// + private static RuntimeEntityObjectLifetime WithGeneration( + RuntimeEntityObjectLifetime lifetime) + { + lifetime.BindEventContext( + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + static () => 1UL); + return lifetime; + } + public static LiveEntityRuntime Create( GpuWorldState spatial, ILiveEntityResourceLifecycle resources, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { - var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId); + var lifetime = WithGeneration( + new RuntimeEntityObjectLifetime(firstLocalEntityId)); return new LiveEntityRuntime(spatial, resources, lifetime); } + /// + /// C3c: a runtime whose initial-create residences can actually be driven + /// to completion — collision generation committed for + /// , the production + /// + /// constructed against the lifetime, and an acknowledge-only placement + /// subscription mirroring the host rules (Discard/ExecutorCompleted + /// acknowledged; Place/Withdraw left at the head for the conductors). + /// Tests exercising post-residence legacy update paths register, then + /// call to complete the + /// conductors exactly like the composed host's Create-transaction pump. + /// + internal sealed class DrivenLiveEntityRuntime + { + internal required LiveEntityRuntime Runtime { get; init; } + internal required RuntimeEntityObjectLifetime Lifetime { get; init; } + internal required AcDream.Runtime.Session.RuntimeFirstEntryDriveController + FirstEntry { get; init; } + internal required AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription Subscription { get; init; } + + internal void Pump() => FirstEntry.DriveAll(); + } + + public static DrivenLiveEntityRuntime CreateDriven( + GpuWorldState spatial, + ILiveEntityResourceLifecycle resources, + uint landblockId = 0x01010000u) + { + var lifetime = WithGeneration(new RuntimeEntityObjectLifetime()); + lifetime.Physics.SetPosition.BeginCollisionGeneration( + landblockId & 0xFFFF0000u, 1UL); + lifetime.Physics.Engine.AddLandblock( + landblockId & 0xFFFF0000u, + new AcDream.Core.Physics.TerrainSurface( + new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + landblockId & 0xFFFF0000u, 1UL, ready: true); + var runtime = new LiveEntityRuntime(spatial, resources, lifetime); + var movement = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerMovementState(); + var identity = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerIdentityState(); + var publication = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsPublicationState( + lifetime.Entities, + lifetime.Physics, + movement, + identity); + movement.AttachPhysicsPublication(publication); + lifetime.LocalPlayerFirstEntry.BindPublication(publication); + var firstEntry = new AcDream.Runtime.Session + .RuntimeFirstEntryDriveController( + lifetime, + new AcDream.Runtime.GameRuntimeClock(), + new SphereCollisionSource(), + static () => AcDream.Runtime.Gameplay + .PlayerMovementConstructionOptions.Fallback, + static _ => new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, + 1.835f, + AcDream.Runtime.Gameplay + .RuntimeLocalPlayerShadowDisposition + .ProvenShapeless)); + var subscription = new AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription( + lifetime.Placements, + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + new AckOnlyPlacementSink(runtime)); + return new DrivenLiveEntityRuntime + { + Runtime = runtime, + Lifetime = lifetime, + FirstEntry = firstEntry, + Subscription = subscription, + }; + } + + private sealed class AckOnlyPlacementSink(LiveEntityRuntime runtime) + : AcDream.Runtime.Physics.IRuntimePlacementProjectionSink + { + public bool TryApply( + in AcDream.Runtime.Physics.RuntimePlacementProjectionSnapshot + projection) + { + if (projection.Kind is AcDream.Runtime.Physics + .RuntimePlacementProjectionKind.Discard) + { + return true; + } + if (projection.Kind is AcDream.Runtime.Physics + .RuntimePlacementProjectionKind.ExecutorCompleted) + { + return projection.Token.ExactCellId == 0u + || runtime.TryApplyInitialCreateCompletionPresentation( + in projection); + } + return !runtime.HasActiveInitialCreateResidence( + projection.Token.Entity) + && runtime.TryApplyRuntimePlacementProjection(in projection); + } + } + + private sealed class SphereCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision>.Loaded( + new AcDream.Core.Physics.FlatSetupCollision( + System.Collections.Immutable.ImmutableArray< + AcDream.Core.Physics.FlatCollisionCylinder>.Empty, + [new AcDream.Core.Physics.FlatCollisionSphere( + System.Numerics.Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatGfxObjCollisionAsset> + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatCellStructureCollisionAsset> + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } + public static LiveEntityRuntime Create( GpuWorldState spatial, ILiveEntityResourceLifecycle resources, PhysicsEngine physicsEngine, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { - var lifetime = new RuntimeEntityObjectLifetime( + var lifetime = WithGeneration(new RuntimeEntityObjectLifetime( physicsEngine, - firstLocalEntityId); + firstLocalEntityId)); return new LiveEntityRuntime(spatial, resources, lifetime); } @@ -39,7 +215,8 @@ internal static class LiveEntityRuntimeFixture Action tearDownRuntimeComponents, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { - var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId); + var lifetime = WithGeneration( + new RuntimeEntityObjectLifetime(firstLocalEntityId)); return new LiveEntityRuntime( spatial, resources, @@ -53,7 +230,8 @@ internal static class LiveEntityRuntimeFixture ILiveEntityRuntimeComponentLifecycle runtimeComponentLifecycle, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { - var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId); + var lifetime = WithGeneration( + new RuntimeEntityObjectLifetime(firstLocalEntityId)); return new LiveEntityRuntime( spatial, resources, @@ -68,9 +246,9 @@ internal static class LiveEntityRuntimeFixture PhysicsEngine physicsEngine, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { - var lifetime = new RuntimeEntityObjectLifetime( + var lifetime = WithGeneration(new RuntimeEntityObjectLifetime( physicsEngine, - firstLocalEntityId); + firstLocalEntityId)); return new LiveEntityRuntime( spatial, resources, diff --git a/tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs b/tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs new file mode 100644 index 00000000..336b5ae6 --- /dev/null +++ b/tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs @@ -0,0 +1,53 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Tests; + +/// +/// C3c: initial-Create registration now freezes the raw create for the +/// canonical residence lease and requires the flattened parser projections +/// to agree with the nested PhysicsDesc block +/// (RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent). +/// Legacy hand-built fixture spawns predate that gate; this helper derives +/// the minimal consistent nested block from the flattened fields. +/// +internal static class LiveEntitySpawnFixture +{ + internal static WorldSession.EntitySpawn WithConsistentPhysics( + this WorldSession.EntitySpawn spawn) => spawn with + { + Physics = new PhysicsSpawnData( + RawState: spawn.PhysicsState ?? 0u, + Position: spawn.Position, + Movement: null, + AnimationFrame: spawn.PlacementId, + SetupTableId: spawn.SetupTableId, + MotionTableId: spawn.MotionTableId, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: spawn.ParentGuid is { } parentGuid + && spawn.ParentLocation is { } parentLocation + ? new PhysicsAttachment(parentGuid, parentLocation) + : null, + Children: null, + Scale: spawn.ObjScale, + Friction: spawn.Friction, + Elasticity: spawn.Elasticity, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: new PhysicsTimestamps( + Position: spawn.PositionSequence, + Movement: spawn.MovementSequence, + State: 0, + Vector: 0, + Teleport: 0, + ServerControlledMove: spawn.ServerControlSequence, + ForcePosition: 0, + ObjDesc: 0, + Instance: spawn.InstanceSequence)), + }; +} diff --git a/tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs b/tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs new file mode 100644 index 00000000..72851261 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs @@ -0,0 +1,204 @@ +using System.Net; +using AcDream.App.Net; +using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Physics; +using AcDream.Core.Social; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.Net; + +/// +/// C3c-F1 (2026-08-02): the movement-stats application seam driven through +/// the REAL inbound chain that crashed the connected lifecycle gate — +/// ClientObjectTable.IngestObjectAdded/ObjectUpdated → +/// LiveSessionEventRouter.RecomputePlayerQualities → +/// OnMovementStatsUpdated → +/// . +/// +public sealed class LiveMovementStatsApplierTests +{ + private const uint PlayerGuid = 0x5000000Au; + + private sealed class Harness : IDisposable + { + public WorldSession Session { get; } + public LiveSessionEventRouter Router { get; } + public ClientObjectTable Objects { get; } = new(); + public RuntimeCharacterState Character { get; } = new(); + public RuntimeLocalPlayerMovementState Movement { get; } = new(); + public LiveMovementStatsApplier Applier { get; } + public List Log { get; } = []; + + public Harness() + { + Session = new WorldSession(new IPEndPoint(IPAddress.Loopback, 9)); + // The REAL applier the factory binds (LiveSessionRuntimeFactory + // constructs the same class over the same owner pair) with the + // REAL character-bindings recompute callback shape. + Applier = new LiveMovementStatsApplier( + Movement, + Character.MovementSkills, + Log.Add); + Router = new LiveSessionEventRouter( + Session, + new LiveEntitySessionSink( + _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, + _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }), + new LiveEnvironmentSessionSink(_ => { }, _ => { }), + new LiveInventorySessionBindings( + Objects, + PlayerGuid: () => PlayerGuid, + OnShortcuts: null, + OnUseDone: null, + ItemMana: new ItemManaState(), + ExternalContainers: new ExternalContainerState()), + new LiveCharacterSessionBindings( + new CombatState(), + Character, + ResolveSkillFormulaBonus: null, + OnSkillsUpdated: (_, _) => Applier.Apply("skills"), + OnConfirmationRequest: null, + OnConfirmationDone: null, + ClientTime: () => 0d, + OnMovementStatsUpdated: () => Applier.Apply("stats")), + new LiveSocialSessionBindings( + new ChatLog(), + new TurbineChatState(), + new FriendsState(), + new SquelchState())); + Router.Attach(); + } + + /// + /// Ingests the player's own object row — the exact post-logout + /// inbound-Create edge (ApplyAcceptedSpawn → + /// ClientObjectTable.Ingest) that fired the crashing + /// recompute in logs/connected-world-gate-20260802-122749. + /// + public void IngestPlayerRow(uint? pwdBitfield = null) => + Objects.Ingest(new WeenieData( + Guid: PlayerGuid, Name: "+Acdream", Type: ItemType.Creature, + WeenieClassId: 1u, IconId: 0, IconOverlayId: 0, + IconUnderlayId: 0, Effects: 0, + Value: null, StackSize: null, StackSizeMax: null, Burden: null, + ContainerId: null, WielderId: null, ValidLocations: null, + CurrentWieldedLocation: null, Priority: null, + ItemsCapacity: null, ContainersCapacity: null, + Structure: null, MaxStructure: null, Workmanship: null, + PublicWeenieBitfield: pwdBitfield)); + + public void Dispose() + { + Router.Dispose(); + Session.Dispose(); + Movement.Dispose(); + Character.Dispose(); + } + } + + private static PlayerMovementController NewDormantRuntimeController() + { + PlayerMovementController controller = + PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + controller.SealPublicationCandidate(); + controller.CommitRuntimeOwnership(new RetailObjectQuantumClock()); + return controller; + } + + [Fact] + public void PostTeardownIngestRecomputeReportsTypedDropInsteadOfCrashing() + { + using var harness = new Harness(); + // The session's authoritative skills were complete before teardown. + harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180); + + // Post-teardown transient truth: a retired controller still + // reachable through the displaced recompute callback — the state the + // old direct path crashed on ("A sealed, retired, or discarded + // Runtime movement controller cannot be mutated"). + PlayerMovementController controller = NewDormantRuntimeController(); + harness.Movement.Controller = controller; + controller.ActivateRuntimePublication(); + controller.RetireRuntimePublication(); + Assert.Throws( + () => controller.SetCharacterSkills(1, 1)); + + // The exact crash edge: a post-logout inbound Create's object-table + // ingest fires the quality recompute through the real router. + harness.IngestPlayerRow(); + + Assert.Contains( + harness.Log, + line => line.StartsWith( + "player: dropped displaced movement stats", + StringComparison.Ordinal)); + Assert.DoesNotContain( + harness.Log, + line => line.StartsWith( + "player: applied server movement", + StringComparison.Ordinal)); + } + + [Fact] + public void DormantWindowIngestRecomputeAppliesToTheControllerThatGoesLive() + { + using var harness = new Harness(); + harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180); + harness.Character.MovementSkills.UpdateStamina(37); + + // The committed-but-unactivated first-entry window (activation + // deferred on cell streaming) with inbound ingests still pumping. + PlayerMovementController controller = NewDormantRuntimeController(); + harness.Movement.Controller = controller; + Assert.True(controller.IsRuntimeOwnedDormant); + + // BF_PLAYER (0x8) + BF_PLAYER_KILLER (0x20) on the player's own row + // exercises the full stat set through the recompute. + harness.IngestPlayerRow(pwdBitfield: 0x28u); + + Assert.Contains( + harness.Log, + line => line.StartsWith( + "player: applied server movement stats", + StringComparison.Ordinal)); + + // The same instance goes live at activation with the values already + // current. + controller.ActivateRuntimePublication(); + IWeenieObject weenie = controller.Motion.WeenieObj!; + Assert.True(weenie.InqRunRate(out float runRate)); + Assert.True(runRate > 0f); + Assert.Equal( + ObjectInfoState.IsPK, + controller.OwnPvpFlags & ObjectInfoState.IsPK); + } + + [Fact] + public void AbsentControllerAndIncompleteSnapshotStaySilent() + { + using var harness = new Harness(); + + // Incomplete snapshot (no PlayerDescription yet) with no controller: + // byte-identical to the pre-F1 silent skip — no log line at all. + harness.IngestPlayerRow(); + Assert.DoesNotContain( + harness.Log, + line => line.StartsWith("player:", StringComparison.Ordinal)); + + // Complete snapshot but still no controller (pre-first-entry): + // still the silent skip. + harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180); + Assert.Equal( + RuntimeMovementStatsApplication.DroppedNoController, + harness.Applier.Apply("stats")); + Assert.DoesNotContain( + harness.Log, + line => line.StartsWith("player:", StringComparison.Ordinal)); + } +} diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs index 33f4c3a1..51541dcd 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -257,7 +257,12 @@ public sealed class LiveSessionResetPlanTests spatial, new FailingOnceResources(), runtime.EntityObjects); - live.RegisterLiveEntity(Spawn(player, 1, 1, 0x01010001u)); + // C3c: this GameRuntime has no session, so its generation is zero and + // residence-based registration (RegisterLiveEntity) correctly refuses + // an initial world Create. The subject here is reset/teardown retry, + // not the create flow — seed the entity through the legacy direct + // Runtime registration, which needs no residence lease. + runtime.EntityObjects.RegisterEntity(Spawn(player, 1, 1, 0x01010001u)); live.MaterializeLiveEntity( player, 0x01010001u, diff --git a/tests/AcDream.App.Tests/Physics/C3cF1ProductionWiringTests.cs b/tests/AcDream.App.Tests/Physics/C3cF1ProductionWiringTests.cs new file mode 100644 index 00000000..967c15e4 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/C3cF1ProductionWiringTests.cs @@ -0,0 +1,53 @@ +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.Physics; + +/// +/// C3c-F1 (2026-08-02): source pins for the App-side halves of the +/// movement-owner application seams whose production graphs have no +/// focused harness (the inbound network-update controller's dependency +/// set is composition-only). The Runtime lifecycle matrices carry the +/// behavioral coverage; these pins keep the App wiring routed through the +/// owner's typed entries instead of the throwing direct mutations that +/// crashed the connected lifecycle gate twice +/// (logs/connected-world-gate-20260802-122749 — SetCharacterSkills; +/// logs/connected-world-gate-20260802-125907 — ApplyPhysicsState). +/// +public sealed class C3cF1ProductionWiringTests +{ + [Fact] + public void LocalPlayerInboundSetState_RoutesThroughTheTypedOwnerEntry() + { + string source = ReadSource( + "Physics", + "LiveEntityNetworkUpdateController.cs"); + + Assert.Single( + Regex.Matches(source, @"ApplyServerPhysicsState\(") + .Cast()); + Assert.DoesNotContain( + ".ApplyPhysicsState(", + source, + StringComparison.Ordinal); + } + + private static string ReadSource(params string[] relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return File.ReadAllText(Path.Combine( + directory.FullName, + "src", + "AcDream.App", + Path.Combine(relativePath))); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs b/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs index dbd1b5d4..b83fa9d1 100644 --- a/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs +++ b/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs @@ -7,20 +7,40 @@ public sealed class Issue270ProductionWiringTests [Fact] public void MovementStats_UseOneEdgeTrackerAndResetItWithTheSession() { - string source = ReadSource("Net", "LiveSessionRuntimeFactory.cs"); + // C3c-F1 (2026-08-02): the #270 invariant is unchanged — exactly one + // stamina-exhaustion edge tracker, exhaustion dispatched once on the + // edge, tracker reset with the session — but the wiring moved from + // the factory's deleted ApplyMovementStats body into + // LiveMovementStatsApplier, which routes through the Runtime + // movement owner's typed seam instead of touching the controller. + string applier = ReadSource("Net", "LiveMovementStatsApplier.cs"); + string factory = ReadSource("Net", "LiveSessionRuntimeFactory.cs"); Assert.Contains( "_staminaExhaustion.Observe(snapshot.CurrentStamina)", - source, + applier, StringComparison.Ordinal); Assert.Single( Regex.Matches( - source, - @"controller!\.Motion\.ReportExhaustion\(\);") + applier, + @"_movement\.ReportExhaustion\(\);") .Cast()); Assert.Contains( "_staminaExhaustion.Reset();", - source, + applier, + StringComparison.Ordinal); + Assert.Contains( + "_movementStats.Reset();", + factory, + StringComparison.Ordinal); + // The factory keeps zero direct controller mutations: both stat + // callbacks route through the applier's seam. + Assert.Equal( + 2, + Regex.Matches(factory, @"_movementStats\.Apply\(").Count); + Assert.DoesNotContain( + "Motion.ReportExhaustion", + factory, StringComparison.Ordinal); } @@ -38,8 +58,11 @@ public sealed class Issue270ProductionWiringTests Assert.Equal( 3, Regex.Matches(source, @"SeedRemoteSpawnPlacement\(").Count); + // C3c-F5: the settle helper moved to Core (SpawnPlacementSettler) so + // the local player's Runtime first-entry activation shares the same + // tested compressed-first-gravity-frame sweep. Assert.Contains( - "RemoteSpawnPlacementSettler.TrySettle(", + "SpawnPlacementSettler.TrySettle(", source, StringComparison.Ordinal); } diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs index d2662229..accb5b6c 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs @@ -221,7 +221,12 @@ public sealed class LiveEntityInboundAuthorityGateTests out AcceptedPositionNetworkUpdate accepted)); Assert.Same(registration.Canonical, accepted.Canonical); - Assert.Equal((ulong)2, accepted.PositionAuthorityVersion); + // C3c: the accepted Position of an entity whose initial-create + // residence is still pending is admitted into the residence FIFO — + // its merge (and the position-authority advance) commits at the + // executor drain, so the gate's captured authority is the + // admission-time version, not a post-apply bump. + Assert.Equal((ulong)1, accepted.PositionAuthorityVersion); Assert.Equal(1, publishCount); Assert.False(runtime.TryGetRecord(Guid, out _)); } diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index 3ff794ea..aea08387 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -1112,7 +1112,10 @@ public sealed class RemotePhysicsUpdaterTests 0f, 0f, 0f); - var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + // C3c: residence admission requires the nested block's Instance + // timestamp to agree with the flattened InstanceSequence. + var timestamps = new PhysicsTimestamps( + 1, 1, 1, 1, 0, 1, 0, 1, instanceSequence); var physics = new PhysicsSpawnData( RawState: (uint)state, Position: serverPosition, diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs index 61ff1ca2..779aa605 100644 --- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -466,6 +466,7 @@ public sealed class EquippedChildProjectionWithdrawalTests ChildInstanceSequence: 1, ChildPositionSequence: 1); + fixture.CompleteFirstEntry(); Assert.True(fixture.Live.TryApplyCreateParent(update, out _)); fixture.Controller.OnCreateParentAccepted(update); @@ -509,13 +510,20 @@ public sealed class EquippedChildProjectionWithdrawalTests LiveEntityRecord parent = fixture.Spawn(0x70000270u, generation: 1); WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData( 0x70000271u, - generation: 1) with + generation: 1); + childSpawn = childSpawn with { Position = null, ParentGuid = parent.ServerGuid, ParentLocation = 0, PlacementId = null, PositionSequence = 0, + Physics = childSpawn.Physics!.Value with + { + Position = null, + Parent = new PhysicsAttachment(parent.ServerGuid, 0u), + AnimationFrame = null, + }, }; fixture.Live.RegisterLiveEntity(childSpawn); fixture.Controller.OnSpawn(childSpawn); @@ -558,13 +566,20 @@ public sealed class EquippedChildProjectionWithdrawalTests LiveEntityRecord parent = fixture.Spawn(0x70000272u, generation: 1); WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData( 0x70000273u, - generation: 1) with + generation: 1); + childSpawn = childSpawn with { Position = null, ParentGuid = parent.ServerGuid, ParentLocation = 0, PlacementId = 0, PositionSequence = 0, + Physics = childSpawn.Physics!.Value with + { + Position = null, + Parent = new PhysicsAttachment(parent.ServerGuid, 0u), + AnimationFrame = 0, + }, }; fixture.Live.RegisterLiveEntity(childSpawn); fixture.Controller.OnSpawn(childSpawn); @@ -577,13 +592,20 @@ public sealed class EquippedChildProjectionWithdrawalTests Assert.Null(entity.PaletteOverride); WorldSession.EntitySpawn grandchildSpawn = ControllerFixture.SpawnData( 0x70000274u, - generation: 1) with + generation: 1); + grandchildSpawn = grandchildSpawn with { Position = null, ParentGuid = child.ServerGuid, ParentLocation = 0, PlacementId = 0, PositionSequence = 0, + Physics = grandchildSpawn.Physics!.Value with + { + Position = null, + Parent = new PhysicsAttachment(child.ServerGuid, 0u), + AnimationFrame = 0, + }, }; fixture.Live.RegisterLiveEntity(grandchildSpawn); fixture.Controller.OnSpawn(grandchildSpawn); @@ -734,6 +756,8 @@ public sealed class EquippedChildProjectionWithdrawalTests Position = null, PositionSequence = 1, PlacementId = 0, + ParentGuid = parent.ServerGuid, + ParentLocation = 0, Physics = physics, }; @@ -1192,6 +1216,50 @@ public sealed class EquippedChildProjectionWithdrawalTests private const uint Cell = 0x01010001u; private readonly DeferredLiveEntityRuntimeComponentLifecycle _lifecycle = new(); private readonly Setup _setup; + private readonly AcDream.Runtime.Session.RuntimeFirstEntryDriveController _firstEntry; + + private sealed class NullCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision>.Missing; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatGfxObjCollisionAsset> + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatCellStructureCollisionAsset> + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } internal ControllerFixture( Func @@ -1201,10 +1269,24 @@ public sealed class EquippedChildProjectionWithdrawalTests (Cell & 0xFFFF0000u) | 0xFFFFu, new LandBlock(), Array.Empty())); - Live = LiveEntityRuntimeFixture.Create( + EntityObjects = new RuntimeEntityObjectLifetime(); + EntityObjects.BindEventContext( + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + static () => 1UL); + _firstEntry = new AcDream.Runtime.Session.RuntimeFirstEntryDriveController( + EntityObjects, + new AcDream.Runtime.GameRuntimeClock(), + new NullCollisionSource(), + () => AcDream.Runtime.Gameplay.PlayerMovementConstructionOptions.Fallback, + static _ => new AcDream.Runtime.Gameplay.RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, + 1.835f, + AcDream.Runtime.Gameplay.RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + Live = new LiveEntityRuntime( Spatial, new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), - _lifecycle); + _lifecycle, + EntityObjects); _setup = new Setup { HoldingLocations = @@ -1231,6 +1313,21 @@ public sealed class EquippedChildProjectionWithdrawalTests } internal GpuWorldState Spatial { get; } = new(); + internal RuntimeEntityObjectLifetime EntityObjects { get; } + + /// + /// C3c: completes fresh residences through the first-entry + /// conductors (a celless route performs no SetPosition, so its drain + /// completes synchronously), releasing the lease so legacy direct + /// CreateParent/Parent application stays valid post-flip. The + /// controller is constructed lazily but binds no notification of its + /// own tracking value until first use, so it sweeps active leases + /// directly instead. + /// + internal void CompleteFirstEntry() + { + _firstEntry.DriveAll(); + } internal ClientObjectTable Objects { get; } = new(); internal EntityEffectPoseRegistry Poses { get; } = new(); internal LiveEntityRuntime Live { get; } @@ -1254,7 +1351,13 @@ public sealed class EquippedChildProjectionWithdrawalTests { WorldSession.EntitySpawn spawn = SpawnData(guid, generation); if (!hasPosition) - spawn = spawn with { Position = null }; + { + spawn = spawn with + { + Position = null, + Physics = spawn.Physics!.Value with { Position = null }, + }; + } if (!hasSetup) spawn = spawn with { SetupTableId = null }; return Assert.IsType( @@ -1288,21 +1391,59 @@ public sealed class EquippedChildProjectionWithdrawalTests return record; } - internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation) => new( - guid, - new CreateObject.ServerPosition( - Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f), - 0x02000001u, - Array.Empty(), - Array.Empty(), - Array.Empty(), - BasePaletteId: null, - ObjScale: null, - Name: "attached fixture", - ItemType: null, - MotionState: null, - MotionTableId: null, - InstanceSequence: generation); + internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation) + { + // C3c: residence admission freezes the raw create and requires + // the flattened parser projections to agree with the nested + // PhysicsDesc block (HasConsistentCreateIdentityAndParent). + var position = new CreateObject.ServerPosition( + Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f); + var physics = new PhysicsSpawnData( + RawState: 0u, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: new PhysicsTimestamps( + Position: 0, + Movement: 0, + State: 0, + Vector: 0, + Teleport: 0, + ServerControlledMove: 0, + ForcePosition: 0, + ObjDesc: 0, + Instance: generation)); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "attached fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: generation, + Physics: physics); + } internal void InstallAttached( LiveEntityRecord parent, diff --git a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs index 29039d38..8eef106d 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs @@ -33,7 +33,7 @@ public sealed class LiveAppearanceAnimationTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: 1); + InstanceSequence: 1).WithConsistentPhysics(); LiveEntityRecord record = runtime.RegisterAndMaterializeProjection( spawn, id => Entity(id, 0x01000001u, guid)); @@ -175,7 +175,7 @@ public sealed class LiveAppearanceAnimationTests Assert.Equal(1, registry.RetainedRegistrationCount); } - private static WorldSession.EntitySpawn Spawn(uint guid, uint cell, ushort instance) => new( + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell, ushort instance) => new WorldSession.EntitySpawn( guid, new CreateObject.ServerPosition(cell, 1f, 1f, 1f, 1f, 0f, 0f, 0f), 0x02000010u, @@ -188,7 +188,7 @@ public sealed class LiveAppearanceAnimationTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: instance); + InstanceSequence: instance).WithConsistentPhysics(); private static WorldEntity Entity( uint id, diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs index 0a9f6753..39ef45da 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs @@ -74,7 +74,22 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests publishAppearance: _ => { operations.Add("appearance"); - runtime.RegisterLiveEntity(Spawn() with { Name = "fresher" }); + // C3c: a fresher same-generation CreateObject no longer + // advances create authority at registration — while a + // residence is active it is FIFO-staged and its authority + // advance lands at the executor drain's WeenieDescription + // stage (AdvanceCreateAuthority). Model that exact advance + // directly so the between-stage revalidation guard stays + // covered. + // C3c-R1 F3: honest MODEL of the executor drain's advance + // (ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests); a + // nested production OnCreate can no longer reach it — + // post-residence registration is description-only + // (RuntimeEntityObjectLifetime :660-665, + // !beginInitialResidence gate) and ConsumeExecuted already + // removed the completed residence entry. + record.Canonical.AdvanceCreateAuthority(); return true; }, publishCurrentSnapshot: _ => operations.Add("current"), @@ -315,7 +330,7 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests return record; } - private static WorldSession.EntitySpawn Spawn() => new( + private static WorldSession.EntitySpawn Spawn() => new WorldSession.EntitySpawn( Guid, new CreateObject.ServerPosition( Cell, @@ -336,7 +351,7 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: 1); + InstanceSequence: 1).WithConsistentPhysics(); private static LiveEntityAnimationState AnimationState( WorldEntity entity, diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs index 3cf160e3..298bc97d 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs @@ -232,7 +232,7 @@ public sealed class LiveEntityLightControllerTests InstanceSequence: 1, MovementSequence: 1, ServerControlSequence: 1, - PositionSequence: 1); + PositionSequence: 1).WithConsistentPhysics(); } } diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 3697c088..3acc470a 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -278,6 +278,15 @@ public sealed class CurrentGameRuntimeAdapterTests [Fact] public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace() { + // C3c (clause 2 — undriven-residence semantics): an initial world + // Create now begins the canonical initial-create residence, whose + // admission requires a live session generation. Both hosts here + // deliberately carry the zero generation of a session-less runtime, + // so BOTH refuse the Create transactionally with the identical + // structural error and publish no entity-object trace at all. The + // full driven-flow direct-vs-graphical parity (residence -> + // conductor -> receipts) is covered by the C3c first-entry + // integration tests. WorldSession.EntitySpawn spawn = Spawn(Harness.TargetGuid, instance: 7, cell: 0x12340001u); var direct = new RuntimeEntityObjectLifetime(); @@ -286,148 +295,28 @@ public sealed class CurrentGameRuntimeAdapterTests using IDisposable directSubscription = direct.Events.Subscribe(directTrace); - RuntimeEntityRegistrationResult directRegistration = - direct.RegisterEntity(spawn); - RuntimeEntityRecord directCanonical = - Assert.IsType(directRegistration.Canonical); - direct.Objects.AddOrUpdate(Object(spawn)); - direct.Objects.MoveItem( - spawn.Guid, - Harness.PlayerGuid, - newSlot: 3); - var objDesc = new ObjDescEvent.Parsed( - spawn.Guid, - new CreateObject.ModelData( - 0x04000001u, - Array.Empty(), - Array.Empty(), - Array.Empty()), - spawn.InstanceSequence, - ObjDescSequence: 2); - Assert.True(direct.TryApplyObjDesc( - objDesc, - acknowledgeProjection: null, - out _)); - var motion = new WorldSession.EntityMotionUpdate( - spawn.Guid, - new CreateObject.ServerMotionState(0x3D, 0x11), - spawn.InstanceSequence, - MovementSequence: 2, - ServerControlSequence: 2, - IsAutonomous: false); - Assert.True(direct.TryApplyMotion( - motion, - retainPayload: true, - acknowledgeProjection: null, - out _, - out _)); - var vector = new VectorUpdate.Parsed( - spawn.Guid, - new Vector3(1f, 2f, 3f), - new Vector3(0f, 0f, 0.25f), - spawn.InstanceSequence, - VectorSequence: 2); - Assert.True(direct.TryApplyVector( - vector, - acknowledgeProjection: null, - out _)); - var state = new SetState.Parsed( - spawn.Guid, - (uint)(PhysicsStateFlags.ReportCollisions - | PhysicsStateFlags.Hidden), - spawn.InstanceSequence, - StateSequence: 2); - Assert.True(direct.TryApplyState( - state, - acknowledgeProjection: null, - out _, - out _)); - Assert.True(direct.CommitChildNoDraw( - directCanonical, - noDraw: true)); - Assert.True(direct.CommitChildNoDraw( - directCanonical, - noDraw: false)); - WorldSession.EntityPositionUpdate position = - Position(spawn.Guid, spawn.InstanceSequence); - Assert.True(direct.TryApplyPosition( - position, - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - projectionRequiresTeleportHook: false, - acknowledgeProjection: null, - out _, - out _, - out _)); - Assert.True(direct.CommitRebucket( - directCanonical, - 0x12360001u, - 0x1236FFFFu)); - Assert.True(direct.TryApplyPickup( - new PickupEvent.Parsed( - spawn.Guid, - spawn.InstanceSequence, - PositionSequence: 3), - acknowledgeProjection: null, - out _)); - Assert.True(direct.TryAcceptDelete( - new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence), - isLocalPlayer: false, - removeRetainedObject: true, - out RuntimeEntityDeleteAcceptance directDelete)); - direct.CompleteAcceptedDelete(directDelete); - Assert.Null(direct.RetireCanonicalOnly(directCanonical)); + InvalidOperationException directRefusal = + Assert.Throws(() => + direct.RegisterEntityWithInitialResidence( + spawn, + isLocalPlayer: false)); using var graphical = new Harness(); var graphicalTrace = new EntityObjectTrace(); using IDisposable graphicalSubscription = graphical.EntityObjects.Events.Subscribe(graphicalTrace); - _ = graphical.Entities.RegisterAndMaterializeProjection(spawn); - graphical.Objects.AddOrUpdate(Object(spawn)); - graphical.Objects.MoveItem( - spawn.Guid, - Harness.PlayerGuid, - newSlot: 3); - Assert.True(graphical.Entities.TryApplyObjDesc( - objDesc, - out _)); - Assert.True(graphical.Entities.TryApplyMotion( - motion, - retainPayload: true, - out _, - out _)); - Assert.True(graphical.Entities.TryApplyVector(vector, out _)); - Assert.True(graphical.Entities.TryApplyState(state, out _, out _)); - Assert.True(graphical.Entities.SetAttachedChildNoDraw( - spawn.Guid, - noDraw: true)); - Assert.True(graphical.Entities.SetAttachedChildNoDraw( - spawn.Guid, - noDraw: false)); - Assert.True(graphical.Entities.TryApplyPosition( - position, - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - out _, - out _, - out _)); - Assert.True(graphical.Entities.RebucketLiveEntity( - spawn.Guid, - 0x12360001u)); - Assert.True(graphical.Entities.TryApplyPickup( - new PickupEvent.Parsed( - spawn.Guid, - spawn.InstanceSequence, - PositionSequence: 3), - out _)); - Assert.True(graphical.Entities.UnregisterLiveEntity( - new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence), - isLocalPlayer: false, - removeRetainedObject: true)); + InvalidOperationException graphicalRefusal = + Assert.Throws(() => + graphical.Entities.RegisterAndMaterializeProjection(spawn)); + + Assert.Contains( + "cannot acquire a structurally valid initial residence lease", + directRefusal.Message, + StringComparison.Ordinal); + Assert.Equal(directRefusal.Message, graphicalRefusal.Message); Assert.Equal(directTrace.Entries, graphicalTrace.Entries); + Assert.Empty(directTrace.Entries); Assert.Equal(0, direct.Entities.Count); Assert.Equal(0, direct.Objects.ObjectCount); Assert.Equal(0, graphical.EntityObjects.Entities.Count); @@ -438,6 +327,11 @@ public sealed class CurrentGameRuntimeAdapterTests public void GraphicalObserverFailure_DoesNotStarveLaterObserverOrOwner() { using var harness = new Harness(); + // C3c: registration begins the canonical initial-create residence, + // whose admission requires a live session generation. + Assert.Equal( + RuntimeSessionStartStatus.Connected, + harness.Runtime.Session.Start(harness.Runtime.Generation).Status); var throwing = new ThrowingEntityObserver(); var recording = new RuntimeTraceRecorder(); IDisposable first = harness.Runtime.Subscribe(throwing); diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index 1b6c9fcc..92b78533 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -599,7 +599,7 @@ public sealed class LocalPlayerTeleportControllerTests new Vector3(x, y, z), Quaternion.Identity)); - private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new( + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new WorldSession.EntitySpawn( Guid: guid, Position: new CreateObject.ServerPosition( cell, @@ -619,7 +619,7 @@ public sealed class LocalPlayerTeleportControllerTests Name: "player", ItemType: null, MotionState: null, - MotionTableId: null); + MotionTableId: null).WithConsistentPhysics(); private sealed class NullResources : ILiveEntityResourceLifecycle { diff --git a/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs index 3b4e9cfd..ae029adb 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs @@ -544,7 +544,7 @@ public sealed class StreamingFrameControllerTests InstanceSequence: 1, MovementSequence: 1, ServerControlSequence: 1, - PositionSequence: 1); + PositionSequence: 1).WithConsistentPhysics(); } private static IReadOnlyList Drain( diff --git a/tests/AcDream.App.Tests/World/C3cR1F3DriftModelSourcePinTests.cs b/tests/AcDream.App.Tests/World/C3cR1F3DriftModelSourcePinTests.cs new file mode 100644 index 00000000..80de6162 --- /dev/null +++ b/tests/AcDream.App.Tests/World/C3cR1F3DriftModelSourcePinTests.cs @@ -0,0 +1,74 @@ +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.World; + +/// +/// C3c-R1 F3 (coordinator resolution, 2026-08-02): the create-authority +/// drift probes in the expectation-item 6/8 tests +/// (LiveEntityHydrationControllerTests + LiveEntityCreateSupersessionRecoveryTests) +/// hand-call record.Canonical.AdvanceCreateAuthority() as an HONEST +/// MODEL of the executor drain's advance — the SOLE remaining production +/// site that advances create authority for an existing incarnation. A +/// nested production OnCreate can no longer produce that drift: +/// post-residence ExistingGeneration registration is description-only +/// (RuntimeEntityObjectLifetime gates the advance on +/// !beginInitialResidence) and ConsumeExecuted removes the +/// completed residence entry at Released, closing the FIFO-adoption path +/// (empirically confirmed: the restored nested-OnCreate probe produced no +/// drift and no CreateSupersessionRecovery). This pin flags the model as +/// STALE if the production site ever moves or loses the advance — the +/// item 6/8 probes must be re-derived from wherever it goes. +/// +public sealed class C3cR1F3DriftModelSourcePinTests +{ + [Fact] + public void HandCalledDriftProbe_StillModelsTheExecutorDrainAdvance() + { + string executor = ReadRuntimeSource( + "Entities", + "RuntimeInitialCreateContinuationExecutor.cs"); + + // Exactly one production advance, and it lives inside the + // WeenieDescription drain stage the probes model. + Assert.Single( + Regex.Matches(executor, @"_entities\.AdvanceCreateAuthority\(") + .Cast()); + Assert.Matches( + new Regex( + @"private bool ApplyWeenieDescriptionAction[\s\S]{0,6000}?" + + @"_entities\.AdvanceCreateAuthority\(canonical\);"), + executor); + + // The registration-time advance stays gated OFF the residence + // route — the reason a nested production OnCreate cannot reach the + // modeled drift. + string lifetime = ReadRuntimeSource( + "Entities", + "RuntimeEntityObjectLifetime.cs"); + Assert.Matches( + new Regex( + @"if \(!beginInitialResidence\)\s*" + + @"Entities\.AdvanceCreateAuthority\(retained\);"), + lifetime); + } + + private static string ReadRuntimeSource(params string[] relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return File.ReadAllText(Path.Combine( + directory.FullName, + "src", + "AcDream.Runtime", + Path.Combine(relativePath))); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs index 55a3e554..7626956f 100644 --- a/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs +++ b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs @@ -144,7 +144,7 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests } private static WorldSession.EntitySpawn CreateSpawn(uint guid) => - new( + new WorldSession.EntitySpawn( Guid: guid, Position: null, SetupTableId: null, @@ -157,5 +157,5 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: 1); + InstanceSequence: 1).WithConsistentPhysics(); } diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index fdd55068..7b51ef7d 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -370,12 +370,14 @@ public sealed class LiveEntityHydrationControllerTests fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); LiveEntityRecord record = fixture.Record; WorldEntity entity = record.WorldEntity!; - var body = new PhysicsBody(); - Assert.Same( - body, - fixture.Runtime.GetOrCreatePhysicsBody( - record.ServerGuid, - _ => body)); + // C3c/C3b: the first-entry conductor constructs the canonical + // physics body at Create (retail ACCObjectMaint::CreateObject / + // set_description). Capture that existing body — the identity the + // pickup/re-enter cycle must preserve — instead of seeding one. + PhysicsBody body = fixture.Runtime.GetOrCreatePhysicsBody( + record.ServerGuid, + static _ => throw new InvalidOperationException( + "The conductor-built canonical body should already exist.")); fixture.Relationships.OnUnparentAction = _ => fixture.Runtime.WithdrawLiveEntityProjection(record) ? ChildUnparentDisposition.Completed @@ -962,6 +964,14 @@ public sealed class LiveEntityHydrationControllerTests // Local-player records ordinarily do not rebucket from streaming // callbacks; an incomplete initial transaction must still recover. + // C3c: the failed Create transaction unwound before OnCreateCore's + // own drive pump ran, leaving the residence pending with FullCellId + // 0 (streaming callbacks key candidates off the committed cell). In + // production the per-frame first-entry pump completes the conductor + // independently of the failed hydration transaction; model that + // pump here, then let the streaming callback recover the partial + // projection exactly as before. + fixture.FirstEntry.DriveAll(); fixture.Controller.OnLandblockLoaded(Cell); Assert.Same(partial, record.WorldEntity); @@ -1042,7 +1052,13 @@ public sealed class LiveEntityHydrationControllerTests Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); Assert.True(fixture.Record.InitialHydrationCompleted); Assert.Single(fixture.Materializer.Calls); - Assert.Equal((ushort)2, fixture.Materializer.PositionSequences[0]); + // C3c: the nested fresher same-generation Create is admitted into + // the outer create's ACTIVE residence FIFO (AD-59) and its facts + // commit at the executor drain, in array order. The single + // materialization therefore runs from the admission-frozen seq-1 + // create; the seq-2 facts (including the name asserted above) land + // through the drain and bind via the completion receipt. + Assert.Equal((ushort)1, fixture.Materializer.PositionSequences[0]); } [Fact] @@ -1114,10 +1130,16 @@ public sealed class LiveEntityHydrationControllerTests Assert.Equal((ushort)1, fixture.Record.Generation); Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); Assert.True(fixture.Record.InitialHydrationCompleted); - Assert.Equal((ushort)3, fixture.Materializer.PositionSequences[^1]); + // C3c: the nested fresher same-generation Create is admitted into + // the replacement generation's ACTIVE residence FIFO (AD-59) and its + // facts commit at the executor drain, in array order. The + // materialization therefore runs from the admission-frozen seq-2 + // replacement create; the seq-3 facts (including the name asserted + // above) land through the drain and never re-materialize. + Assert.Equal((ushort)2, fixture.Materializer.PositionSequences[^1]); Assert.DoesNotContain( - (ushort)2, - fixture.Materializer.PositionSequences.Skip(1)); + (ushort)3, + fixture.Materializer.PositionSequences); } [Fact] @@ -1210,11 +1232,19 @@ public sealed class LiveEntityHydrationControllerTests Assert.True(record.InitialHydrationCompleted); Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); - Assert.Equal([1, 1, 2], fixture.Materializer.PositionSequences); + // C3c: a post-residence same-generation Create is description-only + // at registration — its position churn flows through the + // freshness-gated events tail (which is what makes RecoverProjection + // return false above), and create authority advances only inside the + // residence transaction. No drift means no nested + // CreateSupersessionRecovery re-materialization: the recovery's own + // SpatialRecovery attempt stays the last call at the original + // installed version. + Assert.Equal([1, 1], fixture.Materializer.PositionSequences); Assert.Equal( - LiveProjectionPurpose.CreateSupersessionRecovery, + LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[^1].Purpose); - Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion); + Assert.Equal(1UL, fixture.Materializer.InstalledCreateIntegrationVersion); } [Theory] @@ -1236,10 +1266,23 @@ public sealed class LiveEntityHydrationControllerTests if (refreshed || spawn.PositionSequence != 1) return; refreshed = true; - fixture.Controller.OnCreate(Spawn( - Generation: 1, - PositionSequence: 2, - Name: "recovery v2")); + // C3c: a fresher same-generation CreateObject no longer advances + // create authority at registration (its advance lands at the + // residence drain's WeenieDescription stage). Model that exact + // advance directly so the drift-retry machinery under test still + // fires. + // C3c-R1 F3 (coordinator resolution): this hand-call is an + // honest MODEL of the executor drain's advance + // (RuntimeInitialCreateContinuationExecutor + // .ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests). A nested + // production OnCreate can no longer reach it here: + // post-residence ExistingGeneration registration is + // description-only (RuntimeEntityObjectLifetime gates the + // advance on !beginInitialResidence, :660-665) and + // ConsumeExecuted already removed the completed residence + // entry, closing the FIFO-adoption path. + record.Canonical.AdvanceCreateAuthority(); }; fixture.Materializer.ThrowAfterMaterializePurposeOnce = LiveProjectionPurpose.CreateSupersessionRecovery; @@ -1270,8 +1313,12 @@ public sealed class LiveEntityHydrationControllerTests Assert.False(record.CreateProjectionSynchronizationPending); Assert.Same(retained, record.WorldEntity); + // C3c: the retry retransmit (a post-residence same-generation + // Create) no longer advances create authority itself, so both retry + // paths install the drift probe's version (2), not a + // retransmit-advanced 3. Assert.Equal( - retryFromLandblock ? 2UL : 3UL, + 2UL, fixture.Materializer.InstalledCreateIntegrationVersion); Assert.Equal( LiveProjectionPurpose.CreateSupersessionRecovery, @@ -1357,10 +1404,23 @@ public sealed class LiveEntityHydrationControllerTests if (refreshed || spawn.PositionSequence != 1) return; refreshed = true; - fixture.Controller.OnCreate(Spawn( - Generation: 1, - PositionSequence: 2, - Name: "ready v2")); + // C3c: a fresher same-generation CreateObject no longer advances + // create authority at registration (its advance lands at the + // residence drain's WeenieDescription stage). Model that exact + // advance directly so the drift-retry machinery under test still + // fires. + // C3c-R1 F3 (coordinator resolution): this hand-call is an + // honest MODEL of the executor drain's advance + // (RuntimeInitialCreateContinuationExecutor + // .ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests). A nested + // production OnCreate can no longer reach it here: + // post-residence ExistingGeneration registration is + // description-only (RuntimeEntityObjectLifetime gates the + // advance on !beginInitialResidence, :660-665) and + // ConsumeExecuted already removed the completed residence + // entry, closing the FIFO-adoption path. + record.Canonical.AdvanceCreateAuthority(); }; fixture.Ready.FailPublishCount = 1; @@ -1486,6 +1546,12 @@ public sealed class LiveEntityHydrationControllerTests { const uint parentGuid = 0x70000002u; using var fixture = new Fixture(originKnown: true); + // C3c: a Create whose parent is not addressable is now queued under + // the parent's GUID (retail QueueBlobForObject) instead of applying + // immediately. Register the parent so the nested parented Create + // routes exactly as before. + fixture.Runtime.RegisterLiveEntity( + Spawn(Generation: 1, PositionSequence: 1) with { Guid = parentGuid }); fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); LiveEntityRecord record = fixture.Record; WorldEntity retained = record.WorldEntity!; @@ -1522,6 +1588,17 @@ public sealed class LiveEntityHydrationControllerTests record, positionVersion)); Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record)); + // C3c: mirror the production relationship owner + // (EquippedChildRenderController.TryAttach), which converts a + // residence-managed child's sticky residence to LegacyImmediate + // at the world -> attached kind transition. + if (record.MaterializationResidence is + AcDream.App.World.LiveEntityMaterializationResidence + .AwaitRuntimePlacement) + { + record.MaterializationResidence = AcDream.App.World + .LiveEntityMaterializationResidence.LegacyImmediate; + } WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity( Guid, Cell, @@ -1540,6 +1617,12 @@ public sealed class LiveEntityHydrationControllerTests fixture.Controller.OnCreate(CelllessSpawn( PositionSequence: 2, parentGuid)); + // C3c: a post-residence same-generation Create no longer + // advances create authority at registration (the advance lands + // at a residence drain's WeenieDescription stage). Model that + // advance directly so the supersession-recovery machinery under + // test still fires and completes at the attached-ready boundary. + record.Canonical.AdvanceCreateAuthority(); }; Assert.False(fixture.Controller.RecoverProjection( @@ -1575,6 +1658,25 @@ public sealed class LiveEntityHydrationControllerTests using var fixture = new Fixture( originKnown: true, playerGuid: retryFromLandblock ? Guid : 0u); + // C3c: a Create whose parent is not addressable is now queued under + // the parent's GUID (retail QueueBlobForObject) instead of applying + // immediately. Register the parent so the initial parented Create + // routes exactly as before — placed in a DIFFERENT landblock so this + // scaffolding identity is not itself a candidate for the recovered + // landblock's projection sweep (the child's RegisterCount assertions + // count only the child's resources). + WorldSession.EntitySpawn parentSpawn = + Spawn(Generation: 1, PositionSequence: 1) with { Guid = parentGuid }; + var parentPosition = new CreateObject.ServerPosition( + 0x01020001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + fixture.Runtime.RegisterLiveEntity(parentSpawn with + { + Position = parentPosition, + Physics = parentSpawn.Physics!.Value with + { + Position = parentPosition, + }, + }); fixture.Ready.FailPublishCount = 2; fixture.Network.ApplyAction = events => { @@ -1659,8 +1761,20 @@ public sealed class LiveEntityHydrationControllerTests if (!replaced && replacementStage == stage) { replaced = true; - fixture.Runtime.RegisterLiveEntity( - Spawn(Generation: 1, PositionSequence: 2)); + // C3c: a fresher same-generation CreateObject no longer + // advances create authority at registration — its advance + // lands at the residence drain's WeenieDescription stage. + // Model that exact advance directly so the between-stage + // revalidation guard stays covered. + // C3c-R1 F3: honest MODEL of the executor drain's advance + // (ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests); a + // nested production OnCreate can no longer reach it — + // post-residence registration is description-only + // (RuntimeEntityObjectLifetime :660-665, + // !beginInitialResidence gate) and ConsumeExecuted already + // removed the completed residence entry. + expected.Canonical.AdvanceCreateAuthority(); } return true; } @@ -1768,8 +1882,18 @@ public sealed class LiveEntityHydrationControllerTests var projection = new RecordingLiveProjectionSink( _ => { - fixture.Runtime.RegisterLiveEntity( - Spawn(Generation: 1, PositionSequence: 2)); + // C3c: a fresher same-generation CreateObject's authority + // advance now lands at the residence drain's + // WeenieDescription stage; model that advance directly. + // C3c-R1 F3: honest MODEL of the executor drain's advance + // (ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests); a + // nested production OnCreate can no longer reach it — + // post-residence registration is description-only + // (RuntimeEntityObjectLifetime :660-665, + // !beginInitialResidence gate) and ConsumeExecuted already + // removed the completed residence entry. + fixture.Record.Canonical.AdvanceCreateAuthority(); return true; }); var publisher = new LiveEntityReadyPublisher( @@ -1792,10 +1916,19 @@ public sealed class LiveEntityHydrationControllerTests WorldEntity entity = record.WorldEntity!; ulong capturedCreateIntegrationVersion = record.CreateIntegrationVersion; - // Models ProjectionPoseReady synchronously accepting a fresher - // same-generation CreateObject before EntityReady is emitted. - fixture.Runtime.RegisterLiveEntity( - Spawn(Generation: 1, PositionSequence: 2)); + // Models ProjectionPoseReady synchronously observing a fresher + // same-generation CreateObject's authority advance before + // EntityReady is emitted. C3c: that advance now lands at the + // residence drain's WeenieDescription stage + // (AdvanceCreateAuthority), not at registration; model it directly. + // C3c-R1 F3: honest MODEL of the executor drain's advance + // (ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests); a nested + // production OnCreate can no longer reach it — post-residence + // registration is description-only (RuntimeEntityObjectLifetime + // :660-665, !beginInitialResidence gate) and ConsumeExecuted + // already removed the completed residence entry. + record.Canonical.AdvanceCreateAuthority(); bool published = false; Assert.False(EquippedChildRenderController.PublishEntityReadyExact( @@ -1937,6 +2070,55 @@ public sealed class LiveEntityHydrationControllerTests uint playerGuid = 0u) { Resources = resources ?? new RecordingResources(); + // C3c: initial-Create registration begins the canonical + // residence, whose admission requires a live generation; the + // fixture also commits the wire landblock's collision generation + // and wires the production first-entry drive pump so each Create + // transaction completes its conductor synchronously, exactly + // like the composed graphical host. + EntityObjects.BindEventContext( + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + static () => 1UL); + EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, 1UL); + EntityObjects.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new AcDream.Core.Physics.TerrainSurface( + new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, 1UL, ready: true); + Movement = new AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState(); + IdentityState = new AcDream.Runtime.Gameplay.RuntimeLocalPlayerIdentityState(); + var publication = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsPublicationState( + EntityObjects.Entities, + EntityObjects.Physics, + Movement, + IdentityState); + Movement.AttachPhysicsPublication(publication); + EntityObjects.LocalPlayerFirstEntry.BindPublication(publication); + IdentityState.ServerGuid = playerGuid; + FirstEntry = new AcDream.Runtime.Session.RuntimeFirstEntryDriveController( + EntityObjects, + new AcDream.Runtime.GameRuntimeClock(), + new HydrationNullCollisionSource(), + () => AcDream.Runtime.Gameplay.PlayerMovementConstructionOptions.Fallback, + static _ => new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, + 1.835f, + AcDream.Runtime.Gameplay + .RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + // C3c: the real per-session placement subscription — without it + // the first entity's unacknowledged ExecutorCompleted receipt + // wedges the one ordered FIFO and every later Create's conductor + // yields AwaitingReceiptAcknowledgement forever. Ack rules mirror + // production: Discard/ExecutorCompleted acknowledge-only; + // Place/Withdraw stay at the head for the conductor machinery. var spatial = new GpuWorldState(); spatial.AddLandblock(new LoadedLandblock( 0x0101FFFFu, @@ -1947,6 +2129,11 @@ public sealed class LiveEntityHydrationControllerTests Resources, Teardown, EntityObjects); + _placements = new AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription( + EntityObjects.Placements, + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + new FixturePlacementSink(Runtime)); Materializer = new RecordingMaterializer(Runtime, Operations); Relationships = new RecordingRelationships(Operations); Ready = new RecordingReadyPublisher(Operations); @@ -1988,7 +2175,90 @@ public sealed class LiveEntityHydrationControllerTests Timestamps, identity, deletion, - Dormant); + Dormant, + firstEntry: FirstEntry); + } + + public AcDream.Runtime.Session.RuntimeFirstEntryDriveController FirstEntry { get; } + private readonly AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription _placements; + + private sealed class FixturePlacementSink(LiveEntityRuntime runtime) + : AcDream.Runtime.Physics.IRuntimePlacementProjectionSink + { + public bool TryApply( + in AcDream.Runtime.Physics.RuntimePlacementProjectionSnapshot projection) + { + if (projection.Kind is AcDream.Runtime.Physics + .RuntimePlacementProjectionKind.Discard) + { + return true; + } + if (projection.Kind is AcDream.Runtime.Physics + .RuntimePlacementProjectionKind.ExecutorCompleted) + { + return projection.Token.ExactCellId == 0u + || runtime.TryApplyInitialCreateCompletionPresentation( + in projection); + } + return !runtime.HasActiveInitialCreateResidence( + projection.Token.Entity) + && runtime.TryApplyRuntimePlacementProjection(in projection); + } + } + public AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState Movement { get; } + public AcDream.Runtime.Gameplay.RuntimeLocalPlayerIdentityState IdentityState { get; } + + private sealed class HydrationNullCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision>.Loaded( + new AcDream.Core.Physics.FlatSetupCollision( + System.Collections.Immutable.ImmutableArray< + AcDream.Core.Physics.FlatCollisionCylinder>.Empty, + [new AcDream.Core.Physics.FlatCollisionSphere( + System.Numerics.Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatGfxObjCollisionAsset> + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatCellStructureCollisionAsset> + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } } public LiveEntityRecord Record @@ -2165,7 +2435,34 @@ public sealed class LiveEntityHydrationControllerTests }, LiveEntityProjectionKind.World, initializeProjection: null, - out LiveEntityRecord? expectedRecord); + out LiveEntityRecord? expectedRecord, + // C3c: mirror the production materializer + // (DatLiveEntityProjectionMaterializer.MaterializeProjection), + // which materializes route-1 world creates residence-managed. + // The legacy-immediate default would commit the wire cell + // out-of-band and retire the fresh residence lease before the + // fixture's drive pump ever ran (diagnosed RejectedToken). + AcDream.App.World.LiveEntityMaterializationResidence + .AwaitRuntimePlacement); + // C3c: mirror the production materializer's self-projection + // branch — when the residence-driven placement already committed + // (or a legacy post-residence path committed the cell) before + // this sidecar could exist, its completion receipt is gone, so + // presentation self-projects from the committed canonical state + // through the presentation-only bucket path. + if (entity is not null + && expectedRecord is not null + && runtime.IsCurrentCreateIntegration( + expectedCanonical, + expectedCreateIntegrationVersion) + && expectedCanonical.FullCellId != 0u + && !runtime.HasActiveInitialCreateResidence(expectedCanonical) + && !runtime.RebucketLiveEntity( + canonicalSpawn.Guid, + expectedCanonical.FullCellId)) + { + return false; + } if (ThrowAfterMaterializePurposeOnce == purpose) { ThrowAfterMaterializePurposeOnce = null; diff --git a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs index 3bdfdd62..98167806 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs @@ -423,6 +423,11 @@ public sealed class LiveEntityLifecycleStressTests canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true); EntityObjects = new RuntimeEntityObjectLifetime(Engine); + // C3c: initial-Create registration begins the canonical residence, + // whose admission requires a live session generation. + EntityObjects.BindEventContext( + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + static () => 1UL); Runtime = new LiveEntityRuntime( Spatial, new DelegateLiveEntityResourceLifecycle( diff --git a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs index 53a849d1..cafbf0e6 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs @@ -639,6 +639,9 @@ public sealed class LiveEntityPhysicsHostOwnershipTests teardown); private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) => + // C3c: residence admission requires the flattened identity fields to + // agree with a nested PhysicsDesc block; a bare logical fixture + // carries the minimal consistent one (instance timestamp only). new( Guid: guid, Position: null, @@ -652,7 +655,37 @@ public sealed class LiveEntityPhysicsHostOwnershipTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: instance); + InstanceSequence: instance, + Physics: new PhysicsSpawnData( + RawState: 0u, + Position: null, + Movement: null, + AnimationFrame: null, + SetupTableId: null, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: new PhysicsTimestamps( + Position: 0, + Movement: 0, + State: 0, + Vector: 0, + Teleport: 0, + ServerControlledMove: 0, + ForcePosition: 0, + ObjDesc: 0, + Instance: instance))); private static AcDream.Core.World.WorldEntity Entity( uint localId, diff --git a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs index 366babc1..fc52de54 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs @@ -733,7 +733,10 @@ public sealed class LiveEntityPresentationControllerTests { var position = new CreateObject.ServerPosition( 0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f); - var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + // C3c: residence admission requires the nested block's Instance + // timestamp to agree with the flattened InstanceSequence. + var timestamps = new PhysicsTimestamps( + 1, 1, 1, 1, 0, 1, 0, 1, instanceSequence); var physics = new PhysicsSpawnData( RawState: (uint)state, Position: position, diff --git a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs index ad291825..8661ef29 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs @@ -247,6 +247,9 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests MotionState: null, MotionTableId: null, InstanceSequence: instance); + // C3c: residence admission requires the flattened parser + // projections to agree with a nested PhysicsDesc block. + spawn = spawn.WithConsistentPhysics(); LiveEntityRecord record = Live.RegisterAndMaterializeProjection( spawn, id => new WorldEntity diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index d4704ef4..7baa11ef 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -826,13 +826,22 @@ public sealed class LiveEntityRuntimeTests { const uint parentGuid = 0x70000020u; const uint childGuid = 0x70000021u; - var runtime = LiveEntityRuntimeFixture.Create(new GpuWorldState(), new RecordingResources()); + // C3c: initial residences defer wire applies into their FIFO until a + // host pump drives the conductors; use the driven fixture and pump + // after each Create so the parent-event tail commits as before. + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven = + LiveEntityRuntimeFixture.CreateDriven( + new GpuWorldState(), + new RecordingResources()); + LiveEntityRuntime runtime = driven.Runtime; runtime.RegisterLiveEntity(Spawn(parentGuid, 9, 1, 0x01010001u)); + driven.Pump(); runtime.ParentAttachments.Enqueue(new ParentEvent.Parsed( parentGuid, childGuid, 1, 2, 9, 5)); ResolveParent(runtime, childGuid); runtime.RegisterLiveEntity(Spawn(childGuid, 3, 4, 0x01010001u)); + driven.Pump(); ResolveParent(runtime, childGuid); Assert.True(runtime.ParentAttachments.TryGetProjection( @@ -1261,6 +1270,143 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(0.0, clock.PendingSeconds, 8); } + /// + /// C3c-R1 review R2: the presentation-only rebucket shortcut is scoped + /// to the ACTIVE initial-create residence (where the public API is + /// suppressed outright — the conductor's completion receipt is the only + /// presentation channel). A RETIRED-residence entity's sticky + /// MaterializationResidence must NOT keep it on the shortcut: the + /// unflipped legacy update routes (network position/state, teleports, + /// streaming reprojection, hydration recovery — all callers of this one + /// public RebucketLiveEntity chokepoint) are the position authority + /// again, so post-residence moves take the FULL legacy branch: + /// CommitRebucket writes the canonical cell and retail's + /// prepare_to_enter_world (0x00511FA0) clock rebase runs on every + /// root-workset membership edge. + /// + [Fact] + public void PostResidenceRebucket_TakesTheFullLegacyPathIncludingTheClockEdge() + { + const uint guid = 0x7000004Au; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven = + LiveEntityRuntimeFixture.CreateDriven( + spatial, + new RecordingResources()); + LiveEntityRuntime runtime = driven.Runtime; + RuntimeEntityRecord canonical = + Assert.IsType(runtime.RegisterLiveEntity( + Spawn(guid, 1, 1, 0x01010001u)).Canonical); + runtime.MaterializeLiveEntity( + canonical, + 0x01010001u, + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out _, + LiveEntityMaterializationResidence.AwaitRuntimePlacement); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + + // ACTIVE residence: the public API stays suppressed (the completion + // receipt is the entity's first world-visible moment) and no legacy + // cell commit can race the conductor's pending placement. + Assert.True(runtime.HasActiveInitialCreateResidence(canonical)); + Assert.False(runtime.RebucketLiveEntity(guid, 0x01020001u)); + Assert.Equal(0u, canonical.FullCellId); + // C3c-R1 review F5: the tracked-but-undriven entry is visible in + // the entity-object ownership ledger while it awaits its pump. + Assert.Equal( + 1, + driven.Lifetime.CaptureOwnership().FirstEntryDrivePendingCount); + + driven.Pump(); + Assert.False(runtime.HasActiveInitialCreateResidence(canonical)); + Assert.Equal( + LiveEntityMaterializationResidence.AwaitRuntimePlacement, + record.MaterializationResidence); + Assert.Equal(0x01010001u, canonical.FullCellId); + + // C3c-R1 review F5: the drive's tracked entries fold into the + // entity-object ownership ledger — one pending entry while the + // residence awaited its pump, zero after. + Assert.Equal( + 0, + driven.Lifetime.CaptureOwnership().FirstEntryDrivePendingCount); + + // RETIRED residence, loaded-to-loaded: full legacy branch commits + // the canonical cell (the presentation-only shortcut never did) and + // preserves the running clock. + RetailObjectQuantumClock clock = record.ObjectClock; + Assert.Equal(0, clock.Advance(0.02).Count); + Assert.True(runtime.RebucketLiveEntity(guid, 0x01020001u)); + Assert.Equal(0x01020001u, canonical.FullCellId); + Assert.Same(clock, record.ObjectClock); + Assert.True(clock.IsActive); + Assert.Equal(0.02, clock.PendingSeconds, 8); + + // Membership edge into a pending bucket suspends the clock; the + // pending drain's reentry rebases it for enter-world — the retail + // prepare_to_enter_world edge the shortcut skipped. + Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u)); + Assert.Equal(0x02020001u, canonical.FullCellId); + Assert.False(clock.IsActive); + spatial.AddLandblock(EmptyLandblock(0x0202FFFFu)); + Assert.True(clock.IsActive); + Assert.Equal(0.0, clock.PendingSeconds, 8); + } + + /// + /// C3c-R1 review F1: converting the sticky residence-managed + /// presentation kind to LegacyImmediate (the equipped-child + /// world→attached transition) is the owner's explicit API — it refuses + /// while the initial-create residence lease is still active, because an + /// attached materialization would otherwise race the conductor's + /// pending placement. + /// + [Fact] + public void ResidenceConversionToLegacyImmediate_RefusesWhileTheResidenceIsActive() + { + const uint guid = 0x7000004Bu; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven = + LiveEntityRuntimeFixture.CreateDriven( + spatial, + new RecordingResources()); + LiveEntityRuntime runtime = driven.Runtime; + RuntimeEntityRecord canonical = + Assert.IsType(runtime.RegisterLiveEntity( + Spawn(guid, 1, 1, 0x01010001u)).Canonical); + runtime.MaterializeLiveEntity( + canonical, + 0x01010001u, + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out _, + LiveEntityMaterializationResidence.AwaitRuntimePlacement); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + + Assert.Throws(() => + runtime.ConvertMaterializationResidenceToLegacyImmediate(record)); + Assert.Equal( + LiveEntityMaterializationResidence.AwaitRuntimePlacement, + record.MaterializationResidence); + + driven.Pump(); + runtime.ConvertMaterializationResidenceToLegacyImmediate(record); + Assert.Equal( + LiveEntityMaterializationResidence.LegacyImmediate, + record.MaterializationResidence); + // Idempotent once converted (and a no-op for legacy records). + runtime.ConvertMaterializationResidenceToLegacyImmediate(record); + Assert.Equal( + LiveEntityMaterializationResidence.LegacyImmediate, + record.MaterializationResidence); + } + [Fact] public void InitiallyVisibleStaticObject_RebasesWithoutBecomingActive() { @@ -1428,10 +1574,27 @@ public sealed class LiveEntityRuntimeTests { const uint stateBeforeBindGuid = 0x70000037u; const uint bindBeforeStateGuid = 0x70000038u; - var runtime = LiveEntityRuntimeFixture.Create(new GpuWorldState(), new RecordingResources()); - runtime.RegisterLiveEntity(Spawn(stateBeforeBindGuid, 1, 1, 0x01010001u)); - runtime.RegisterLiveEntity(Spawn(bindBeforeStateGuid, 1, 1, 0x01010001u)); + // C3c: initial residences defer wire applies into their FIFO until a + // host pump drives the conductors. + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven = + LiveEntityRuntimeFixture.CreateDriven( + new GpuWorldState(), + new RecordingResources()); + LiveEntityRuntime runtime = driven.Runtime; + RuntimeEntityRecord stateBeforeBindCanonical = + Assert.IsType(runtime.RegisterLiveEntity( + Spawn(stateBeforeBindGuid, 1, 1, 0x01010001u)).Canonical); + RuntimeEntityRecord bindBeforeStateCanonical = + Assert.IsType(runtime.RegisterLiveEntity( + Spawn(bindBeforeStateGuid, 1, 1, 0x01010001u)).Canonical); + // C3c/C3b: the first-entry conductor constructs the canonical body + // at Create (never-clobber: a fixture can no longer seed a + // replacement RemoteMotionRuntime over it). "Arrival order" is now + // SetState-before-the-drain (FIFO'd into the pending residence, + // applied against the conductor-built body at Execute) versus + // SetState-after-completion (legacy immediate apply). Both must + // leave the canonical body's state synchronized. PhysicsStateFlags firstState = PhysicsStateFlags.Hidden | PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions; @@ -1439,18 +1602,30 @@ public sealed class LiveEntityRuntimeTests new SetState.Parsed(stateBeforeBindGuid, (uint)firstState, 1, 2), out _)); runtime.MaterializeLiveEntity( - stateBeforeBindGuid, + stateBeforeBindCanonical, 0x01010001u, - id => Entity(id, stateBeforeBindGuid)); - var lateBody = new RemoteMotionRuntime(); - runtime.SetRemoteMotionRuntime(stateBeforeBindGuid, lateBody); - + id => Entity(id, stateBeforeBindGuid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out _, + LiveEntityMaterializationResidence.AwaitRuntimePlacement); runtime.MaterializeLiveEntity( - bindBeforeStateGuid, + bindBeforeStateCanonical, 0x01010001u, - id => Entity(id, bindBeforeStateGuid)); - var earlyBody = new RemoteMotionRuntime(); - runtime.SetRemoteMotionRuntime(bindBeforeStateGuid, earlyBody); + id => Entity(id, bindBeforeStateGuid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out _, + LiveEntityMaterializationResidence.AwaitRuntimePlacement); + driven.Pump(); + PhysicsBody lateBody = runtime.GetOrCreatePhysicsBody( + stateBeforeBindGuid, + static _ => throw new InvalidOperationException( + "The conductor-built canonical body should already exist.")); + PhysicsBody earlyBody = runtime.GetOrCreatePhysicsBody( + bindBeforeStateGuid, + static _ => throw new InvalidOperationException( + "The conductor-built canonical body should already exist.")); PhysicsStateFlags secondState = PhysicsStateFlags.Static | PhysicsStateFlags.Ethereal | PhysicsStateFlags.NoDraw; @@ -1460,8 +1635,8 @@ public sealed class LiveEntityRuntimeTests Assert.Equal((firstState & ~PhysicsStateFlags.ReportCollisions) | PhysicsStateFlags.IgnoreCollisions, - lateBody.Body.State); - Assert.Equal(secondState, earlyBody.Body.State); + lateBody.State); + Assert.Equal(secondState, earlyBody.State); } [Fact] @@ -1573,9 +1748,17 @@ public sealed class LiveEntityRuntimeTests public void PositionAfterPickup_RequiresTeleportHookEvenWithEqualTeleportStamp() { const uint guid = 0x70000043u; - var runtime = LiveEntityRuntimeFixture.Create(new GpuWorldState(), new RecordingResources()); + // C3c: initial residences defer wire applies into their FIFO until a + // host pump drives the conductors; use the driven fixture and pump + // after the Create so the pickup/position tail flows as before. + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven = + LiveEntityRuntimeFixture.CreateDriven( + new GpuWorldState(), + new RecordingResources()); + LiveEntityRuntime runtime = driven.Runtime; WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u); runtime.RegisterLiveEntity(spawn); + driven.Pump(); Assert.True(runtime.TryApplyPickup( new PickupEvent.Parsed(guid, 1, 2), out _)); diff --git a/tests/AcDream.App.Tests/World/RuntimeFirstEntryHostIntegrationTests.cs b/tests/AcDream.App.Tests/World/RuntimeFirstEntryHostIntegrationTests.cs new file mode 100644 index 00000000..829a1c25 --- /dev/null +++ b/tests/AcDream.App.Tests/World/RuntimeFirstEntryHostIntegrationTests.cs @@ -0,0 +1,743 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Plugins; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using AcDream.Runtime.Entities; +using AcDream.Runtime.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.World; + +/// +/// C3c contract integration tests: the flipped graphical host wiring driven +/// end-to-end — registration through , +/// the REAL behind the REAL +/// , +/// and the production +/// pump. +/// No conductor is ever hand-called. +/// +public sealed class RuntimeFirstEntryHostIntegrationTests +{ + private const uint Cell = 0x01010001u; + private const uint Guid = 0x70000301u; + + [Fact] + public void InitialCreate_ResidenceConductorReceipt_BindsWorldVisibilityExactlyOnce() + { + using var fixture = new HostFixture(playerGuid: 0u); + int residencesBegan = 0; + fixture.EntityObjects.BindInitialResidenceBeginNotification( + _ => residencesBegan++); + bool visibleAtMaterialize = true; + fixture.Materializer.AfterMaterialize = record => + { + // Clause 1: the sidecar exists but presentation stays suppressed + // until the conductor's completion receipt binds it. + visibleAtMaterialize = record.IsSpatiallyProjected + || record.IsSpatiallyVisible; + }; + + fixture.Controller.OnCreate(Spawn(Guid, Cell)); + + Assert.Equal(1, residencesBegan); + Assert.False(visibleAtMaterialize); + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + // The residence was consumed by the conductor inside the Create + // transaction's own pump. + Assert.False(fixture.Runtime.HasActiveInitialCreateResidence( + record.Canonical)); + Assert.Equal(Cell, record.Canonical.FullCellId); + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.NotNull(record.PhysicsBody); + Assert.Equal((record, true), Assert.Single(fixture.VisibilityEdges)); + AcDream.Plugin.Abstractions.WorldEntitySnapshot snapshot = + Assert.Single(fixture.WorldState.Entities); + Assert.Equal(record.WorldEntity!.Position, snapshot.Position); + Assert.Equal(0, fixture.EntityObjects.Placements.PendingCount); + Assert.Equal(0, fixture.FirstEntry.PendingCount); + } + + [Fact] + public void DeferredParentCreate_StaysInvisibleUntilParentReplay() + { + const uint parentGuid = 0x70000302u; + const uint childGuid = 0x70000303u; + using var fixture = new HostFixture(playerGuid: 0u); + int residencesBegan = 0; + fixture.EntityObjects.BindInitialResidenceBeginNotification( + _ => residencesBegan++); + + fixture.Controller.OnCreate(ParentedSpawn(childGuid, parentGuid)); + + // Retail queues the raw blob under the parent's GUID; nothing about + // the child may escape — no canonical, no sidecar, no presentation. + Assert.Equal(0, residencesBegan); + Assert.False(fixture.Runtime.TryGetCanonical(childGuid, out _)); + Assert.False(fixture.Runtime.TryGetRecord(childGuid, out _)); + Assert.True(fixture.Runtime.ParentAttachments.ContainsDeferredCreate( + childGuid, + instanceSequence: 1)); + Assert.Empty(fixture.WorldState.Entities); + Assert.Empty(fixture.VisibilityEdges); + + fixture.Controller.OnCreate(Spawn(parentGuid, Cell)); + // The replayed child's residence was recorded mid-drain; the next + // frame's pump (the per-frame retry phase) drives its conductor. + fixture.FirstEntry.DriveAll(); + + Assert.Equal(2, residencesBegan); + Assert.False(fixture.Runtime.ParentAttachments.ContainsDeferredCreate( + childGuid, + instanceSequence: 1)); + Assert.True(fixture.Runtime.TryGetCanonical( + childGuid, + out RuntimeEntityRecord child)); + Assert.False(fixture.Runtime.HasActiveInitialCreateResidence(child)); + Assert.Equal(0, fixture.FirstEntry.PendingCount); + // The parented child is celless and presentation-suppressed until its + // own attach/position flow — only the parent is world-visible. + Assert.Equal(0u, child.FullCellId); + Assert.False(fixture.Runtime.TryGetRecord(childGuid, out _)); + Assert.True(fixture.Runtime.TryGetRecord( + parentGuid, + out LiveEntityRecord parent)); + Assert.True(parent.IsSpatiallyVisible); + Assert.Single(fixture.WorldState.Entities); + } + + [Fact] + public void LocalLogin_PresentationAttachFailure_RetriesWithoutRuntimeRollback() + { + using var fixture = new HostFixture(playerGuid: Guid); + // The camera/shadow-analog App attach failure: the first + // world-visibility binding throws AFTER Runtime committed the + // controller/body/placement. + fixture.VisibilityFailuresRemaining = 1; + + fixture.Controller.OnCreate(Spawn(Guid, Cell)); + + // Runtime is NOT rolled back by the App-side presentation failure: + // the published movement controller, canonical body, and committed + // cell all survive; only the completion receipt stays pending for + // the per-frame retry. + AcDream.Runtime.Gameplay.PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + Assert.True(controller.IsRuntimePublished); + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + Assert.Equal(Cell, record.Canonical.FullCellId); + Assert.NotNull(record.PhysicsBody); + Assert.False(fixture.Runtime.HasActiveInitialCreateResidence( + record.Canonical)); + Assert.Equal(1, fixture.EntityObjects.Placements.PendingCount); + Assert.Equal((record, true), Assert.Single(fixture.VisibilityEdges)); + + Assert.True(fixture.Subscription.RetryPending()); + + Assert.Same(controller, fixture.Movement.Controller); + Assert.True(controller.IsRuntimePublished); + Assert.Equal(0, fixture.EntityObjects.Placements.PendingCount); + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.Equal(2, fixture.VisibilityEdges.Count); + } + + /// + /// C3c-F5: through the REAL flipped host wiring (hydration -> + /// residence -> conductor -> publication -> dormant activation), a + /// login onto flat ground must complete with retail's + /// first-gravity-frame contact (SmartBox::HandleCreateObject 0x00454C80 + /// -> init_player 0x00455010 -> CPhysicsObj::enter_world 0x00516170 + + /// the first simulated frame's touch, compressed via the shared #270 + /// settle) — and the outbound motion snapshot must report grounded, + /// the exact bit LocalPlayerOutboundController serializes and ACE's + /// "You can't do that while in the air!" gate reads. Spawn feet at 5 + /// over a flat floor at 4.7 (0.3 m inside the settle reach), with the + /// production human bottom-sphere origin so the authored placement + /// stands clear of the floor. + /// + [Fact] + public void LocalLogin_FlatGround_ReportsGroundedOutboundContactBit() + { + using var fixture = new HostFixture( + playerGuid: Guid, + terrainHeight: 4.7f, + moverSphereOriginZ: 0.475f); + + fixture.Controller.OnCreate(Spawn(Guid, Cell)); + + AcDream.Runtime.Gameplay.PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + Assert.True(controller.IsRuntimePublished); + Assert.True(fixture.Runtime.TryGetRecord( + Guid, + out LiveEntityRecord record)); + PhysicsBody body = Assert.IsType(record.PhysicsBody); + Assert.True(body.InWorld); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + Assert.True(body.ContactPlaneValid); + Assert.InRange(body.Position.Z, 4.65f, 4.76f); + Assert.True(controller.CanSendPositionEvent); + Assert.True(controller.CaptureMovementResult( + mouseLookEvent: false).IsOnGround); + Assert.Equal(0, fixture.FirstEntry.PendingCount); + } + + /// + /// C3c-F5 counterpart through the same real wiring: a login spawn with + /// no floor within the settle's reach stays genuinely airborne — no + /// forced grounding anywhere in the first-entry sequence. + /// + [Fact] + public void LocalLogin_AirborneSpawn_StaysGenuinelyAirborne() + { + using var fixture = new HostFixture( + playerGuid: Guid, + moverSphereOriginZ: 0.475f); + + fixture.Controller.OnCreate(Spawn(Guid, Cell)); + + AcDream.Runtime.Gameplay.PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + Assert.True(controller.IsRuntimePublished); + Assert.True(fixture.Runtime.TryGetRecord( + Guid, + out LiveEntityRecord record)); + PhysicsBody body = Assert.IsType(record.PhysicsBody); + Assert.True(body.InWorld); + Assert.False(body.InContact); + Assert.False(body.OnWalkable); + Assert.False(controller.CanSendPositionEvent); + Assert.False(controller.CaptureMovementResult( + mouseLookEvent: false).IsOnGround); + } + + [Fact] + public void GraphicalAndDirectHosts_CommitIdenticalFirstEntryRuntimeFacts() + { + // Graphical host: full flipped wiring. + using var graphical = new HostFixture(playerGuid: 0u); + graphical.Controller.OnCreate(Spawn(Guid, Cell)); + Assert.True(graphical.Runtime.TryGetCanonical( + Guid, + out RuntimeEntityRecord graphicalRecord)); + + // Direct (no-window) host: the same canonical machinery with no + // presentation at all — registration, drive pump, ack-only + // subscription (the headless host shape). + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime direct = + LiveEntityRuntimeFixture.CreateDriven( + new GpuWorldState(), + new NoopResources()); + RuntimeEntityRecord directRecord = Assert.IsType( + direct.Lifetime.RegisterEntityWithInitialResidence( + Spawn(Guid, Cell), + isLocalPlayer: false).Canonical); + Assert.True(direct.Lifetime.ApplyAcceptedSpawn( + directRecord, + directRecord.CreateIntegrationVersion, + directRecord.Snapshot, + replaceGeneration: false)); + direct.Pump(); + + Assert.Equal( + FirstEntryFacts.Capture(graphicalRecord), + FirstEntryFacts.Capture(directRecord)); + Assert.False(graphical.Runtime.HasActiveInitialCreateResidence( + graphicalRecord)); + Assert.Equal(0, direct.FirstEntry.PendingCount); + Assert.Equal(0, graphical.FirstEntry.PendingCount); + } + + private readonly record struct FirstEntryFacts( + uint ServerGuid, + ushort Incarnation, + uint? LocalEntityId, + uint FullCellId, + uint CanonicalLandblockId, + ulong PositionAuthorityVersion, + ulong PlacementCommitVersion, + ulong CreateIntegrationVersion, + ushort SnapshotPositionSequence, + bool HasBody, + Vector3 BodyPosition, + Quaternion BodyOrientation, + PhysicsStateFlags BodyState, + bool BodyInWorld) + { + internal static FirstEntryFacts Capture(RuntimeEntityRecord record) => + new( + record.ServerGuid, + record.Incarnation, + record.LocalEntityId, + record.FullCellId, + record.CanonicalLandblockId, + record.PositionAuthorityVersion, + record.PlacementCommitVersion, + record.CreateIntegrationVersion, + record.Snapshot.PositionSequence, + record.PhysicsBody is not null, + record.PhysicsBody?.Position ?? default, + record.PhysicsBody?.Orientation ?? default, + record.PhysicsBody?.State ?? default, + record.PhysicsBody?.InWorld ?? false); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) + { + var position = new CreateObject.ServerPosition( + cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + [], + [], + [], + null, + null, + "first entry", + (uint)ItemType.Creature, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private static WorldSession.EntitySpawn ParentedSpawn( + uint guid, + uint parentGuid) + { + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: null, + Movement: null, + AnimationFrame: 1u, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: new PhysicsAttachment(parentGuid, LocationId: 1u), + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + Position: null, + SetupTableId: 0x02000001u, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: "deferred child", + ItemType: (uint)ItemType.Creature, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + ParentGuid: parentGuid, + ParentLocation: 1u, + PlacementId: 1u, + Physics: physics); + } + + private sealed class HostFixture : IDisposable + { + internal readonly RuntimeEntityObjectLifetime EntityObjects = new(); + internal readonly LiveEntityRuntime Runtime; + internal readonly LiveEntityHydrationController Controller; + internal readonly HostMaterializer Materializer; + internal readonly AcDream.Runtime.Session.RuntimeFirstEntryDriveController + FirstEntry; + internal readonly AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription Subscription; + internal readonly AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState + Movement; + internal readonly WorldGameState WorldState = new(); + internal readonly List<(LiveEntityRecord Record, bool Visible)> + VisibilityEdges = []; + internal int VisibilityFailuresRemaining; + + internal HostFixture( + uint playerGuid, + float terrainHeight = 0f, + float moverSphereOriginZ = 0f) + { + EntityObjects.BindEventContext( + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + static () => 1UL); + EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, 1UL); + EntityObjects.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface( + new byte[81], + Enumerable.Repeat(terrainHeight, 256).ToArray()), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, 1UL, ready: true); + Movement = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerMovementState(); + var runtimeIdentity = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerIdentityState(); + var publication = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsPublicationState( + EntityObjects.Entities, + EntityObjects.Physics, + Movement, + runtimeIdentity); + Movement.AttachPhysicsPublication(publication); + EntityObjects.LocalPlayerFirstEntry.BindPublication(publication); + runtimeIdentity.ServerGuid = playerGuid; + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + (Cell & 0xFFFF0000u) | 0xFFFFu, + new LandBlock(), + Array.Empty())); + Runtime = new LiveEntityRuntime( + spatial, + new NoopResources(), + EntityObjects); + FirstEntry = new AcDream.Runtime.Session + .RuntimeFirstEntryDriveController( + EntityObjects, + new AcDream.Runtime.GameRuntimeClock(), + new SphereCollisionSource(moverSphereOriginZ), + static () => AcDream.Runtime.Gameplay + .PlayerMovementConstructionOptions.Fallback, + static _ => new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, + 1.835f, + AcDream.Runtime.Gameplay + .RuntimeLocalPlayerShadowDisposition + .ProvenShapeless)); + var sink = new RuntimePlacementPresentationSink( + Runtime, + new RuntimeWorldTransitState(), + WorldState, + new WorldEvents(), + new EntityEffectPoseRegistry(), + new LocalPlayerShadowState(), + () => playerGuid, + _ => { }, + [ + (record, visible) => + { + VisibilityEdges.Add((record, visible)); + if (VisibilityFailuresRemaining > 0) + { + VisibilityFailuresRemaining--; + throw new InvalidOperationException( + "fixture presentation attach failure"); + } + }, + ]); + Subscription = new AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription( + EntityObjects.Placements, + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + sink); + Materializer = new HostMaterializer(Runtime); + var identity = new LocalPlayerIdentityState + { + ServerGuid = playerGuid, + }; + var dormant = new DormantLiveEntityStore(); + var teardown = new NoopTeardown(); + var deletion = new LiveEntityDeletionController( + Runtime, + EntityObjects, + teardown, + identity, + dormant); + Controller = new LiveEntityHydrationController( + Runtime, + EntityObjects, + new object(), + Materializer, + new NoopRelationships(), + new AcceptingReady(), + new KnownOrigin(), + new NoopNetworkSink(), + new NoopTimestamps(), + identity, + deletion, + dormant, + firstEntry: FirstEntry); + } + + public void Dispose() + { + try + { + Runtime.Clear(); + } + catch + { + // Failure-path tests assert their own exceptions. + } + } + } + + /// + /// Mirrors the production materializer + /// (DatLiveEntityProjectionMaterializer.MaterializeProjection): route-1 + /// world creates materialize residence-managed and self-project only when + /// the committed cell already exists with no active residence. + /// + private sealed class HostMaterializer(LiveEntityRuntime runtime) + : ILiveEntityProjectionMaterializer + { + internal Action? AfterMaterialize { get; set; } + + public bool TryMaterialize( + RuntimeEntityRecord expectedCanonical, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + AcDream.App.Rendering.LiveEntityAppearanceUpdateState? appearanceUpdate = null) + { + if (canonicalSpawn.Position is not { } position + || canonicalSpawn.SetupTableId is null) + { + return false; + } + + WorldEntity? entity = runtime.MaterializeLiveEntity( + expectedCanonical, + position.LandblockId, + id => new WorldEntity + { + Id = id, + ServerGuid = canonicalSpawn.Guid, + SourceGfxObjOrSetupId = canonicalSpawn.SetupTableId.Value, + Position = new Vector3( + position.PositionX, + position.PositionY, + position.PositionZ), + Rotation = Quaternion.Identity, + MeshRefs = [], + ParentCellId = position.LandblockId, + }, + LiveEntityProjectionKind.World, + initializeProjection: null, + out LiveEntityRecord? record, + LiveEntityMaterializationResidence.AwaitRuntimePlacement); + if (entity is null || record is null) + return false; + if (runtime.IsCurrentCreateIntegration( + expectedCanonical, + expectedCreateIntegrationVersion) + && expectedCanonical.FullCellId != 0u + && !runtime.HasActiveInitialCreateResidence(expectedCanonical) + && !runtime.RebucketLiveEntity( + canonicalSpawn.Guid, + expectedCanonical.FullCellId)) + { + return false; + } + AfterMaterialize?.Invoke(record); + return runtime.IsCurrentRecord(record); + } + + public void ResetSessionState() + { + } + } + + private sealed class NoopResources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) + { + } + + public void Unregister(WorldEntity entity) + { + } + } + + private sealed class NoopTeardown : ILiveEntityTeardownCoordinator + { + public void TearDown(LiveEntityRecord record) + { + } + + public void ForgetUnknownOwner(uint serverGuid) + { + } + } + + private sealed class NoopRelationships : ILiveEntityRelationshipProjection + { + public void OnSpawn(WorldSession.EntitySpawn spawn) + { + } + + public void OnParent(ParentEvent.Parsed update) + { + } + + public void OnCreateParentAccepted(CreateParentUpdate update) + { + } + + public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid) => + ChildUnparentDisposition.Completed; + + public bool TryApplyAttachedAppearance( + LiveEntityRecord record, + ulong objDescAuthorityVersion) => false; + } + + private sealed class AcceptingReady : ILiveEntityReadyPublisher + { + public bool Publish(LiveEntityReadyCandidate candidate) => true; + } + + private sealed class KnownOrigin : 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 NoopTimestamps : IAcceptedLocalPhysicsTimestampPublisher + { + public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps) + { + } + } + + private sealed class SphereCollisionSource(float sphereOriginZ = 0f) + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult + .Loaded(new FlatSetupCollision( + System.Collections.Immutable.ImmutableArray< + FlatCollisionCylinder>.Empty, + [new FlatCollisionSphere( + new Vector3(0f, 0f, sphereOriginZ), + 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + public AcDream.Content.PreparedCollisionReadResult< + FlatGfxObjCollisionAsset> ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + FlatCellStructureCollisionAsset> ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } +} diff --git a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs index 1d293203..7398e43d 100644 --- a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs @@ -757,6 +757,16 @@ public sealed class RuntimePlacementPresentationSinkTests internal LiveEntityRecord Materialize(WorldSession.EntitySpawn spawn) { LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(spawn); + // C3c fixture normalization: RegisterAndMaterializeProjection's + // legacy-immediate rebucket commits the wire cell out-of-band of + // the fresh initial-create residence, leaving that residence + // stale. Converge it deterministically HERE (the query performs + // the lazy retirement, releasing the residence's never-driven + // initial SetPosition operation) so ownership snapshots captured + // by tests reflect the settled post-registration state instead of + // shifting inside the sink's own first residence-gate query. + Assert.False(Runtime.HasActiveInitialCreateResidence( + record.Canonical)); Assert.True(record.ResourcesRegistered); WorldEntity entity = record.WorldEntity!; var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 4adcb828..28527d3e 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -740,16 +740,22 @@ public sealed class UpdateFrameOrchestratorTests "AcDream.App", "Input", "PlayerModeController.cs")); + // C3c (clause 4 — sealed-setter lifecycle routing): the movement + // controller, physics body, host, and committed placement are + // Runtime-owned, published by the first-entry conductor's + // publication transaction. Player-mode entry attaches presentation + // only: it gates on the Runtime-published controller, then wires + // camera -> shadow -> host slot -> mode flag. The old pinned + // markers (PreparePositionForCommit / InstallOrRebind / + // CommitPreparedPosition / `_controllerSlot.Controller =`) were + // exactly the App-side controller construction+commit this flip + // deleted. AssertAppearsInOrder( playerModeSource, - "controller.PreparePositionForCommit(", + "controller.IsRuntimePublished", "_camera.EnterChaseMode(legacyCamera, retailCamera);", - "EntityPhysicsHostComposition.SelectStableHostWithoutRebind(", "_shadow.SyncPose(", - "EntityPhysicsHostComposition.InstallOrRebind(", - "playerEntity.SetPosition(initial.Position);", - "controller.CommitPreparedPosition();", - "_controllerSlot.Controller = controller;", + "_hostSlot.Host = playerHost;", "_mode.IsPlayerMode = true;"); Assert.Contains("_shadow.Restore(playerEntity, priorShadow);", playerModeSource, StringComparison.Ordinal); diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 91194f05..1b7939a5 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -56,6 +56,29 @@ public class ShadowObjectRegistryTests Assert.Equal(1, reg.TotalRegistered); } + [Fact] + public void Register_CornerLandblock_DerivesRealOutdoorSeed() + { + // C3c-F3: landblock (0,0) — id 0x0000FFFF — has prefix 0x00000000. + // DeriveOutdoorSeed's prefix-0 "absent" sentinel used to reject it, + // silently dropping every landblock-baked static in the map-corner + // block. Local (12,12) = cell (0,0) = cellId 0x00000000 | 1. + var reg = new ShadowObjectRegistry(); + reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, 0x0000FFFFu); + Assert.Equal(1, reg.TotalRegistered); + Assert.Contains(reg.GetObjectsInCell(0x00000001u), e => e.EntityId == 1u); + } + + [Fact] + public void Register_AbsentLandblockId_StillKeepsWhenEmpty() + { + // The genuine "no landblock" input (id 0) must keep the + // keep-when-empty behavior (retail pc:283540) the sentinel provided. + var reg = new ShadowObjectRegistry(); + reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, 0u); + Assert.Equal(0, reg.TotalRegistered); + } + // ----------------------------------------------------------------------- // GetObjectsInCell // ----------------------------------------------------------------------- diff --git a/tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs b/tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs similarity index 86% rename from tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs rename to tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs index cbef8c97..a3e60a1c 100644 --- a/tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs @@ -1,10 +1,15 @@ using System.Numerics; -using AcDream.App.Physics; using AcDream.Core.Physics; -namespace AcDream.App.Tests.Physics; +namespace AcDream.Core.Tests.Physics; -public sealed class RemoteSpawnPlacementSettlerTests +/// +/// C3c-F5: moved from tests/AcDream.App.Tests/Physics/ +/// RemoteSpawnPlacementSettlerTests.cs when the #270 settler moved to Core +/// (App -> ) so the local player's +/// Runtime first-entry activation can share it. Test bodies unchanged. +/// +public sealed class SpawnPlacementSettlerTests { private const uint Landblock = 0xA9B40000u; private const uint Cell = Landblock | 0x0001u; @@ -19,7 +24,7 @@ public sealed class RemoteSpawnPlacementSettlerTests int hitGround = 0; int leaveGround = 0; - bool settled = RemoteSpawnPlacementSettler.TrySettle( + bool settled = SpawnPlacementSettler.TrySettle( engine, body, body.Position, @@ -71,7 +76,7 @@ public sealed class RemoteSpawnPlacementSettlerTests } private static bool TrySettle(PhysicsEngine engine, PhysicsBody body) => - RemoteSpawnPlacementSettler.TrySettle( + SpawnPlacementSettler.TrySettle( engine, body, body.Position, diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 7e4c2886..ff9ab063 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -238,11 +238,25 @@ public sealed class HeadlessSessionHostTests new HeadlessDiagnosticWriter(TextWriter.Null), operations); GameRuntime runtime = host.Runtime; + // C3c: conductor-driven flow — a live generation admits the initial + // residence, the flat landblock's collision generation commits, and + // the world projection's pump drives the local first-entry conductor + // to completion (the deleted SynchronizeLocalPlayer hand-copy's + // replacement). + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); const uint player = 0x50000002u; runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); RuntimeEntityRecord record = runtime.EntityObjects - .RegisterEntity(Spawn(player)) + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) .Canonical!; Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( record, @@ -252,7 +266,8 @@ public sealed class HeadlessSessionHostTests var collision = new FixtureCollisionNeighborhood(); var projection = new HeadlessSessionWorldProjection( runtime, - collision); + collision, + firstEntry); projection.ProjectSpawn(record, isLocalPlayer: true); PlayerMovementController controller = @@ -310,11 +325,25 @@ public sealed class HeadlessSessionHostTests new HeadlessDiagnosticWriter(TextWriter.Null), operations); GameRuntime runtime = host.Runtime; + // C3c: conductor-driven flow — a live generation admits the initial + // residence, the flat landblock's collision generation commits, and + // the world projection's pump drives the local first-entry conductor + // to completion (the deleted SynchronizeLocalPlayer hand-copy's + // replacement). + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); const uint player = 0x50000003u; runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); RuntimeEntityRecord record = runtime.EntityObjects - .RegisterEntity(Spawn(player)) + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) .Canonical!; Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( record, @@ -324,7 +353,8 @@ public sealed class HeadlessSessionHostTests var collision = new FixtureCollisionNeighborhood(); var projection = new HeadlessSessionWorldProjection( runtime, - collision); + collision, + firstEntry); projection.ProjectSpawn(record, isLocalPlayer: true); PlayerMovementController controller = Assert.IsType( @@ -376,6 +406,104 @@ public sealed class HeadlessSessionHostTests Assert.Equal(2, collision.CenterCount); } + /// + /// C3c-R1 review F7: a remote Create whose landblock lies outside the + /// bounded collision neighborhood's service window can never see its + /// deferred placement's collision-generation wake — left alone it would + /// pin its residence (and the drive's pending entry) for the whole + /// session. The host converts it to the celless completion route before + /// the pump: the residence completes with FullCell 0, the accepted wire + /// frame stays on the canonical snapshot (the exact pre-flip + /// accepted-frame behavior for far remotes), and every ledger converges. + /// + [Fact] + public void FarRemoteCreateCompletesCelllessWithoutPinningItsResidence() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + const uint player = 0x5000000Bu; + runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); + AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); + var collision = new FixtureCollisionNeighborhood(); + var projection = new HeadlessSessionWorldProjection( + runtime, + collision, + firstEntry); + RuntimeEntityRecord playerRecord = runtime.EntityObjects + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + playerRecord, + playerRecord.CreateIntegrationVersion, + playerRecord.Snapshot, + replaceGeneration: false)); + projection.ProjectSpawn(playerRecord, isLocalPlayer: true); + + const uint farRemote = 0x70000010u; + const uint farCell = 0x00010001u; + Assert.False(collision.IsWithinServiceWindow(farCell)); + RuntimeEntityRecord remote = runtime.EntityObjects + .RegisterEntityWithInitialResidence( + Spawn(farRemote, farCell), + isLocalPlayer: false) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + remote, + remote.CreateIntegrationVersion, + remote.Snapshot, + replaceGeneration: false)); + + projection.ProjectSpawn(remote, isLocalPlayer: false); + + // Celless completion: no pinned residence, FullCell stays 0, the + // accepted wire frame survives on the canonical snapshot. + Assert.False(runtime.EntityObjects.TryGetInitialCreateResidence( + remote, + out _)); + Assert.Equal(0u, remote.FullCellId); + Assert.Equal( + farCell, + remote.Snapshot.Position!.Value.LandblockId); + RuntimeEntityObjectOwnershipSnapshot ownership = + runtime.EntityObjects.CaptureOwnership(); + Assert.Equal(0, ownership.InitialCreateResidenceLeaseCount); + Assert.Equal(0, ownership.FirstEntryDrivePendingCount); + Assert.Equal(0, firstEntry.PendingCount); + + // Draining the placement FIFO the way the host subscription would + // converges the completion-receipt ledger too. + while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + if (!runtime.EntityObjects.Physics.SetPosition + .AcknowledgeProjection(head.Token)) + { + break; + } + } + Assert.Equal( + 0, + runtime.EntityObjects.CaptureOwnership() + .PendingCompletionReceiptCount); + } + [Fact] public void PlacementReceiptValidationDoesNotRegainMovementOrPhysicsAuthority() { @@ -389,11 +517,25 @@ public sealed class HeadlessSessionHostTests new HeadlessDiagnosticWriter(TextWriter.Null), operations); GameRuntime runtime = host.Runtime; + // C3c: conductor-driven flow — a live generation admits the initial + // residence, the flat landblock's collision generation commits, and + // the world projection's pump drives the local first-entry conductor + // to completion (the deleted SynchronizeLocalPlayer hand-copy's + // replacement). + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); const uint player = 0x50000004u; runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); RuntimeEntityRecord record = runtime.EntityObjects - .RegisterEntity(Spawn(player)) + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) .Canonical!; Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( record, @@ -402,7 +544,8 @@ public sealed class HeadlessSessionHostTests replaceGeneration: false)); var directProjection = new HeadlessSessionWorldProjection( runtime, - new FixtureCollisionNeighborhood()); + new FixtureCollisionNeighborhood(), + firstEntry); directProjection.ProjectSpawn(record, isLocalPlayer: true); PlayerMovementController controller = Assert.IsType< PlayerMovementController>(runtime.MovementOwner.Controller); @@ -1010,6 +1153,197 @@ public sealed class HeadlessSessionHostTests private sealed class FixtureCollisionPublicationException : Exception; + [Fact] + public void MissingPreparedCollisionYieldsTypedRetryAndCompletesWhenAvailable() + { + // C3c contract: the prepared-collision read failure is the + // conductor's typed AwaitingCollisionSource retry — no + // InvalidDataException (or any exception) escapes the host wiring, + // the entity stays a tracked, re-drivable first entry, and the same + // sequence completes once the source can serve the Setup. + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + const uint player = 0x50000021u; + runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); + AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + var source = new FlakySetupCollisionSource(); + var firstEntry = new AcDream.Runtime.Session + .RuntimeFirstEntryDriveController( + runtime.EntityObjects, + runtime.Clock, + source, + () => PlayerMovementConstructionOptions.From( + runtime.CharacterOwner.MovementSkills.Snapshot), + static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + record, + record.CreateIntegrationVersion, + record.Snapshot, + replaceGeneration: false)); + var collision = new FixtureCollisionNeighborhood(); + var projection = new HeadlessSessionWorldProjection( + runtime, + collision, + firstEntry); + + projection.ProjectSpawn(record, isLocalPlayer: true); + + Assert.True(source.SetupReadAttempts >= 1); + Assert.Null(runtime.MovementOwner.Controller); + Assert.Equal(1, firstEntry.PendingCount); + Assert.Equal(0u, record.FullCellId); + + source.Available = true; + // The session tick's retry pump. + firstEntry.DriveAll(); + + Assert.IsType( + runtime.MovementOwner.Controller); + Assert.Equal(0, firstEntry.PendingCount); + Assert.Equal(0xA9B40000u, record.FullCellId & 0xFFFF0000u); + Assert.NotEqual(0u, record.FullCellId); + } + + private sealed class FlakySetupCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + internal bool Available { get; set; } + internal int SetupReadAttempts { get; private set; } + + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + SetupReadAttempts++; + if (!Available) + { + return AcDream.Content.PreparedCollisionReadResult< + FlatSetupCollision>.Missing; + } + return AcDream.Content.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 AcDream.Content.PreparedCollisionReadResult< + FlatGfxObjCollisionAsset> ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + FlatCellStructureCollisionAsset> ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } + + private static AcDream.Runtime.Session.RuntimeFirstEntryDriveController + CreateFirstEntryDrive(GameRuntime runtime) => new( + runtime.EntityObjects, + runtime.Clock, + new LoadedSetupCollisionSource(), + () => PlayerMovementConstructionOptions.From( + runtime.CharacterOwner.MovementSkills.Snapshot), + static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + + private sealed class LoadedSetupCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult + .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 AcDream.Content.PreparedCollisionReadResult< + FlatGfxObjCollisionAsset> ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + FlatCellStructureCollisionAsset> ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } + private static void AddFlatLandblock(PhysicsEngine engine) { var heights = new byte[81]; @@ -1026,10 +1360,12 @@ public sealed class HeadlessSessionHostTests worldOffsetY: 0f); } - private static WorldSession.EntitySpawn Spawn(uint guid) + private static WorldSession.EntitySpawn Spawn( + uint guid, + uint cellId = 0xA9B40001u) { var position = new CreateObject.ServerPosition( - 0xA9B40001u, + cellId, 96f, 97f, 50f, @@ -1217,6 +1553,22 @@ public sealed class HeadlessSessionHostTests public bool IsReady(uint fullCellId) => fullCellId == LastCell; + + // C3c-R1 review F7: the fixture window mirrors production's 3x3 + // membership around the last requested center; no center yet means + // "within" (never convert before the first CenterOn). + public bool IsWithinServiceWindow(uint fullCellId) + { + if (LastCell == 0u) + return true; + int dx = Math.Abs( + (int)((fullCellId >> 24) & 0xFFu) + - (int)((LastCell >> 24) & 0xFFu)); + int dy = Math.Abs( + (int)((fullCellId >> 16) & 0xFFu) + - (int)((LastCell >> 16) & 0xFFu)); + return dx <= 1 && dy <= 1; + } } private sealed class FixtureEventRoute( diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs index 07ae7bd7..d3d410ad 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs @@ -104,18 +104,13 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, second); Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); - const ulong generation = 1UL; - fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( - Cell & 0xFFFF0000u, generation); - fixture.Lifetime.Physics.Engine.AddLandblock( - Cell & 0xFFFF0000u, - new TerrainSurface(new byte[81], new float[256]), - Array.Empty(), - Array.Empty(), - worldOffsetX: 0f, - worldOffsetY: 0f); - fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( - Cell & 0xFFFF0000u, generation, ready: true); + // C3c-F2: the wake goes through the SAME owner production uses (the + // collision admission ledger). Driving the SetPosition seam directly + // leaves that ledger empty — a state production can never be in, and + // the reason this very test did not catch the login activation wedge. + CommitProductionCollisionGeneration( + fixture.Lifetime.Physics, + Cell & 0xFFFF0000u); Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); @@ -603,18 +598,9 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests // AwaitingActivation retry test does. Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, fixture.Advance(out _)); - const ulong generation = 1UL; - fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( - Cell & 0xFFFF0000u, generation); - fixture.Lifetime.Physics.Engine.AddLandblock( - Cell & 0xFFFF0000u, - new TerrainSurface(new byte[81], new float[256]), - Array.Empty(), - Array.Empty(), - worldOffsetX: 0f, - worldOffsetY: 0f); - fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( - Cell & 0xFFFF0000u, generation, ready: true); + CommitProductionCollisionGeneration( + fixture.Lifetime.Physics, + Cell & 0xFFFF0000u); RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance( out RuntimeInitialCreateExecutionReceipt receipt); @@ -631,6 +617,63 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests // Helpers // --------------------------------------------------------------- + /// + /// C3c-F2: publishes one landblock collision generation through the exact + /// production owner chain — BeginCollisionAdmission -> prepare -> stage -> + /// CommitCollisionGeneration — so a parked local-player activation wakes + /// the way it does live, with RuntimePhysicsState's admission ledger + /// populated and then retired. + /// + private static void CommitProductionCollisionGeneration( + RuntimePhysicsState physics, + uint landblockId) + { + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(landblockId); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + new RuntimeLandblockCollisionAssets( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + 0f, + 0f, + 0u)); + for (int poll = 0; poll < 10_000; poll++) + { + while (physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection)) + { + Assert.True(physics.SetPosition.AcknowledgeProjection( + projection.Token)); + } + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal(admission, prepared); + } + while (!seal.Completed && !seal.Restarted); + if (!seal.Completed) + continue; + if (physics.CommitCollisionGeneration(admission, prepared).Completed) + return; + } + + throw new InvalidOperationException( + "Collision generation did not complete its Runtime mutation transaction."); + } + private static (RuntimeEntityRecord Record, RuntimePlacementProjectionToken Token) BeginPendingOrdinaryPlacement(Fixture fixture, uint guid) { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs index 1c6194e9..e4517127 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs @@ -244,4 +244,261 @@ public sealed class RuntimeLocalPlayerMovementStateTests Assert.Equal(0L, allocated); } + + // ── C3c-F1 (2026-08-02): movement-stats application seam ────────────── + // + // The typed seam is the ONLY route by which server stat recomputes + // reach the controller; the publication lifecycle decides whether the + // write lands (live/dormant) or is a typed displaced drop (terminal). + + private static RuntimeMovementSkillState CompleteSkillState() + { + var skills = new RuntimeMovementSkillState(); + skills.Update(runSkill: 240, jumpSkill: 180); + skills.UpdateBurden(0.5f); + skills.UpdateStamina(37); + // BF_PLAYER (0x8) + BF_PLAYER_KILLER (0x20) → ObjectInfoState.IsPK. + skills.UpdateOwnPwdBitfield(0x28u); + skills.UpdatePlayerKillerStatus(1, 123.5f); + return skills; + } + + private static PlayerMovementController NewDormantRuntimeController() + { + PlayerMovementController controller = + PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + controller.SealPublicationCandidate(); + controller.CommitRuntimeOwnership(new RetailObjectQuantumClock()); + return controller; + } + + private static (float RunRate, float JumpVz, bool CanJump, int JumpCost, + ObjectInfoState PvpFlags) CaptureStatObservables( + PlayerMovementController controller) + { + IWeenieObject weenie = controller.Motion.WeenieObj!; + Assert.True(weenie.InqRunRate(out float runRate)); + Assert.True(weenie.InqJumpVelocity(1.0f, out float jumpVz)); + bool canJump = weenie.CanJump(1.0f); + Assert.True(((PlayerWeenie)weenie).JumpStaminaCost(1.0f, out int cost)); + return (runRate, jumpVz, canJump, cost, controller.OwnPvpFlags); + } + + [Fact] + public void ApplyCharacterMovementStats_LiveApplicationIsByteIdenticalToTheRetiredDirectPath() + { + RuntimeMovementSkillState skills = CompleteSkillState(); + RuntimeMovementSkillSnapshot snapshot = skills.Snapshot; + + // Old direct path (the deleted RuntimeMovementSkillProjection.ApplyTo + // body), applied through the public gated setters. + var direct = new PlayerMovementController(new PhysicsEngine()); + direct.SetCharacterSkills(snapshot.RunSkill, snapshot.JumpSkill); + direct.SetCharacterBurden(snapshot.Burden); + direct.SetCharacterStamina(snapshot.CurrentStamina); + direct.OwnPvpFlags = EntityCollisionFlagsExt + .FromPwdBitfield(snapshot.OwnPwdBitfield) + .ToMoverState(); + direct.SetCharacterPkStatus( + snapshot.PlayerKillerStatus, + snapshot.LastPkAttackTimestamp); + + var routed = new PlayerMovementController(new PhysicsEngine()); + using var movement = new RuntimeLocalPlayerMovementState + { + Controller = routed, + }; + + Assert.Equal( + RuntimeMovementStatsApplication.AppliedLive, + movement.ApplyCharacterMovementStats(skills)); + Assert.Equal( + CaptureStatObservables(direct), + CaptureStatObservables(routed)); + Assert.Equal(ObjectInfoState.IsPK, routed.OwnPvpFlags); + } + + [Fact] + public void ApplyCharacterMovementStats_DormantWindowWriteLandsOnTheControllerThatGoesLive() + { + RuntimeMovementSkillState skills = CompleteSkillState(); + PlayerMovementController controller = NewDormantRuntimeController(); + using var movement = new RuntimeLocalPlayerMovementState + { + Controller = controller, + }; + + // The committed-but-unactivated first-entry window: the dormant + // controller is installed in the movement owner while activation + // waits on cell streaming, and inbound recomputes keep pumping. + Assert.True(controller.IsRuntimeOwnedDormant); + Assert.Throws( + () => controller.SetCharacterSkills(1, 1)); + Assert.Equal( + RuntimeMovementStatsApplication.AppliedDormant, + movement.ApplyCharacterMovementStats(skills)); + + // Activation flips the SAME instance live — the applied values are + // already current when movement starts. + controller.ActivateRuntimePublication(); + Assert.True(controller.IsRuntimePublished); + (float runRate, _, _, _, ObjectInfoState pvp) = + CaptureStatObservables(controller); + Assert.Equal( + PlayerWeenie.GetRunRate(0.5f, 240), + runRate, + precision: 5); + Assert.Equal(ObjectInfoState.IsPK, pvp); + } + + [Fact] + public void ApplyCharacterMovementStats_TerminalControllerReportsTypedDisplacedDrop() + { + RuntimeMovementSkillState skills = CompleteSkillState(); + PlayerMovementController controller = NewDormantRuntimeController(); + using var movement = new RuntimeLocalPlayerMovementState + { + Controller = controller, + }; + controller.ActivateRuntimePublication(); + var before = CaptureStatObservables(controller); + // The Motion surface is gated once terminal; capture the weenie + // reference while the controller is still published. + IWeenieObject weenie = controller.Motion.WeenieObj!; + + // Post-teardown transient truth: the controller retired while still + // reachable through a displaced recompute callback. The public + // setter still throws (the seal working); the seam reports the + // typed drop instead and mutates nothing. + controller.RetireRuntimePublication(); + Assert.Throws( + () => controller.SetCharacterSkills(1, 1)); + Assert.Equal( + RuntimeMovementStatsApplication.DroppedDisplacedController, + movement.ApplyCharacterMovementStats(skills)); + + Assert.True(weenie.InqRunRate(out float runRate)); + Assert.Equal(before.RunRate, runRate); + Assert.Equal(before.PvpFlags, controller.OwnPvpFlags); + } + + [Fact] + public void ApplyCharacterMovementStats_SealedCandidateIsATypedDisplacedDrop() + { + // Defensive lifecycle-matrix row: a sealed candidate is frozen for + // the Prepare→Commit validation handshake and never installed into + // the movement owner in production (Prepare requires the owner + // empty; Commit installs it already-dormant within the same + // synchronous conductor step), so only a direct controller-level + // call can observe this row. + PlayerMovementController controller = + PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + controller.SealPublicationCandidate(); + + Assert.Equal( + RuntimeMovementStatsApplication.DroppedDisplacedController, + controller.ApplyCharacterMovementStats( + CompleteSkillState().Snapshot)); + } + + [Fact] + public void ApplyCharacterMovementStats_AbsentControllerAndIncompleteSnapshotDropSilently() + { + var skills = new RuntimeMovementSkillState(); + using var movement = new RuntimeLocalPlayerMovementState(); + + Assert.Equal( + RuntimeMovementStatsApplication.DroppedNoController, + movement.ApplyCharacterMovementStats(skills)); + + movement.Controller = new PlayerMovementController(new PhysicsEngine()); + Assert.Equal( + RuntimeMovementStatsApplication.DroppedIncompleteSnapshot, + movement.ApplyCharacterMovementStats(skills)); + } + + [Fact] + public void ApplyCharacterMovementStats_DisposedOwnerToleratesTheDisplacedCallback() + { + var movement = new RuntimeLocalPlayerMovementState + { + Controller = new PlayerMovementController(new PhysicsEngine()), + }; + movement.Dispose(); + + // The displaced-callback-rejection pattern: a recompute landing + // after terminal disposal observes a typed drop, never a fault. + Assert.Equal( + RuntimeMovementStatsApplication.DroppedNoController, + movement.ApplyCharacterMovementStats(CompleteSkillState())); + Assert.False(movement.ReportExhaustion()); + } + + [Fact] + public void ReportExhaustion_DispatchesOnlyThroughALiveController() + { + using var movement = new RuntimeLocalPlayerMovementState(); + Assert.False(movement.ReportExhaustion()); + + PlayerMovementController controller = NewDormantRuntimeController(); + movement.Controller = controller; + Assert.False(movement.ReportExhaustion()); + + controller.ActivateRuntimePublication(); + Assert.True(movement.ReportExhaustion()); + + controller.RetireRuntimePublication(); + Assert.False(movement.ReportExhaustion()); + } + + [Fact] + public void ApplyServerPhysicsState_DormantDropsForActivationAndLiveAppliesExactly() + { + // The second connected-gate crash chain + // (logs/connected-world-gate-20260802-125907): an inbound local- + // player SetState pushed ApplyPhysicsState at the dormant + // first-entry controller. The typed entry decides by lifecycle. + PlayerMovementController controller = + PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + PhysicsBody body = controller.PhysicsBody; + PhysicsStateFlags initial = body.State; + PhysicsStateFlags pushed = + PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Ethereal; + Assert.NotEqual(initial, pushed); + + controller.SealPublicationCandidate(); + controller.CommitRuntimeOwnership(new RetailObjectQuantumClock()); + + // Dormant: the activation transaction owns the dormant body's + // physics state; the push is dropped and the body is untouched. + Assert.Equal( + RuntimeServerPhysicsStateApplication.DroppedDormantActivationOwned, + controller.ApplyServerPhysicsState(pushed)); + Assert.Equal(initial, body.State); + + // Published: byte-identical to the direct ApplyPhysicsState body. + controller.ActivateRuntimePublication(); + Assert.Equal( + RuntimeServerPhysicsStateApplication.AppliedLive, + controller.ApplyServerPhysicsState(pushed)); + Assert.Equal(pushed, body.State); + + // Terminal: a displaced push, typed instead of a fault, mutating + // nothing. + controller.RetireRuntimePublication(); + Assert.Throws( + () => controller.ApplyPhysicsState(initial)); + Assert.Equal( + RuntimeServerPhysicsStateApplication.DroppedDisplacedController, + controller.ApplyServerPhysicsState(initial)); + Assert.Equal(pushed, body.State); + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs index 0c432e83..3c8468d2 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -198,6 +198,175 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.True(body.InWorld); } + /// + /// C3c-F5: retail seeds the local player's ground contact from the first + /// gravity frame after enter_world (SmartBox::HandleCreateObject + /// 0x00454C80 → init_player 0x00455010 → CPhysicsObj::enter_world + /// 0x00516170; SetPosition records no touch), which the activation + /// compresses through the shared #270 settle sweep. Spawn (1,2,3) with a + /// flat floor at 2.7 — feet 0.3 m above it, inside the 0.5 m settle + /// reach — and the production human bottom-sphere origin (0.475) so the + /// authored placement itself stands clear of the floor. + /// + [Fact] + public void CommitActivationOnFlatGroundSeedsRetailFirstGravityFrameContact() + { + using var fixture = new Fixture( + residentWorld: true, + terrainHeight: 2.7f, + moverSphereOriginZ: 0.475f); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + Assert.True(body.ContactPlaneValid); + Assert.True(body.ContactPlane.Normal.Z > 0.9f); + // The settle snapped the feet onto the floor, exactly like retail's + // first gravity frame. + Assert.InRange(body.Position.Z, 2.65f, 2.76f); + PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + // The exact outbound predicate LocalPlayerOutboundController + // serializes as the wire contact bit (InContact && OnWalkable). + Assert.True(controller.CanSendPositionEvent); + Assert.True(controller.CaptureMovementResult( + mouseLookEvent: false).IsOnGround); + } + + /// + /// C3c-F5 counterpart: a spawn with no floor within the settle's reach + /// (flat terrain at 0, feet at 3) must stay genuinely airborne — the + /// seed only ever commits contact the sweep actually found, exactly + /// like retail's fall after enter_world. + /// + [Fact] + public void CommitActivationOverVoidLeavesFirstEntryGenuinelyAirborne() + { + using var fixture = new Fixture( + residentWorld: true, + moverSphereOriginZ: 0.475f); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.False(body.InContact); + Assert.False(body.OnWalkable); + Assert.False(body.ContactPlaneValid); + Assert.Equal(3f, body.Position.Z); + PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + Assert.False(controller.CanSendPositionEvent); + Assert.False(controller.CaptureMovementResult( + mouseLookEvent: false).IsOnGround); + } + + /// + /// C3c-R1 review R1: the login constraint leash. Retail arms the leash + /// at every accepted-position event (SmartBox::HandleReceivedPosition + /// 0x00453FD0); the flip deleted the App-side login arm + /// (CommitPreparedPosition), so the dormant activation's final commit + /// must arm it — anchored at the committed placement (already + /// floor-snapped by the faithful placement transaction), before the + /// compressed first-gravity-frame settle runs, exactly like retail arms + /// at the received position and only then simulates the first frame. + /// + [Fact] + public void CommitActivationArmsTheLoginConstraintLeashAtTheCommittedPlacement() + { + using var fixture = new Fixture( + residentWorld: true, + terrainHeight: 2.7f, + moverSphereOriginZ: 0.475f); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + + PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + ConstraintManager? constraint = + controller.PositionManager?.Constraint; + Assert.NotNull(constraint); + Assert.True(constraint!.IsConstrained); + // The anchor cell is the placement's committed containing cell. + Assert.Equal( + controller.PhysicsBody.CellPosition.ObjCellId, + constraint.ConstraintPos.ObjCellId); + // Anchored at the committed (floor-snapped) placement pose. + Assert.InRange( + constraint.ConstraintPos.Frame.Origin.Z, 2.65f, 2.76f); + Assert.InRange( + controller.PhysicsBody.Position.Z, 2.65f, 2.76f); + Assert.Equal( + ConstraintDistance.GetStartConstraintDistance( + constraint.ConstraintPos.ObjCellId), + constraint.ConstraintDistanceStart); + Assert.Equal( + ConstraintDistance.GetMaxConstraintDistance( + constraint.ConstraintPos.ObjCellId), + constraint.ConstraintDistanceMax); + } + + /// + /// C3c-R1 review R1: the arm is exactly-once — the successful final + /// commit nulls the activation envelope, so a stale CommitActivation + /// retry is RejectedAuthority and can never re-arm the leash. + /// + [Fact] + public void CommitActivationNeverRearmsTheLeashOnAStaleRetry() + { + using var fixture = new Fixture( + residentWorld: true, + terrainHeight: 2.7f, + moverSphereOriginZ: 0.475f); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + Assert.True( + controller.PositionManager!.Constraint!.IsConstrained); + + controller.PositionManager.UnConstrain(); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority, + fixture.Owner.CommitActivation(evaluation, out _)); + + Assert.False( + controller.PositionManager.Constraint.IsConstrained); + } + [Fact] public void DeferredActivationEvaluationLeavesExactOwnedGraphDormantAndRetryable() { @@ -356,10 +525,117 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.Equal(1, fixture.Owner.CaptureOwnership() .PendingActivationCount); - const ulong generation = 1UL; - fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( - Cell & 0xFFFF0000u, - generation); + // C3c-F2: the wake is driven through the SAME owner production uses + // (RuntimePhysicsState's collision admission), not through the raw + // SetPosition seam. Driving SetPosition directly leaves the admission + // ledger empty, which is a state production can never be in and which + // hid the login rearm wedge this test is supposed to cover. + CommitProductionCollisionGeneration(fixture, Cell & 0xFFFF0000u); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var ready)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(ready, out var projection)); + Assert.True(projection.IsValid); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + } + + [Fact] + public void DeferredCommitRearmsAfterProductionAdmissionCommitsItsGeneration() + { + // C3c-F2 regression (the connected-gate login wedge). The sibling + // test above wakes the parked lease by calling BeginCollisionGeneration + // / CommitCollisionGeneration DIRECTLY on the SetPosition state, which + // leaves RuntimePhysicsState's admission ledger empty. PRODUCTION + // always wakes through BeginCollisionAdmission -> stage -> + // CommitCollisionGeneration, and that path retires the admission the + // instant the generation commits — after which + // ExpectedCollisionGeneration names the NEXT, never-begun generation. + // The rearm must key off the generation the collision world now HOLDS. + using var fixture = new Fixture(); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var deferred)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell, + fixture.Owner.CommitActivation(deferred, out var noProjection)); + Assert.False(noProjection.IsValid); + Assert.False(fixture.Record.PhysicsBody!.InWorld); + Assert.False(fixture.Movement.Controller!.IsRuntimePublished); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + + RuntimeCollisionAdmission admission = fixture.Lifetime.Physics + .BeginCollisionAdmission(Cell & 0xFFFF0000u); + // While the destination admission is in flight the lease must simply + // keep waiting — never a rejection, never an early rearm. + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var waiting)); + Assert.False(waiting.IsValid); + using PreparedLandblockCollisionGeneration prepared = fixture + .Lifetime.Physics.PrepareCollisionGeneration(admission); + fixture.Lifetime.Physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(Cell & 0xFFFF0000u)); + Assert.True(CommitPrepared( + fixture.Lifetime.Physics, + admission, + prepared).Committed); + + // The exact divergence this test pins: the committed authority is the + // generation the lease parked on; "expected" has already moved past it. + Assert.Equal( + admission.Generation, + fixture.Lifetime.Physics.CollisionGenerationAuthority(Cell)); + Assert.NotEqual( + admission.Generation, + fixture.Lifetime.Physics.ExpectedCollisionGeneration(Cell)); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var ready)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(ready, out var projection)); + Assert.True(projection.IsValid); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.NotNull(fixture.Record.PhysicsHost); + Assert.True(fixture.Movement.Controller!.IsRuntimePublished); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + } + + [Fact] + public void DeferredCommitStaysParkedWhileTheCommittingAdmissionIsStillRegistered() + { + // C3c-F2, the link the live probe caught: the collision-generation + // commit marks the parked lease ready and only THEN retires its + // admission (RuntimePhysicsState.cs:2503 vs :2552-2558). Production + // reenters the first-entry pump inside that window. Rearming there + // moves the lease out of AwaitingCell and its very next evaluation + // fails the seal on the still-registered admission — after which + // EvaluateActivation can no longer answer DeferredCell and reports + // RejectedAuthority, which is TERMINAL for the conductor. The lease + // must stay parked and retryable instead. + using var fixture = new Fixture(); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var deferred)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell, + fixture.Owner.CommitActivation(deferred, out _)); + + RuntimeCollisionAdmission admission = fixture.Lifetime.Physics + .BeginCollisionAdmission(Cell & 0xFFFF0000u); + // Reconstruct the exact production instant: the landblock is in the + // engine and the parked lease has been marked ready by the SAME call + // RuntimePhysicsState.cs:2503 makes, while the admission that is + // committing has not yet been retired (:2552-2558). fixture.Lifetime.Physics.Engine.AddLandblock( Cell & 0xFFFF0000u, new TerrainSurface(new byte[81], new float[256]), @@ -369,8 +645,39 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests worldOffsetY: 0f); fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( Cell & 0xFFFF0000u, - generation, + admission.Generation, ready: true); + Assert.Equal( + admission.Generation, + fixture.Lifetime.Physics.CollisionGenerationAuthority(Cell)); + Assert.Equal( + admission.Generation, + fixture.Lifetime.Physics.ExpectedCollisionGeneration(Cell)); + Assert.False(fixture.Lifetime.Physics + .IsCollisionEvaluationPrefixAdmissible(Cell)); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var stillWaiting)); + Assert.False(stillWaiting.IsValid); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + Assert.False(fixture.Record.PhysicsBody!.InWorld); + Assert.False(fixture.Movement.Controller!.IsRuntimePublished); + + using (PreparedLandblockCollisionGeneration prepared = fixture + .Lifetime.Physics.PrepareCollisionGeneration(admission)) + { + fixture.Lifetime.Physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(Cell & 0xFFFF0000u)); + Assert.True(CommitPrepared( + fixture.Lifetime.Physics, + admission, + prepared).Committed); + } + Assert.True(fixture.Lifetime.Physics + .IsCollisionEvaluationPrefixAdmissible(Cell)); Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, fixture.Owner.EvaluateActivation(token, out var ready)); @@ -378,6 +685,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests fixture.Owner.CommitActivation(ready, out var projection)); Assert.True(projection.IsValid); Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.True(fixture.Movement.Controller!.IsRuntimePublished); Assert.Equal(0, fixture.Owner.CaptureOwnership() .PendingActivationCount); } @@ -562,15 +870,9 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects .PendingSetPositionDispatchCount); - const ulong generation = 1UL; - fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( - Cell & 0xFFFF0000u, generation); - fixture.Lifetime.Physics.Engine.AddLandblock( - Cell & 0xFFFF0000u, - new TerrainSurface(new byte[81], new float[256]), - Array.Empty(), Array.Empty(), 0f, 0f); - fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( - Cell & 0xFFFF0000u, generation, ready: true); + // C3c-F2: production wake path (collision admission), see + // CommitProductionCollisionGeneration. + CommitProductionCollisionGeneration(fixture, Cell & 0xFFFF0000u); Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, fixture.Owner.EvaluateActivation(token, out var ready)); Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, @@ -2192,6 +2494,11 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests private sealed class Fixture : IDisposable { private bool _lifetimeDisposed; + // C3c-F5: default 0 keeps every pre-existing test's mover shape + // byte-identical; the first-entry settle tests pass the production + // human Setup's bottom-sphere origin (0.475) so an authored + // placement can stand clear of a floor the settle then reaches. + private readonly float _moverSphereOriginZ; internal Fixture( bool preparePlacement = true, @@ -2199,11 +2506,13 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests RuntimeLocalPlayerShadowDisposition shadowDisposition = RuntimeLocalPlayerShadowDisposition.ProvenShapeless, float terrainHeight = 0f, + float moverSphereOriginZ = 0f, Vector3? initialVelocity = null, Vector3? initialOmega = null, float? initialFriction = null, float? initialElasticity = null) { + _moverSphereOriginZ = moverSphereOriginZ; if (residentWorld) { var engine = new PhysicsEngine @@ -2269,7 +2578,9 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.True(Placement.IsValid); var setup = new FlatSetupCollision( ImmutableArray.Empty, - [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + [new FlatCollisionSphere( + new Vector3(0f, 0f, _moverSphereOriginZ), + 0.48f)], height: 0f, radius: 0f, stepUpHeight: 0.4f, @@ -2537,6 +2848,31 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests "Collision generation did not complete its Runtime mutation transaction."); } + /// + /// C3c-F2: publishes one landblock collision generation through the exact + /// production owner chain — BeginCollisionAdmission -> prepare -> stage -> + /// CommitCollisionGeneration. Waking a parked activation any other way + /// (calling the SetPosition seam directly) leaves RuntimePhysicsState's + /// admission ledger empty, a state production can never reach. + /// + private static void CommitProductionCollisionGeneration( + Fixture fixture, + uint landblockId) + { + RuntimeCollisionAdmission admission = fixture.Lifetime.Physics + .BeginCollisionAdmission(landblockId); + using PreparedLandblockCollisionGeneration prepared = fixture + .Lifetime.Physics.PrepareCollisionGeneration(admission); + fixture.Lifetime.Physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId)); + Assert.True(CommitPrepared( + fixture.Lifetime.Physics, + admission, + prepared).Committed); + } + private static RuntimeLandblockCollisionAssets CollisionAssets( uint landblockId) => new( landblockId, diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.CornerLandblock.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.CornerLandblock.cs new file mode 100644 index 00000000..d2465a54 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.CornerLandblock.cs @@ -0,0 +1,356 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +/// +/// C3c-F3: landblock (0,0) — id 0x0000FFFF, Dereth's south-west map corner — +/// has the legitimate collision prefix 0x00000000. The prefix-0 "absent" +/// sentinel used to make every collision publication, quiescence, park, wake, +/// and retirement against that landblock throw +/// from +/// RuntimeSetPositionState.BeginCollisionPrefixQuiescence (the +/// connected-gate crash at teleport destination (9,4), whose far streaming +/// radius reaches the corner: logs/connected-world-gate-20260802-135444). +/// These tests drive the exact production owner chain against the corner id +/// and, for quiescence semantics, assert step-for-step parity with a +/// nonzero-prefix landblock. +/// +public sealed partial class RuntimeCollisionPrefixQuiescenceTests +{ + private const uint CornerLandblock = 0x0000FFFFu; + private const uint CornerPrefix = 0x00000000u; + private const uint CornerCell = 0x00000001u; + private const uint CornerCell2 = 0x00000002u; + private const uint CornerIndoorCell = 0x00000100u; + private const uint NeighborLandblock = 0x0001FFFFu; + + [Fact] + public void CornerLandblockCollisionGenerationCommitsThroughTheProductionAdmissionChain() + { + // Empty engine — production streaming publishes the corner landblock + // from nothing, exactly like LandblockPhysicsPublisher.AdvanceCompleteOne. + using var fixture = new Fixture( + bindGeneration: true, + engine: new PhysicsEngine { DataCache = new PhysicsDataCache() }); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(CornerLandblock); + Assert.Equal(CornerLandblock, admission.LandblockId); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, CornerLandblock); + // Pre-fix this first commit threw ArgumentOutOfRangeException + // ("landblockId") from BeginCollisionPrefixQuiescence's prefix == 0 + // sentinel guard. + RuntimeCollisionGenerationCommit commit = + CommitToCompletion(physics, admission, prepared); + Assert.True(commit.Completed); + Assert.True(physics.Engine.IsLandblockTerrainResident(CornerLandblock)); + + // The neighbouring landblock (0,1) — prefix 0x00010000 — publishes + // identically through the same chain. + RuntimeCollisionAdmission neighborAdmission = + physics.BeginCollisionAdmission(NeighborLandblock); + using PreparedLandblockCollisionGeneration neighborPrepared = + PrepareSealedMutation(physics, neighborAdmission, NeighborLandblock); + RuntimeCollisionGenerationCommit neighborCommit = + CommitToCompletion(physics, neighborAdmission, neighborPrepared); + Assert.True(neighborCommit.Completed); + Assert.True( + physics.Engine.IsLandblockTerrainResident(NeighborLandblock)); + + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.PendingCollisionPrefixProjectionCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void CornerResidentParksAndRestoresAcrossAnActivationReplacement() + { + // Mirror of ActivationWaitsForExactWithdrawAndPlaceReceipts against + // the corner landblock: publish/park (ParkDeferred's quiescence + // override carries prefix 0x00000000), wake, and restore. + using var fixture = new Fixture( + bindGeneration: true, + engine: CornerEngine()); + RuntimeEntityRecord record = fixture.Add( + 0x700031F1u, + 1, + CornerCell, + new Vector3(11f, 12f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CornerCell, + new Vector3(11.5f, 12f, 0f)); + // SetPosition against a corner cell commits (host acknowledgement of + // the Place projection is the ordinary pending suffix, identical to + // any nonzero-prefix landblock). + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + seeded.Status); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded.Projection)); + + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(CornerLandblock); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, CornerLandblock); + + RuntimeCollisionGenerationCommit first = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(first.EngineCommitted); + Assert.False(first.Completed); + Assert.False(physics.IsSpatialRoot(record)); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawn)); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawn.Kind); + + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawn.Token)); + _ = SealMutation(physics, admission, prepared); + RuntimeCollisionGenerationCommit transferred = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(transferred.EngineCommitted); + Assert.False(transferred.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + RuntimeCollisionGenerationCommit completed = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(completed.Completed); + Assert.True(physics.IsSpatialRoot(record)); + Assert.Equal(CornerCell, record.FullCellId); + Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixMutationCount); + Assert.Equal( + 0, + physics.CaptureOwnership().CollisionPrefixQuiescenceCount); + } + + [Fact] + public void CornerPrefixQuiescenceHoldsAndReleasesExactlyLikeANonzeroPrefix() + { + // Contract test 2: a parked deferral against the prefix-0 landblock + // holds and releases quiescence exactly like a nonzero-prefix + // landblock. The identical script runs against both and every step's + // observable outcome must match. + using var corner = new Fixture( + bindGeneration: true, + engine: CornerEngine()); + using var control = new Fixture(bindGeneration: true); + + // The corner run addresses landblock (0,0) by its canonical id + // 0x0000FFFF — the raw input 0x00000000 stays reserved for "absent" + // (see AbsentLandblockIdStillCannotBeginQuiescence). + List cornerLog = RunHeldPlacementQuiescenceCycle( + corner, + CornerLandblock, + sourceCell: CornerCell, + targetCell: CornerCell2, + guid: 0x700031F2u); + List controlLog = RunHeldPlacementQuiescenceCycle( + control, + PrefixP, + sourceCell: CellP, + targetCell: PrefixP | 0x0002u, + guid: 0x700031F3u); + + Assert.Equal(controlLog, cornerLog); + } + + [Fact] + public void CornerLandblockDemotesAndWithdrawsThroughRetirementMutations() + { + using (var demoteFixture = new Fixture( + bindGeneration: true, + engine: CornerEngine())) + { + RuntimeEntityRecord outdoor = demoteFixture.Add( + 0x700031F4u, + 1, + CornerCell, + new Vector3(12f, 41f, 0f)); + RuntimeEntityRecord indoor = demoteFixture.Add( + 0x700031F5u, + 1, + CornerIndoorCell, + new Vector3(13f, 41f, 0f)); + + RuntimeCollisionMutationResult first = demoteFixture.Lifetime + .Physics.DemoteCollisionToTerrain(CornerLandblock); + Assert.False(first.Completed); + Assert.True(demoteFixture.Lifetime.Physics.IsSpatialRoot(outdoor)); + Assert.False(demoteFixture.Lifetime.Physics.IsSpatialRoot(indoor)); + Assert.True(demoteFixture.Lifetime.Physics.SetPosition + .TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.Equal(indoor.Key, withdrawal.Token.Entity); + Assert.True(demoteFixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(withdrawal.Token)); + + RuntimeCollisionMutationResult completed = demoteFixture.Lifetime + .Physics.DemoteCollisionToTerrain(CornerLandblock); + Assert.True(completed.Completed); + Assert.True(completed.Ready); + } + + using var withdrawFixture = new Fixture( + bindGeneration: true, + engine: CornerEngine()); + RuntimeEntityRecord record = withdrawFixture.Add( + 0x700031F6u, + 1, + CornerCell, + new Vector3(14f, 42f, 0f)); + RuntimeSetPositionOutcome seeded = withdrawFixture.Place( + record, + CornerCell, + new Vector3(14.5f, 42f, 0f)); + Assert.True(withdrawFixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded.Projection)); + RuntimePhysicsState physics = withdrawFixture.Lifetime.Physics; + + RuntimeCollisionMutationResult pending = + physics.WithdrawCollision(CornerLandblock); + Assert.False(pending.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot removed)); + Assert.True(physics.SetPosition.AcknowledgeProjection(removed.Token)); + RuntimeCollisionMutationResult withdrawn = + physics.WithdrawCollision(CornerLandblock); + Assert.True(withdrawn.Completed); + Assert.False(withdrawn.Ready); + Assert.False(physics.IsSpatialRoot(record)); + } + + [Fact] + public void AbsentLandblockIdStillCannotBeginQuiescence() + { + // The prefix-0 sentinel accidentally rejected the corner landblock; + // the genuine "no landblock at all" input (id 0) must keep throwing. + using var fixture = new Fixture( + bindGeneration: true, + engine: CornerEngine()); + Assert.Throws( + () => fixture.Begin(0u, 2UL, includeOutdoorCells: true)); + Assert.Throws( + () => fixture.Lifetime.Physics.BeginCollisionAdmission(0u)); + + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin( + CornerLandblock, + 2UL, + includeOutdoorCells: true); + Assert.True(token.IsValid); + Assert.Equal(CornerPrefix, token.LandblockPrefix); + Assert.True(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token)); + } + + /// + /// One held-placement quiescence cycle (the + /// SourceToOutsidePlacementIsHeldThenRestoredBeforeBarrierOpens shape, + /// single-prefix variant), with every observable step outcome recorded so + /// two runs can be compared for exact parity. + /// + private static List RunHeldPlacementQuiescenceCycle( + Fixture fixture, + uint landblockId, + uint sourceCell, + uint targetCell, + uint guid) + { + var log = new List(); + RuntimeEntityRecord record = fixture.Add( + guid, + 1, + sourceCell, + new Vector3(10f, 22f, 0f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin( + landblockId, + 2UL, + includeOutdoorCells: true); + log.Add($"tokenValid={token.IsValid}"); + + RuntimeSetPositionOutcome held = fixture.Place( + record, + targetCell, + new Vector3(14f, 22f, 0f), + currentCell: sourceCell); + log.Add($"place={held.Status}"); + log.Add($"placeGeneration={held.Projection.CollisionGeneration}"); + log.Add($"placeCellLow={held.ExactCellId & 0xFFFFu:X4}"); + log.Add( + $"root={fixture.Lifetime.Physics.IsSpatialRoot(record)}"); + log.Add($"ackWithdraw={fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(held.Projection)}"); + log.Add($"acquire1={fixture.TryAcquire(token, out _)}"); + log.Add($"acquire2={fixture.TryAcquire(token, out _)}"); + + log.Add($"cancelRestorePending={fixture.Lifetime.Physics + .CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)}"); + bool peeked = fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored); + log.Add($"restorePeeked={peeked}"); + log.Add($"restoreKind={restored.Kind}"); + log.Add($"restoreCellLow={restored.Token.ExactCellId & 0xFFFFu:X4}"); + log.Add($"ackRestore={fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(restored.Token)}"); + log.Add($"cancelCompleted={fixture.Lifetime.Physics + .CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)}"); + log.Add($"finalCellLow={record.FullCellId & 0xFFFFu:X4}"); + log.Add( + $"finalRoot={fixture.Lifetime.Physics.IsSpatialRoot(record)}"); + RuntimePhysicsOwnershipSnapshot ownership = + fixture.Lifetime.Physics.CaptureOwnership(); + log.Add($"quiescences={ownership.CollisionPrefixQuiescenceCount}"); + log.Add( + $"pendingProjections={ownership.PendingCollisionPrefixProjectionCount}"); + return log; + } + + private static RuntimeCollisionGenerationCommit CommitToCompletion( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + for (int poll = 0; poll < 10_000; poll++) + { + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + if (commit.Completed) + return commit; + while (physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection)) + { + Assert.True(physics.SetPosition.AcknowledgeProjection( + projection.Token)); + } + if (!commit.EngineCommitted) + _ = SealMutation(physics, admission, prepared); + } + throw new InvalidOperationException( + "Collision generation did not complete its mutation transaction."); + } + + private static PhysicsEngine CornerEngine() + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + AddFlatLandblock(engine, CornerPrefix); + return engine; + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 524bdb92..647f1ee8 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -17,18 +17,33 @@ public sealed class RuntimeLiveEntitySessionControllerTests [Fact] public void DirectSinkOwnsCanonicalCreateUpdateDeleteWithoutProjection() { - using GameRuntime runtime = CreateRuntime(); + // C3c: the direct sink's Create now enters the canonical initial + // residence; this test drives the remote first-entry conductor to + // completion (the direct-host pump's job) before the follow-up + // Position flows the ordinary post-residence path. + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + CommitLandblockCollision(runtime, 0x01010000u); + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); using var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), new FixtureTransport()); + // C3c-R1 review R3: the residence route requires a drive-backed + // world projection — a content-less (projection-less) controller + // keeps the pre-flip legacy registration instead (see + // ContentLessDirectSink_KeepsPreFlipLegacyRegistration). var controller = new RuntimeLiveEntitySessionController( runtime, - session); + session, + worldProjection: new FixtureWorldProjection()); LiveEntitySessionSink sink = controller.CreateSink(); WorldSession.EntitySpawn spawn = Spawn(0x70000001u, incarnation: 1); sink.Spawned(spawn); + drive.DriveAll(); + Assert.Equal(0, drive.PendingCount); + DrainPlacementFifo(runtime); sink.PositionUpdated(new WorldSession.EntityPositionUpdate( spawn.Guid, spawn.Position!.Value with @@ -62,10 +77,79 @@ public sealed class RuntimeLiveEntitySessionControllerTests runtime.EntityObjects.Entities.PendingTeardownCount); } + /// + /// C3c-R1 review R3: a CONTENT-LESS headless host (validated-legal + /// configuration: HeadlessConfigurationLoader accepts a null + /// process.content) builds no world projection and no first-entry + /// drive. Its Creates must keep the exact pre-flip legacy registration + /// — the accepted frame commits directly (FullCellId derives from the + /// wire cell at registration), no residence lease ever opens, and the + /// entity/residence/drive ledgers stay at zero — because a residence + /// with no drive to pump it would park every Create (and every packet + /// FIFO'd behind its pending residence) forever. + /// + [Fact] + public void ContentLessDirectSink_KeepsPreFlipLegacyRegistration() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session); + LiveEntitySessionSink sink = controller.CreateSink(); + WorldSession.EntitySpawn spawn = + Spawn(0x70000003u, incarnation: 1); + + sink.Spawned(spawn); + + Assert.True( + runtime.EntityObjects.Entities.TryGetActive( + spawn.Guid, + out RuntimeEntityRecord canonical)); + Assert.Equal( + spawn.Position!.Value.LandblockId, + canonical.FullCellId); + RuntimeEntityObjectOwnershipSnapshot ownership = + runtime.EntityObjects.CaptureOwnership(); + Assert.Equal(0, ownership.InitialCreateResidenceLeaseCount); + Assert.Equal(0, ownership.FirstEntryDrivePendingCount); + Assert.Equal(1, runtime.Entities.Count); + Assert.Equal(1, runtime.Inventory.ObjectCount); + + // Position packets flow the ordinary immediate path — nothing is + // FIFO'd behind a pending residence. + sink.PositionUpdated(new WorldSession.EntityPositionUpdate( + spawn.Guid, + spawn.Position!.Value with + { + PositionX = 20f, + }, + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0)); + Assert.Equal( + 20f, + canonical.Snapshot.Position!.Value.PositionX); + + sink.Deleted(new DeleteObject.Parsed(spawn.Guid, 1)); + Assert.Equal(0, runtime.Entities.Count); + Assert.Equal( + 0, + runtime.EntityObjects.Entities.PendingTeardownCount); + } + [Fact] public void DirectSinkCompletesExactPortalAndSendsLoginComplete() { - using GameRuntime runtime = CreateRuntime(); + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; const uint playerGuid = 0x50000001u; runtime.PlayerIdentity.ServerGuid = playerGuid; using var session = new WorldSession( @@ -106,7 +190,8 @@ public sealed class RuntimeLiveEntitySessionControllerTests [Fact] public void DirectSinkProjectsAcceptedLocalWorldStateThroughOneHostSeam() { - using GameRuntime runtime = CreateRuntime(); + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; const uint playerGuid = 0x50000002u; runtime.PlayerIdentity.ServerGuid = playerGuid; using var session = new WorldSession( @@ -154,16 +239,268 @@ public sealed class RuntimeLiveEntitySessionControllerTests Assert.True(runtime.Portal.Snapshot.Completed); } - private static GameRuntime CreateRuntime() + /// + /// C3c-R1 review F6: the drive controller outlives its session routes, + /// so "session reset precedes a new route" is an asserted latch, not a + /// silent assumption — a second route cannot attach before the prior + /// route detached, and a route that never owned the drive cannot clear + /// the live route's tracked entries. + /// + [Fact] + public void FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); + var routeA = new object(); + var routeB = new object(); + + drive.AttachRoute(routeA); + // Re-attaching the same owner is a no-op; a SECOND route asserts. + drive.AttachRoute(routeA); + Assert.Throws( + () => drive.AttachRoute(routeB)); + + _ = runtime.EntityObjects.RegisterEntityWithInitialResidence( + Spawn(0x70000004u, incarnation: 1), + isLocalPlayer: false); + Assert.Equal(1, drive.PendingCount); + + // A non-owner detach (never-attached / displaced route rollback) + // must not clear the live route's entries. + drive.DetachRoute(routeB); + Assert.Equal(1, drive.PendingCount); + + drive.DetachRoute(routeA); + Assert.Equal(0, drive.PendingCount); + // After the owner detached, a replacement route may attach. + drive.AttachRoute(routeB); + drive.DetachRoute(routeB); + } + + /// + /// C3c: initial-residence admission requires a live session generation + /// (RuntimeInitialCreateResidenceState.CanAcceptCreate), so these direct + /// sink tests start one through the same fixture-session shape + /// DirectGameRuntimeCommandAdapterTests uses. + /// + private sealed class StartedRuntime : IDisposable + { + internal required GameRuntime Runtime { get; init; } + internal required LiveSessionHost Live { get; init; } + + public void Dispose() + { + _ = Live.Stop(Runtime.Generation); + Runtime.Dispose(); + } + } + + private static StartedRuntime StartRuntime() { var operations = new FixtureGameplayOperations(); + var sessionOperations = new FixtureSessionOperations(); var runtime = new GameRuntime(new GameRuntimeDependencies( operations, operations, operations, - operations)); + operations, + SessionOperations: sessionOperations)); operations.Bind(runtime); - return runtime; + var resetHost = new FixtureResetHost(); + var options = new LiveSessionConnectOptions( + true, + "127.0.0.1", + 9000, + "account", + "password"); + var live = new LiveSessionHost( + runtime.Session, + new LiveSessionHostBindings( + new LiveSessionRoutingFactories( + _ => new FixtureEventRoute(), + _ => new FixtureCommandRoute()), + generation => runtime.ResetGeneration(generation, resetHost), + new LiveSessionSelectionBindings( + id => runtime.PlayerIdentity.ServerGuid = id, + _ => { }, + runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, + _ => { }, + _ => { }, + runtime.ActionOwner.Combat.Clear), + new LiveSessionEnteredWorldBindings( + _ => { }, + () => { }, + () => { }, + _ => { }, + () => { }), + (_, _, _) => { }, + () => { }), + options); + LiveSessionStartResult startResult = live.Start(options); + Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); + Assert.NotEqual(0UL, runtime.Generation.Value); + return new StartedRuntime { Runtime = runtime, Live = live }; + } + + private static void CommitLandblockCollision( + GameRuntime runtime, + uint landblockId) + { + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + landblockId, 1UL); + runtime.EntityObjects.Physics.Engine.AddLandblock( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + landblockId, 1UL, ready: true); + } + + private static RuntimeFirstEntryDriveController CreateDrive( + GameRuntime runtime) => + new( + runtime.EntityObjects, + runtime.Clock, + new UnusedCollisionSource(), + () => PlayerMovementConstructionOptions.Fallback, + static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + + /// + /// Drains/acknowledges every still-pending placement receipt (the + /// ExecutorCompleted correlation is reaped by its acknowledgement) the + /// way a host subscription would. + /// + private static void DrainPlacementFifo(GameRuntime runtime) + { + while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( + out AcDream.Runtime.Physics.RuntimePlacementProjectionSnapshot head)) + { + if (!runtime.EntityObjects.Physics.SetPosition + .AcknowledgeProjection(head.Token)) + { + break; + } + } + } + + private sealed class UnusedCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision>.Missing; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatGfxObjCollisionAsset> ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatCellStructureCollisionAsset> + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } + + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) => + new(endpoint, new FixtureTransport()); + + public void Connect(WorldSession session, string user, string password) + { + } + + public CharacterList.Parsed GetCharacters(WorldSession session) => + new( + 0u, + [new CharacterList.Character(0x50000001u, "Direct", 0u)], + [], + 11, + "account", + true, + true); + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => + session.Dispose(); + } + + private sealed class FixtureEventRoute : ILiveSessionEventRouting + { + public void Attach() + { + } + + public void Dispose() + { + } + } + + private sealed class FixtureCommandRoute : ILiveSessionCommandRouting + { + public void Activate() + { + } + + public void Dispose() + { + } + } + + private sealed class FixtureResetHost : IRuntimeGenerationResetHost + { + public void RetireEntityProjection(RuntimeEntityRecord entity) + { + } + + public void DrainEntityProjectionBoundary() + { + } + + public void CompleteEntityProjectionRetirement() + { + } } private static WorldSession.EntitySpawn Spawn( @@ -194,7 +531,7 @@ public sealed class RuntimeLiveEntitySessionControllerTests Position: position, Movement: null, AnimationFrame: null, - SetupTableId: 0x02000001u, + SetupTableId: null, MotionTableId: null, SoundTableId: null, PhysicsScriptTableId: null, @@ -213,7 +550,7 @@ public sealed class RuntimeLiveEntitySessionControllerTests return new WorldSession.EntitySpawn( guid, position, - 0x02000001u, + null, [], [], [], From f4ef2b2a2a711cacc58bd2c102a5c6fffe0572c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 18:12:57 +0200 Subject: [PATCH 63/73] docs(physics): record cutover slice C3c completion + closeout C3c COMPLETE at 529e0e9d in the placement-cutover plan (five fix slices, R1 dual-review round, final gates). New closeout research note. ISSUES #276 (settle-CellId discard), #277 (route-1 far-Create radius bound), #278 (user-session triage bundle). Register AD-60/AD-61 numeric order. The next slice before C5 is the 6b28ff99 O(changed) collision clone (soak convergence); C4 resumes after. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 38 +++++++ .../retail-divergence-register.md | 2 +- docs/plans/2026-08-02-placement-cutover.md | 36 +++++- .../2026-08-02-c3c-cutover-closeout.md | 104 ++++++++++++++++++ 4 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 docs/research/2026-08-02-c3c-cutover-closeout.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 43677b55..bb2ae387 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,44 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## C3c placement cutover — 2026-08-02 + +- **#276 — OPEN — SpawnPlacementSettler discards the settle's resolved + cell.** `SpawnPlacementSettler.TrySettle` + (src/AcDream.Core/Physics/SpawnPlacementSettler.cs:61) commits + `settle.Position` but never reads `settle.CellId`: a compressed + first-gravity-frame settle whose few-cm sweep crosses a cell boundary + (outdoor/EnvCell seam, stacked EnvCells) leaves the body's cell at the + placement cell until the next resolve corrects it. Inherited #270 + semantics — shared by the remote spawn seed and the C3c local + first-entry settle (register row AD-61). Fix shape: commit the + settle's resolved cell through the same body/cell channel the per-tick + resolve writeback uses; needs a conformance test placing a body above + a floor whose containing cell differs from the wire cell. Found by C3c + review round 1 (retail minor M2). +- **#277 — OPEN — route-1 far-Create relies on a practical radius bound, + not an invariant.** A graphical-host wire Create for a landblock the + streaming window never reaches keeps its residence + one drive-pending + entry for the session (re-Advanced per frame). Bounded today because + the collision-publication window (5×5, two-tier N₁=4) is strictly + larger than ACE's Create-broadcast group, so the far set is empty; if + C4 changes either radius, route 1 needs the F7-style + service-window/celless conversion + (`HeadlessSessionWorldProjection.cs:557-570` is the template). Wake + and despawn-reap paths are verified correct (C3c adversarial delta + review). Related narrowing: a remote whose landblock leaves the + headless service window between ProjectSpawn and placement commit + still parks (route 8, rarer than the pre-F7 leak). +- **#278 — OPEN — post-C3c user-session triage bundle (2026-08-02 + observations).** (a) purple materialization haze re-fires while + standing still — traced to the Hidden/UnHide script re-firing on a + visibility edge; most plausible driver is the pre-existing `6b28ff99` + streaming-convergence regression (its dedicated slice precedes C5) — + re-observe after that slice; (b) no lateral glide when walking against + impassable slopes — verify against open #269 (Campaign P slope-slide + residual) in the session before treating as new; (c) `/ls` command + reported non-working — identify which command surface at the session. + ## Current queue — 2026-07-27 - **Structural handoff:** all eight `GameWindow` decomposition slices and the diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f73acf81..2b8dcaf7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -145,8 +145,8 @@ readiness/requeue adaptation. See | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | | AD-59 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The `SameIncarnationCreate` envelope buffers one publish per committed stage and flushes them ALL, in stage order, only after the LAST stage commits (constant-true per-field predicate, `IsCurrent`-checked at flush - the per-field closure variant was invalidated by WeenieDescription's six-field `AdvanceCreateAuthority`). A subscriber sees N back-to-back events with no interleaved observation point, each carrying the FINAL merged post-envelope record state, not per-stage state. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyEnvelope` buffered-publish tail; `Publish`/`PublishNow`) | Retail's own tail is one synchronous critical section, and retail emits ONE notice per Create (`ECM_Physics::SendNotice_CreateObject`, fired whenever a weenie exists, independent of the physics-registration outcome) - never N per-internal-step notices. The buffered flush is closer to retail's one-signal model than per-step publication would be, though not a literal 1:1 match. | A subscriber diffing consecutive `Updated` events from the SAME envelope to isolate one stage's delta gets every stage's cumulative state on each event - silently wrong incremental-diff logic, not a crash. | `SmartBox::HandleCreateObject` 0x00454C80 same-incarnation tail (one synchronous critical section); `ACCObjectMaint::CreateObject` 0x00558870 step 11 (`ECM_Physics::SendNotice_CreateObject`) | -| AD-61 | **Filed 2026-08-02 (C3c review round 1).** The #270 settle-timing compression now covers the LOCAL player: `RuntimeLocalPlayerPhysicsPublicationState.SettleFirstEntryGroundContact` runs the shared `SpawnPlacementSettler` exactly once after the dormant activation's final commit (suffix-current authority only), compressing retail's first post-`enter_world` gravity frame — which grants CONTACT/ON_WALKABLE from a real touch — into the placement transaction. The legacy App-era force-seed (`Contact\|OnWalkable\|Active` in `PlayerMovementController.SetPositionCore`) still RUNS during publication-candidate preparation and is then OVERWRITTEN by the faithful activation commit + settle (it was never deleted). Caveat (review minor M2): the settler commits `settle.Position` but discards `settle.CellId` — a settle whose few-cm sweep crosses a cell boundary keeps the placement cell until the next resolve corrects it (inherited #270 semantics; ISSUES entry filed) | `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs` (`SettleFirstEntryGroundContact`); `src/AcDream.Core/Physics/SpawnPlacementSettler.cs` (`TrySettle`); overwritten seed `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`SetPositionCore`) | Timing compression only: contact comes exclusively from the sweep's real touch (no caller-bool seeding, no forced transients), an airborne spawn stays genuinely airborne, and the overwritten force-seed leaves no observable residue past the activation commit — the committed state is exactly what retail's first gravity frame produces | A settle crossing a cell boundary reports the stale placement cell for the frames before the next resolve; a future reader trusting `SetPositionCore`'s "treat as grounded" seed comment could reintroduce the Contact-without-plane state the landing family calls unrepresentable | `CPhysicsObj::enter_world` 0x00516170; `SmartBox::HandleCreateObject` 0x00454C80 | | AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** Executor Position-continuation merges never directly commit residency: `ApplyPositionAction` refreshes `canonical.Snapshot.Position` with the retained wire pose but withholds the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`); only a Runtime `SetPosition` commit (the continuation's own classified placement) or a later simulation full-cell commit may change residency. The LEGACY immediate-apply path's `RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)` (`RuntimeEntityObjectLifetime.cs:1338`) still derives `FullCellId` from bare wire acceptance - that coarser rule is part of the AP-1 divergence this campaign is removing, not something this row blesses. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment) | Matches retail exactly: `HandleReceivedPosition` never writes a resident cell - `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. | If a future change passes `refreshPosition: true` here, a wire Position would make a cellless canonical body resident without any placement/collision commit - the classic AP-1-shaped bug this campaign exists to close. | `SmartBox::HandleReceivedPosition` 0x00453FD0; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | +| AD-61 | **Filed 2026-08-02 (C3c review round 1).** The #270 settle-timing compression now covers the LOCAL player: `RuntimeLocalPlayerPhysicsPublicationState.SettleFirstEntryGroundContact` runs the shared `SpawnPlacementSettler` exactly once after the dormant activation's final commit (suffix-current authority only), compressing retail's first post-`enter_world` gravity frame — which grants CONTACT/ON_WALKABLE from a real touch — into the placement transaction. The legacy App-era force-seed (`Contact\|OnWalkable\|Active` in `PlayerMovementController.SetPositionCore`) still RUNS during publication-candidate preparation and is then OVERWRITTEN by the faithful activation commit + settle (it was never deleted). Caveat (review minor M2): the settler commits `settle.Position` but discards `settle.CellId` — a settle whose few-cm sweep crosses a cell boundary keeps the placement cell until the next resolve corrects it (inherited #270 semantics; ISSUES entry filed) | `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs` (`SettleFirstEntryGroundContact`); `src/AcDream.Core/Physics/SpawnPlacementSettler.cs` (`TrySettle`); overwritten seed `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`SetPositionCore`) | Timing compression only: contact comes exclusively from the sweep's real touch (no caller-bool seeding, no forced transients), an airborne spawn stays genuinely airborne, and the overwritten force-seed leaves no observable residue past the activation commit — the committed state is exactly what retail's first gravity frame produces | A settle crossing a cell boundary reports the stale placement cell for the frames before the next resolve; a future reader trusting `SetPositionCore`'s "treat as grounded" seed comment could reintroduce the Contact-without-plane state the landing family calls unrepresentable | `CPhysicsObj::enter_world` 0x00516170; `SmartBox::HandleCreateObject` 0x00454C80 | --- diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index b992bb71..1884bb81 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -162,11 +162,37 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. never-clobber coexistence with the build-at-first-motion production path. The acknowledge discriminator is one shared body (`RuntimeFirstEntryAcknowledgement`) for both conductors. Dormant. - - **C3c — the host flips (production):** both hosts onto the complete - machinery; seal the Controller setter; presentation-only - rebucketing; the connected lifecycle/reconnect + nine-stop gates - (harnesses: `tools/run-connected-world-lifecycle-gate.ps1`, - `tools/run-connected-r6-soak.ps1`). + - **C3c — the host flips (production) — COMPLETE at `529e0e9d` + (2026-08-02, dual Opus reviews: initial FAIL 2+2 MAJOR → R1 fix + round → delta PASS both).** Both hosts register initial Creates + through residence + conductors via the shared + `RuntimeFirstEntryDriveController`; Controller setter sealed; + rebucketing presentation-only strictly while the residence is + ACTIVE (post-residence entities take the full legacy path including + the `prepare_to_enter_world` clock edges); content-less headless + keeps pre-flip direct registration. Five fix slices landed inside + the cutover, each connected-gated: F1 (Runtime ownership seam for + movement stats/server physics — the post-logout retired-controller + crash), F2 (the login activation wedge: admission-prefix gate + factored from the seal, rearm generation identity, auto-entry + requires the published controller), F3 (landblock-prefix 0-sentinel + → explicit absent-id; corner landblocks legal), F4 (diagnosis only: + the nine-stop soak's convergence failure is pre-existing `6b28ff99` + whole-world collision-clone throughput — its fix is the next slice + before C5), F5 (local-player first-entry ground contact via the + shared `SpawnPlacementSettler` at `FinalizeActivation`; the + standing-cast airborne rejections are gone; register AD-61). R1 + additionally armed the login constraint leash at the committed + placement (`HandleReceivedPosition` 0x00453FD0 analog) and + refreshed AD-42. Final gates: complete solution 10,816/0/4 skips; + lifecycle/reconnect gate PASS (`connected-world-gate-20260802- + 175401`). Closeout: + [`2026-08-02-c3c-cutover-closeout.md`](../research/2026-08-02-c3c-cutover-closeout.md). + **Carried to C4/C5:** route-1 far-Create service-window conversion + if either streaming/broadcast radius changes (#276); the + window-departure park narrowing; `NotifyRetirement`-on-active-entry + subscriber invariant; the reachable equip-mid-conductor fail-fast; + settle-CellId discard (#276-adjacent, see ISSUES). - **C4 — remaining routes: 2 (ForcePosition), 3 (portal, with the `RuntimeWorldTransitState` → `RuntimePortalPlacementAuthority` adapter), 4 (remote Create/Position; delete `RemoteTeleportController`/`Placement` diff --git a/docs/research/2026-08-02-c3c-cutover-closeout.md b/docs/research/2026-08-02-c3c-cutover-closeout.md new file mode 100644 index 00000000..efd53d9f --- /dev/null +++ b/docs/research/2026-08-02-c3c-cutover-closeout.md @@ -0,0 +1,104 @@ +# C3c production placement cutover — closeout (2026-08-02) + +Behavior commit: `529e0e9d` (68 files, +5,979/−833, register rows AD-61 + +AD-42 refresh in-commit). Plan: +[`2026-08-02-placement-cutover.md`](../plans/2026-08-02-placement-cutover.md). +Session evidence trail: the campaign scratchpad's `implementer-progress.md` +sections `Continuation 1-4`, `C3c-F1`..`C3c-F5`, `C3c-R1` (not committed; +summarized here). + +## What shipped + +Both production hosts (graphical + headless) register every initial +wire Create through the C0-C3b residence/executor/conductor machinery. +One shared `RuntimeFirstEntryDriveController` pumps the local-player and +remote conductors from the placement-receipt flow (per-frame graphical, +per-tick headless). `MaterializeProjection`/`RebucketLiveEntity` are +presentation-only strictly while the initial-create residence is ACTIVE +(exact-token check; `ExecutorCompleted` is the presentation-binding +receipt); post-residence entities take the full legacy path including +retail's `prepare_to_enter_world` (0x00511FA0) clock rebase. +`RuntimeLocalPlayerMovementState.Controller`'s setter is sealed; every +controller mutation flows through the publication lifecycle. Content-less +headless sessions (validated-legal config) keep the pre-flip direct +registration until C4/C5 revisit. + +## The five fix slices (each connected-gated inside the cutover) + +- **F1** — live movement-stat + server-physics application moved behind + Runtime ownership (`RuntimeMovementStatsApplication`, + `ApplyServerPhysicsState`); the post-logout ingest crash on the + retired controller is eliminated; `RuntimeMovementSkillProjection` + deleted. +- **F2** — the login activation wedge (world never revealed): the + collision-admission prefix gate factored out of the seal (reentrant + commit could yield terminal `RejectedAuthority`), the rearm's + generation identity corrected (parked G vs post-retirement G+1), and + `PlayerModeAutoEntry` now requires the Runtime-published controller + (`IsPlayerControllerReady` was a constant `true` — one early attempt + permanently sealed the reveal). +- **F3** — landblock-prefix `0`-sentinel replaced by explicit absent-id + representation; map-corner landblocks (grid row/col 0, e.g. + `0x0000FFFF`) are legal through admission, park/rearm/retire, + quiescence, and outdoor shadow seeds. +- **F4** — diagnosis only: the nine-stop soak's convergence failure + (pendingPublications=1, farBacklog nonzero, landblock/mesh dimensions) + is **pre-existing `6b28ff99`** (2026-07-31, "make collision activation + starvation-free"): every far publication clones the complete collision + world (median ~19.7k leaves / 3.64 ms), so the queue drains ~10 + landblocks/s and never catches its window. Fix requires an O(changed) + clone (structural sharing or per-landblock atomic unit) — a semantics + change to that slice's asserted one-leaf-per-step invariant; scheduled + as its own slice BEFORE C5 (whose gate matrix includes the soak). +- **F5** — local-player first-entry ground contact: retail seeds contact + from the first gravity frame's transition touch (`enter_world` + 0x00516170 carries no seed; local player and remotes share the + mechanism via `HandleCreateObject` 0x00454C80). The shared + `SpawnPlacementSettler` (moved App→Core) runs at `FinalizeActivation` + exactly once; genuinely airborne spawns stay airborne; the outbound + contact bit chain is asserted end-to-end. The legacy path's + unconditional `Contact|OnWalkable|Active` force-seed (non-retail, no + plane) still runs during candidate preparation and is OVERWRITTEN by + the faithful settle (register AD-61). Fixes the user-observed + standing-cast "You can't do that while in the air!" rejections. + +## Review round R1 (dual Opus: initial FAIL 2+2 MAJOR → delta PASS both) + +Retail MAJORs: the login constraint leash (deleted with the legacy +resolve path; re-armed at the committed placement in +`FinalizeActivation` — `HandleReceivedPosition` 0x00453FD0 arms on every +accepted position) and the post-residence rebucket scope (fixed to +exact-token active-residence). Adversarial MAJORs: content-less headless +(no drive → legacy registration) and the register rows. Nine minors +fixed (owner conversion API with active-residence throw, wire-landblock +guards, drive-pending ledger in `IsConverged`, route attach/detach +latch, celless conversion for far headless remotes, doc-comment truth, +per-incarnation cylinder cache, executor-drain drift model documented + +source-pinned); two tracked (#276, #277 in ISSUES). + +## Final gates + +Runtime 1,003; App 4,039/3 skips; Headless 79; complete solution +**10,816 / 0 failed / 4 skips** (Release, `-m:1`). Connected +lifecycle/reconnect gate **PASS** (`connected-world-gate-20260802-175401`; +graceful exits, world-visible, zero airborne-rejection strings; run +`-174811` failed on user-interference fingerprint — +`activeTeleportCount=1` at the stable checkpoint — and is attributed, +not counted). The soak stays red for the pre-existing F4 attribution. + +## Process lessons (carried to memory) + +1. **Report artifacts over marker logs** — three wrong classifications + this campaign came from reading route/marker logs instead of + `report.json` (the soak "clean route" was Passed=false with 37 + convergence failures). +2. **Log lifetime before absence claims** — a 26-second, 67-line log's + silence about a defect proves nothing (the 122749 misread inverted a + root-cause classification twice). +3. **User observation is the cheapest gate** — the standing-cast + airborne rejections and the black-screen reveal were both + user-spotted minutes before harness detection. +4. **The seal finds the bypasses** — sealing the controller setter + surfaced a runtime-mutation bypass (F1) the compile-break audit could + not see; expect the same class when sealing any long-lived escape + hatch. From c52ce14a07e774d16fd4d96e9b0ddcffbf6c8556 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 18:30:10 +0200 Subject: [PATCH 64/73] docs: record C3c smoke-test findings (#278 additions, #279 filed) User session observations: late monster pop-in, extended/stuck portal space, and portal-exit character pop-in are the 6b28ff99 publication- throughput regression made visible by C3c retail-correct wait-for- collision placement (next slice). Intermittent spell particle loss filed as #279: one-shot scripts arriving in the suppressed-until-receipt window need retail pending-script deferral to presentation binding. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bb2ae387..5d4be4dc 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -61,6 +61,27 @@ What does NOT go here: impassable slopes — verify against open #269 (Campaign P slope-slide residual) in the session before treating as new; (c) `/ls` command reported non-working — identify which command surface at the session. + **Smoke-test additions (2026-08-02 evening, retail-UI session):** + (d) monsters pop into existence late — appear on radar BEHIND the + running player; (e) recall spends far longer in portal space than + retail, occasionally sticks there; (f) on portal exit the character + pops in instead of being visible at reveal. (d)/(e)/(f) are the + `6b28ff99` publication-throughput regression made user-visible by + C3c's retail-correct wait-for-collision placement order (spawns no + longer hand-place ahead of publication); expected to resolve with the + O(changed)-clone slice — re-verify all three in its acceptance + session. +- **#279 — OPEN — one-shot spell/effect scripts arriving during the + suppressed-until-receipt window can be lost.** User-observed: spell + particle effects intermittently missing (2026-08-02 smoke test). C3c + suppresses presentation for a created entity until its placement + receipt binds it; a play-once VFX/script that fires while suppressed + has no presentation to land on and never replays at bind time — + "sometimes works" = the receipt won the race. Investigate retail's + pending-script handling for not-yet-in-world objects (HandleCreateObject + tail / PlayScript queuing) and defer one-shot scripts to the + presentation-binding moment. Route: presentation sink / + TryApplyInitialCreateCompletionPresentation. ## Current queue — 2026-07-27 From 71604331cfe1840565c32a1805b859bab9b95867 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 20:06:59 +0200 Subject: [PATCH 65/73] wip(physics): collision O(changed) delta-commit (O1-O3) - ON HOLD, feel-test failed Publication-throughput rework per the D2 design (docs/research/ 2026-08-02-collision-throughput-handoff/design-note.md): O1 per-prefix installed-key ledgers replacing the seal's full-map scans; O2 per- landblock delta commit (LandblockReplacementApplyCursor against the active root) replacing whole-world TransferTo; O3 empty staging root, commit-time reflood (CObjCell::init_objects 0x0052B420 -> recalc_cross_cells 0x00515A30), journal/peer-rebase machinery deleted (~1,900 lines net). Automated gates green: Runtime 999, Core physics 2,135, App 4,039/3, Headless 79, complete solution 10,812/0/4; lifecycle gate PASS (connected-world-gate-20260802-193029). Soak 194423: publication-side acceptance fully met (37 -> 4 failures, all convergence dims zero, loadedLandblocks baseline-identical, waitCue 6/9 -> 1/9). COMMITTED AS WIP ON USER DIRECTION - NOT ACCEPTED. The user feel-test FAILED on this tree: monsters still pop into existence at close range, monsters spawned mid-air far ahead, static placements visibly wrong, plus 243x "Landblock already has a full retirement receipt" InvalidOperationException catch-retry loop during origin recenter (launch-feeltest-oclone.log). The 4 remaining soak failures (pendingLandblockRetirements 131/122 at the Caul->Sawato stops) and the implementer's "exposed pre-existing" classification are under re-judgment against that loop. Dual reviews were dispatched and then stopped mid-flight on user direction; NO review has passed this commit. Full problem inventory + next-agent instructions: docs/research/2026-08-02-collision-throughput-handoff/. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 5 +- .../design-note.md | 411 ++ .../docs-drafts.md | 109 + .../implementer-progress.md | 4516 +++++++++++++++++ .../user-observations-feel-test.md | 30 + .../user-observations-smoke-test.md | 29 + .../Physics/CollisionWorldState.cs | 210 + src/AcDream.Core/Physics/PhysicsDataCache.cs | 256 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 607 +-- .../Physics/ShadowObjectRegistry.cs | 88 +- src/AcDream.Core/World/Cells/CellGraph.cs | 100 +- .../Physics/RuntimePhysicsState.cs | 970 +--- .../Physics/RuntimePhysicsStateTests.cs | 973 ++-- 13 files changed, 6410 insertions(+), 1894 deletions(-) create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/design-note.md create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/user-observations-feel-test.md create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/user-observations-smoke-test.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 5d4be4dc..d098eb42 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -59,8 +59,9 @@ What does NOT go here: streaming-convergence regression (its dedicated slice precedes C5) — re-observe after that slice; (b) no lateral glide when walking against impassable slopes — verify against open #269 (Campaign P slope-slide - residual) in the session before treating as new; (c) `/ls` command - reported non-working — identify which command surface at the session. + residual) in the session before treating as new; (c) ~~`/ls` command + reported non-working~~ — RESOLVED 2026-08-02: user confirmed `/ls` + works in-client; the earlier report was environmental noise. **Smoke-test additions (2026-08-02 evening, retail-UI session):** (d) monsters pop into existence late — appear on radar BEHIND the running player; (e) recall spends far longer in portal space than diff --git a/docs/research/2026-08-02-collision-throughput-handoff/design-note.md b/docs/research/2026-08-02-collision-throughput-handoff/design-note.md new file mode 100644 index 00000000..dffd67cc --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/design-note.md @@ -0,0 +1,411 @@ +# O(changed) collision clone — design note + +**Phase:** research + design only. No production edits, nothing staged, no probes left +behind. Worktree `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch +`codex/port-claude-agents`, HEAD `c52ce14a`. + +**Problem:** the collision-generation staging clone is O(resident world) per landblock +publication, so loading an N-landblock ring costs O(N²). The far ring never converges. +C3c made it user-visible (late monster pop-in, extended/stuck portal space, portal-exit +pop-in, failing nine-stop soak) but did not cause it. + +--- + +## (a) What the one-leaf-per-step invariant actually protects + +### It is a frame-time bound. Nothing else. + +The whole-world copy did not arrive with `6b28ff99`. It arrived one commit earlier, in +`be94bc9b` "fix(physics): activate collision generations atomically" (2026-07-31), as a +**synchronous** copy performed in a single call at admission: + +```csharp +// be94bc9b, PhysicsEngine.CreateCollisionStagingCopy +foreach ((uint id, LandblockPhysics landblock) in _landblocks) + staging._landblocks[id] = landblock; +staging.ShadowObjects.CopyCollisionStateFrom(ShadowObjects, stagingCache); +``` + +`6b28ff99` "make collision activation starvation-free" replaced that with +`CollisionStagingBuilder` (`src/AcDream.Core/Physics/PhysicsEngine.cs:785-941`), which +performs the *same* copy chopped into single leaves across frames. The retired AD-6 row +states the purpose verbatim +(`docs/architecture/retail-divergence-register.md:113`): + +> "Admission captures the active root in O(1); a stable landblock/owner slot suffix +> materializes non-target leaves incrementally, **so resident-world size cannot become a +> synchronous clone spike**." + +The committed test says the same thing three ways +(`tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991`): + +| Assertion | line | What it pins | +|---|---|---| +| `Assert.InRange(admissionAllocation, 1L, 128L*1024L)` | :973 | admission allocates a constant | +| `Assert.Equal(0, prepared.Engine.LandblockCount)` | :974 | admission copies no resident landblock | +| `Assert.InRange(step.WorkUnits, 0, 1)` | :983 | **the copy is chopped to one leaf per host step** | +| `Assert.True(advances > residentLandblocks)` | :988 | it really walked the resident world | +| `Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount)` | :989 | the draft ends up holding the whole world | + +So the invariant protects **hitch avoidance**: a dense resident world must not produce +one long synchronous copy inside a single update step. It is a *scheduling* property +asserted as a *mechanism*, which is why the batching lever tripped it. + +### What it does NOT protect + +- **Not concurrent-reader isolation.** That is `CollisionWorldStateSlot.TransferTo`'s + single `Volatile.Write` (`src/AcDream.Core/Physics/CollisionWorldState.cs:66-84`). + And a full threading audit of every writer and reader of `CollisionWorldState` found + **no concurrent reader or writer exists**: `GameWindow` runs one Silk.NET loop thread; + `UpdateFrameOrchestrator.Tick` runs `_streaming.Tick()` → `DrainAndApply` and then the + live/physics/camera phases strictly sequentially on that thread; the only background + workers (`LandblockStreamer` worker thread, `EnvCellRenderer` `Parallel.ForEach`, + `ObjectMeshManager` `Task.Run`) never touch `PhysicsDataCache` / `CellGraph` / + `ShadowObjectRegistry` / `PhysicsEngine` — grep of `LandblockBuildFactory.cs` and + `LandblockMesh.cs` for those types returns zero hits. Every one of + `BeginCollisionAdmission` (:2040), `PrepareCollisionGeneration` (:2092), + `AdvanceCollisionGenerationPreparation` (:2123), `StageCollisionAssets` (:2218), + `AdvanceCollisionGenerationSeal` (:2298), `CommitCollisionGeneration` (:2382), + `CancelCollisionGeneration` (:2139) passes through + `RuntimePhysicsState.EnsureCollisionMutationThread` (:2857-2869). The + `ConcurrentDictionary` choices are load-bearing only for *single-threaded* + mutate-while-enumerating (`PhysicsDataCache.cs:958-984`, and the seal cursor holding a + live enumerator across frames at :1081-1160) — the cross-thread rationale in the + `CellGraph.cs:17` and `PhysicsDataCache.cs:14-20` doc comments is **stale after + 6b28ff99**. +- **Not admission fairness.** That is the separate journal/coalescing machinery + (`RuntimePhysicsState.cs:475-529`, research doc step 5) — the other half of what + "starvation-free" meant. It is orthogonal to the leaf metering and stays. + +### What the pre-6b28ff99 mechanism did + +`be94bc9b`'s commit was a **delta apply**, not a root swap: + +```csharp +// be94bc9b, PhysicsEngine.CommitLandblockReplacement — deleted by 6b28ff99 +DataCache.CommitLandblockReplacement(replacement.DataCache); // O(changed) +_landblocks[replacement.LandblockId] = replacement.Landblock; +ShadowObjects.CommitLandblockReplacement(replacement.Shadows); +``` + +`6b28ff99` replaced those three lines with +`stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld)` +(`PhysicsEngine.cs:304-321`). **That is the change that made the clone load-bearing.** +Before it, the clone was a build sandbox; after it, the clone *is* the world that gets +published, so every leaf not cloned is a leaf deleted from the world. + +Before `be94bc9b` the client mutated the active maps in place across many frames — the +genuinely non-equivalent state the research doc describes ("the active `PhysicsDataCache`, +`CellGraph`, `PhysicsEngine`, buildings, static shadows, and retained-object refloods +changed at different cursors", `docs/research/2026-07-31-atomic-collision-generation.md:12-16`). +**The atomicity requirement is "the multi-frame build must not be observable", not "the +whole world must be swapped".** A delta applied inside one synchronous update-thread call +satisfies it. + +### The cost is worse than F4 measured + +F4 attributed median 19,736 / p90 32,135 / max 38,021 leaves and median 3.64 ms per +publication to the staging clone. The **seal** does the same walk again: the replacement +builder holds live enumerators over four `_staging` maps *and* four `_active` maps +(`PhysicsDataCache.cs:1081, 1092, 1103, 1114, 1125, 1136, 1147, 1158`) plus two in +`CellGraph.cs:184, 203`, using `CapturePrefixOne` / `CaptureRemovalOne` — a full scan of +each map to find O(target) keys. Real per-publication cost is therefore roughly **2–3× +resident world**, not 1×. **Fixing only the clone leaves O(N²) in the seal.** Any design +that does not also scope the removal capture is not a fix. + +--- + +## (b) Candidate designs + +### D1 — Structural sharing (persistent/immutable `CollisionWorldState`) + +Replace the ~20 mutable maps with persistent maps (HAMT / `ImmutableDictionary`) so a +staging clone shares unchanged subtrees and copies only the changed path. + +- **Blast radius:** every read and write site of every map in `CollisionWorldState`, + `PhysicsDataCache`, `CellGraph`, `ShadowObjectRegistry`, `PhysicsEngine`. +- **Invariant changes:** none semantically; the root swap survives unchanged, so the + atomicity story is untouched. +- **Throughput:** admission O(1), clone O(1), commit O(changed · log N). Excellent on the + copy axis. +- **Why rejected:** it pays for the copy with the *query*. A HAMT probe is several times a + `Dictionary` probe and allocates on write; the resolver runs thousands of these per + frame at 30 Hz. Slice I's entire thesis is flat, integer-indexed, zero-allocation + collision (`docs/plans/2026-07-25-modern-runtime-slice-i.md`; I1 measured 0 B/resolve). + D1 optimizes the rare operation at the expense of the hot one and fights the I-series + architecture head-on. + +### D1b — Landblock-sliced root (per-prefix immutable slice + small map) + +Regroup the root so each landblock's cells / flat cells / EnvCells / buildings / terrain / +outdoor cells / `LandblockPhysics` live in one immutable `LandblockCollisionSlice`, and +the root becomes `Dictionary` (~625 entries). Commit = one dictionary +write per prefix. + +- **Blast radius:** every keyed read becomes mask + two probes; the seal's removal scans + collapse to "old slice vs new slice". The **shadow registry does not partition** — + `ShadowEntityCells`, `ShadowEntityShapes`, `ShadowEntityRegistrations`, + `ShadowOwnerVersions` are owner-keyed and owners legitimately span prefixes (that is + the whole retained-owner problem), so the shadow half needs a separate mechanism. +- **Invariant changes:** the atomic unit becomes the slice; the root swap disappears. +- **Throughput:** O(changed) by construction, and atomic even for a hypothetical + concurrent reader. +- **Verdict:** this is the right answer *if* concurrent readers existed. They do not. + Keep it on the shelf as the migration target should the runtime ever go multi-threaded; + do not pay its refactor cost now. + +### D2 — Per-landblock atomic unit: restore the delta apply *(recommended)* + +`CommitLandblockReplacement` drains the **already-existing** +`PhysicsEngine.LandblockReplacementApplyCursor` (`PhysicsEngine.cs:568-777`) against the +**active** root inside one synchronous call, instead of `TransferTo`. The staging root +becomes empty-at-admission (target content only); `CollisionStagingBuilder` phases 0–8 are +deleted. + +The delta record already exists and is already tested: `PreparedPhysicsDataCacheLandblock` +(`PhysicsDataCache.cs:1255-1270`) is exactly lists of key/value pairs to install and lists +of ids to remove, all target-scoped. The apply cursor already handles removals, installs, +terrain, the 0x40 synthesized outdoor cells, the landblock itself, and yields the reflood +owner ids to the caller at phase 12 (`PhysicsEngine.cs:702-712`). Today it is used to +rebase a committed peer delta into a *later draft*; pointing its `destination` at the +active engine is a constructor argument, not new machinery. + +**What breaks, honestly:** + +1. *Readers mid-query* — nothing. Single-threaded, evidenced above. A drained cursor + inside one call is indivisible with respect to every reader that exists. +2. *Re-entrancy* — real, and the audit flagged it: `OwnerMutated` / + `OwnerPrefixMembershipChanged` (`ShadowObjectRegistry.cs:86-87`) can fire mid-delta. + Precedent already exists: the commit brackets itself with + `_suppressCollisionOwnerJournal = true` (`RuntimePhysicsState.cs:2491-2501`). Extend + that bracket to cover the whole apply. +3. *Cross-frame enumerators* — the seal holds live enumerators over the **active** maps + across frames (`PhysicsDataCache.cs:1092, 1136, 1158`). A delta apply now mutates the + maps those enumerators walk. `ConcurrentDictionary` will not throw, but the observed + set is unspecified. **O1 below removes those enumerators entirely**, which is why O1 + must land first. +4. *The retirement machinery* — `LandblockRetirementCursor` (`PhysicsEngine.cs:348-...`) + currently retires from an off-side draft. Same cursor, destination becomes the active + root, still drained in one call. +5. *The reflood context* — the seal currently computes retained-owner refloods against a + full staging world. With an empty staging root that context is gone, so the reflood + moves to the commit call, against the now-current active world. **That is precisely + retail**: `CObjCell::init_objects` (0x0052B420) → `CPhysicsObj::recalc_cross_cells` + (0x00515A30), already the retail anchor cited on the AD-6 row. +6. *The peer-rebase / journal apparatus* — with no snapshot there is nothing to rebase. + `EnqueueCommittedRebase` (`RuntimePhysicsState.cs:563-578, 2502-2507`) and most of the + journal become dead. Delete them in the same slice; do not leave dead invariants + guarding a deleted mechanism. + +- **Throughput:** per publication ≈ target payload (~70–200 leaves at the measured + ~184 ns/leaf) + the owners touching the target, versus today's ~2–3 × 20,000. Roughly + **300× less work per publication**, and — decisively — **independent of resident-world + size**, so total ring load goes O(N²) → O(N). At the failing run's numbers that is + ~13.7 M leaf copies for a 625-landblock ring down to ~44 K. + +### D3 — Adjacency-scoped clone (the tempting middle ground) — **rejected as unsafe** + +Copy only leaves in the target's 3×3 landblock neighbourhood. One predicate change in +`CopyOneOutsideTarget` (`PhysicsEngine.cs:949-961`); clone drops ~20,000 → ~630 and +becomes O(1) in world size. + +Rejected for a structural reason worth stating plainly: **while commit is a whole-root +transfer, "clone less" means "delete more."** Anything not copied into the draft is absent +from the root that replaces the world. A partial clone is therefore a silent world-erasure +bug, not a perf tuning knob. Only after commit becomes a delta does bounded context become +safe — at which point D2 has already removed the need for it. It also leaves the seal's +O(world) scans untouched, so O(N²) survives regardless. + +--- + +## (c) Recommendation + +**Take D2, in three landable slices, with O1 first.** + +Rationale in one line: the delta-apply commit path is not a new invention — it is the +mechanism that shipped in `be94bc9b` and was deleted by `6b28ff99` to buy an atomicity +guarantee against concurrent readers that do not exist; restoring it makes the cost +O(changed) by construction and moves the client *toward* retail's `init_objects` shape, +not away from it. + +### Invariant-test replacement + +Delete from `DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep` +(`tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991`) the three +assertions that pin the clone itself — `:983` `Assert.InRange(step.WorkUnits, 0, 1)`, +`:988` `Assert.True(advances > residentLandblocks)`, `:989` +`Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount)`. They assert the exact +mechanism being removed. + +Replace with `CollisionPreparationCostIsIndependentOfResidentWorldSize` — a strictly +stronger invariant, because it pins the *property* (bounded, world-size-independent work) +rather than a mechanism: + +``` +Run the full admission → preparation → seal → commit sequence twice, +at residentLandblocks = 32 and residentLandblocks = 256. + +Assert total preparation advances(32) == total preparation advances(256) // O(changed) +Assert total seal WorkUnits(32) == total seal WorkUnits(256) // closes the seal scans +Assert every step.WorkUnits <= K // K = retained per-step bound +Assert admissionAllocation in [1, 128 KiB] // kept from :973 +Assert prepared.Engine.LandblockCount == 0 after preparation completes // stronger than :974: + // the draft now holds ONLY the target +``` + +Add two more: + +- `CommitAppliesOneLandblockDeltaInASingleCall` — the engine-mutating + `CommitCollisionGeneration` call drains the apply cursor to `Completed` before it + returns; the active world holds no target-prefix content before it and the complete + target after it, with no observable intermediate. +- `CommitTimeRefloodMatchesPrecomputedReflood` — for a fixed scenario, the owner set and + each owner's resulting cross-cell set after a commit-time reflood are **equal** to what + the pre-change staged reflood produced. This is the proof that D2 is a scheduling + change and not a semantics change, and it is the test that makes the perf framing in + (d) legitimate. + +**Keep unchanged:** every `Assert.InRange(seal.WorkUnits, 0, 1)` at `:558, :667, :747, +:848, :1797, :2020, :2318, :2428, :3033` — the seal stays metered; the zero-managed-byte +commit assertions (the delta lists are built during seal, so the apply must still be +allocation-free); and `CommittedPreparationRevokesItsStagingCollisionRoot` (`:1664`) in +spirit — the staging root must still be revoked after commit, it simply no longer becomes +the active root. + +### Migration plan + +| Slice | Change | Gate | +|---|---|---| +| **O1** | Per-prefix installed-key ledger in `CollisionWorldState`, maintained by the install/remove paths. Rewrite the seal's ten full-map scans (`PhysicsDataCache.cs:1081-1160`, `CellGraph.cs:184, 203`) to enumerate that set. Removes the cross-frame active-map enumerators. **Behaviour-identical; a pure win that lands alone.** | existing suites green + the new seal-independence assertion | +| **O2** | `PhysicsEngine.CommitLandblockReplacement` drains `LandblockReplacementApplyCursor` against the active root instead of `TransferTo`. Extend the `_suppressCollisionOwnerJournal` bracket over the whole apply. Retirement cursor destination → active root. | focused Runtime physics suite + connected lifecycle gate | +| **O3** | Empty staging root: delete `CollisionStagingBuilder` phases 0–8. Move retained-owner reflood into the commit call (retail `init_objects` → `recalc_cross_cells`). Delete the now-dead peer-rebase/journal paths and their tests. | full ladder below | + +### Gate ladder (O3 closeout) + +1. **Focused:** Runtime physics collision-generation suite, App + `LandblockPhysicsPublisherTests`, Headless `HeadlessSessionHostTests`. +2. **Complete Release solution:** baseline to match or beat is **10,808 passed / 0 failed + / 4 skips** (`-m:1`, `ACDREAM_PAK_PATH`). +3. **Connected lifecycle/reconnect gate:** signature must hold — `Passed=true`, + `Failures=[]`, both sessions `ExitCode=0`, zero render-shadow mismatches, zero pending + deltas, graceful exits. +4. **Nine-stop soak must reach `Passed: true` with `Failures: []`.** The failing run is + `logs/connected-r6-soak-20260802-143157.report.json` (37 failures, `Passed: false`); + the passing baseline is `logs/connected-r6-soak-20260727-004942.report.json` + (`Passed: true`, commit `a9a822f2`). Concrete acceptance, per checkpoint: + + | Key | Failing run | Required | + |---|---|---| + | `resources.streamingWork.deferredCompletions` | 92–501 at 8/9 stops | `0` at all 9 | + | `resources.streamingWork.farBacklog` | same values | `0` at all 9 | + | `resources.streamingWork.pendingPublications` | `1` at 8/9 | `0` at all 9 | + | `resources.streamingWork.deferredAdoptedCpuBytes` | 1.5–8.5 MB | `0` at all 9 | + | `resources.streamingWork.oldestDeferredAgeMilliseconds` | 37,764–69,728 | `0` | + | `resources.loadedLandblocks` | 124–533 | **625** at the eight outdoor stops | + | `reveal.waitCueShown` | `true` at 6/9 | `false` at all 9 — *this is the user-reported "extended/stuck portal space"* | + | `streamingWork.lifetimeFrameOverrunCount` | 1,706 | materially lower | + | `streamingWork.maximumOperationStage` | `"publication-index-physics"` | must no longer name this stage | + + **`aerlinthe` (sequence 4) is the control, not a target.** It is the one indoor + destination and the one stop that is already clean in the failing run (374/173 vs the + baseline's 374/176, every streaming counter `0`) — precisely because an indoor + destination streams few landblocks, so O(N²) never bites. It must stay clean; do not + expect it to reach 625. + +5. **Frame time must not regress — and must recover.** Route-level `cpuUs` from + `frame-history-summary.json`, microseconds: + + | | p50 | p95 | p99 | p999 | + |---|---|---|---|---| + | baseline `20260727` | 9,730 | 41,262 | 44,875 | 63,474 | + | failing `20260802` | 17,001 | 47,434 | 57,061 | 102,876 | + + Gate on the sharper per-checkpoint window numbers: **`checkpointWindows[].metrics.cpuUs` + p99 within +10 % of the `20260727` baseline at every stop.** The worst offenders are + `caul-plateau` 101,408 → target ≈ 47,821; `caul-return` 103,309 → ≈ 46,938; + `caul-baseline` 108,148 → ≈ 43,664; `sawato-baseline` 49,748 → ≈ 10,592. Frame count + should recover toward the baseline's 35,492 frames / 498.5 s from the failing run's + 25,965 / 583.9 s. + +6. **Do NOT gate on these — retest only after convergence.** `trackedGpuBytes` (62 MB + failing vs 474 MB baseline), `meshRenderData` 588 vs 607, `meshEstimatedBytes` + 233.8 MB vs 268.6 MB, and the inverted CPU mesh-cache hit ratio + (3,666 hits / 5,689 misses vs 20,825 / 6,845) are all far-ring-never-converged + artifacts of the same mechanism. F4 already reached this conclusion; a leak + investigation before convergence is restored will chase a ghost. + +### Register / plan bookkeeping (same commit as the code) + +- **`docs/architecture/retail-divergence-register.md:113`** — the retired AD-6 row + describes the deleted mechanism verbatim ("one shared off-side `CollisionWorldState`", + "one zero-managed-byte volatile root transfer", the journal, the peer rebases). A + retired row still documents what shipped; leaving it describing a deleted clone is + exactly the out-of-sync failure the register rules forbid. Rewrite it to the delta-apply + mechanism. The row's retail anchor is already + `CObjCell::init_objects` → `recalc_cross_cells` (0x0052b420 / 0x00515a30) — the new + mechanism is **closer** to that anchor, so no new deviation row is created. +- **Judgment call for the implementer, do not assume:** the *original* AD-6 deviation was + "Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells`" + (`be94bc9b` register diff). If O3's commit-time reflood is again per-landblock rather + than per-cell, decide explicitly whether AD-6 must be un-retired or a successor row + added, and record the decision. Flagged, not decided here. +- **`docs/research/2026-07-31-atomic-collision-generation.md`** — steps 2, 3, 5, 6, 7, 8 + and most of the "Deterministic evidence" list describe the clone / journal / rebase. + Rewrite in the same commit. +- **`memory/project_collision_port.md`** — the 37-line block `6b28ff99` added is now wrong. +- **`claude-memory/project_physics_collision_digest.md`** — add two DO-NOT-RETRY entries: + (1) *"Do not re-introduce a whole-world staging clone. The atomic unit is the landblock + delta applied in one update-thread call; the runtime is single-threaded and the root + swap buys nothing."* (2) *"Batching N leaves per staging step is not a fix — measured + 1.8× at N=256, not convergence, and it trips the committed invariant test."* +- **`docs/ISSUES.md`** — F4 established this regression is not in the C3c diff, so it + needs its own issue id (the C3c smoke-test commit `c52ce14a` filed #279 for a different + finding). File it, and reference it from the O1/O2/O3 commit messages. +- **Rollback:** each slice lands as one commit with its own recorded `git revert` SHA, + per the Modern Runtime convention. + +--- + +## (d) Perf-work framing + +**This is modern-runtime infrastructure, not retail-scoped behaviour work.** The delta +apply installs the *identical* `PreparedPhysicsDataCacheLandblock` content that +`TransferTo` publishes today — the same cells, flat cells, EnvCell topology, buildings, +terrain, synthesized outdoor cells, landblock, and owner set. Only the path by which that +content reaches the active root changes, and only the amount of work done to get there. +Collision results, contact planes, walkable polygons, membership, and therefore game feel +are bit-identical. + +The project's render-perf-not-faithfulness-gated rule +(`claude-memory/feedback_render_perf_not_faithfulness_gated.md`) applies: throughput work +that is pixel- and feel-identical does not need a retail-behaviour gate. But because this +is collision, the acceptance bar is still the connected gates plus the user's visual pass +— green unit tests prove nothing about a streaming convergence bug. + +Two guards keep the framing honest: + +1. **The direction of travel is toward retail, not away.** Retail hydrates a cell + synchronously in `CObjCell::init_objects` and refloods the objects associated with it + via `recalc_cross_cells`. A per-landblock delta applied in one update-thread call is + the streaming-shaped version of exactly that. The whole-world clone was the adaptation; + removing it retires an adaptation rather than adding one. +2. **The one thing that could change feel is reflood timing** — owners near the target + re-flooding at commit rather than from a pre-computed staged set. + `CommitTimeRefloodMatchesPrecomputedReflood` (above) is the specific test that turns + that from an assumption into evidence. If that test cannot be made to pass, the perf + framing is void and the slice needs a behaviour gate. + +**Explicitly not a workaround.** Per the no-workarounds rule, note what this is *not*: no +suppression flag, no grace period, no budget loosening, no early-return guard at the +symptom. The root cause is an algorithm that is quadratic in resident-world size, and the +fix is to make it linear by restoring the per-landblock atomic unit the mechanism had +before `6b28ff99`. + +### Measure before and after + +A stripped-after probe should count, per publication: (clone leaves, seal leaves, apply +leaves) and wall-clock for each. F4 measured only the clone (median 19,736 / p90 32,135 / +max 38,021 leaves, median 3.64 ms, 1,584 preparations = 8.53 s CPU in one 4-minute capped +session). The seal was never measured and D2 must beat both. Expected after O3: clone +leaves 0, seal leaves ≈ target payload, apply leaves ≈ target payload, total per +publication well under 100 µs and flat as the ring fills. diff --git a/docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md b/docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md new file mode 100644 index 00000000..cc722d76 --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md @@ -0,0 +1,109 @@ +# Docs-commit drafts — collision publication-throughput fix (O1/O2/O3) + +Drafted per contract; NOT applied to the repo. Apply in the docs commit after +code review. Register judgment executed as pinned: AD-6 stays retired with a +successor note; the residual timing/order compression gets a NEW row (AD-62). + +--- + +## 1. `docs/architecture/retail-divergence-register.md` + +### 1a. Append to the retired ~~AD-6~~ row (line 113), at the end of column 2 + +> **Successor note (2026-08-02, collision publication-throughput fix +> O1/O2/O3):** the whole-world staging clone, the owner-mutation journal, the +> peer-rebase/retirement cursors, and the zero-managed-byte whole-root +> transfer this row describes were deleted. The shipped mechanism is now the +> per-landblock delta commit this row's retail anchor always pointed at: +> admission captures an O(1) empty target-only staging root +> (`PhysicsEngine.CollisionStagingBuilder`), the seal enumerates one prefix's +> installed keys through the `CollisionWorldState` per-prefix ledgers, and +> `PhysicsEngine.CommitLandblockReplacement` drains the sealed delta into the +> ACTIVE root in one synchronous update-thread call, recalculating every +> associated owner's cross-cells against the live world +> (`ShadowObjectRegistry.ApplyCommittedOwnerReplacement` + +> `RefloodPrefixOwnersAfterReplacement`; retail `CObjCell::init_objects` +> 0x0052b420 → `CPhysicsObj::recalc_cross_cells` 0x00515a30). Equivalence is +> pinned by `CommitTimeRefloodMatchesPrecomputedReflood`; world-size +> independence by `CollisionPreparationCostIsIndependentOfResidentWorldSize`. +> Residual timing/order compression vs retail: AD-62. + +### 1b. New row AD-62 (residual timing/order compression), adaptation class + +| AD-62 | **Adaptation.** Commit-time collision reflood granularity/order: retail runs `CObjCell::init_objects` per CELL at cell hydration and `CPhysicsObj::recalc_cross_cells` per object as each cell loads; acdream runs the equivalent once per LANDBLOCK replacement inside the single synchronous activation call, walking the sealed owner list then the live prefix-owner slots (per-landblock granularity matches the streaming unit, same compression `ShadowObjectRegistry.RefloodLandblock` has always carried). An owner becoming target-associated mid-publication refloods at activation (the prefix-slot sweep) rather than at its own cell's hydration instant; a stationary owner adjacent to the target whose flood would only change through building/EnvCell bridges can carry frame-stale cross-cells between the seal capture and the activation sweep (movers self-heal per `SetPositionInternal`). | `src/AcDream.Core/Physics/PhysicsEngine.cs` (`CommitLandblockReplacement`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`ApplyCommittedOwnerReplacement`, `RefloodPrefixOwnersAfterReplacement`) | Late/stale cross-cell rows for a non-moving seam object for a few frames around a landblock publication — an object collidable through a wall seam or briefly not collidable where new topology landed | Low | `CObjCell::init_objects` 0x0052b420; `CPhysicsObj::recalc_cross_cells` 0x00515a30; `CPhysicsObj::SetPositionInternal` tail 0x00515330 | + +--- + +## 2. `claude-memory/project_physics_collision_digest.md` — DO-NOT-RETRY additions + +> - **Do not re-introduce a whole-world staging clone for collision +> generations.** The atomic unit is the landblock delta applied in one +> update-thread call (`PhysicsEngine.CommitLandblockReplacement`); the +> runtime is single-threaded and a root swap buys nothing. The clone made +> ring load O(N²) (the C3c late-monster-pop-in / stuck-portal-space soak +> failure, issue #280). Deleted 2026-08-02. +> - **Batching N staging-clone leaves per step is not a fix** — measured 1.8× +> at N=256, not convergence, and it trips the committed one-work-unit seal +> invariant. The fix was removing the clone, not tuning it. + +## 3. `docs/research/2026-07-31-atomic-collision-generation.md` — update + +Add a banner at the top: + +> **SUPERSEDED IN PART (2026-08-02).** Steps 2 (whole-world staging clone), 3 +> (owner-mutation journal write-through), 5 (journal coalescing/compaction), 6 +> (peer rebases), 7 (draft retirement cursors), and 8 (whole-root transfer) +> describe machinery deleted by the collision publication-throughput fix +> (O1/O2/O3). The atomicity requirement they served — "the multi-frame build +> must not be observable" — is now met by one synchronous per-landblock delta +> apply under prefix quiescence with commit-time owner refloods against the +> live world (retail init_objects → recalc_cross_cells). The admission +> fairness half (quiescence, prefix mutation permissions, ordered activation) +> is unchanged and still accurate. Deterministic-evidence entries that name +> the journal/rebase/retirement tests refer to tests deleted with the +> machinery; their replacements are +> `CollisionPreparationCostIsIndependentOfResidentWorldSize`, +> `CommitAppliesOneLandblockDeltaInASingleCall`, and +> `CommitTimeRefloodMatchesPrecomputedReflood`. + +## 4. `memory/project_collision_port.md` + +Remove/replace the 37-line block `6b28ff99` added (the starvation-free clone +description) with a pointer to the new mechanism (same content as 1a). + +## 5. `docs/ISSUES.md` — file the regression as its own issue + +> - **#280 — OPEN → fixed pending review: collision staging clone made ring +> load O(N²)** (filed 2026-08-02). The per-publication whole-world staging +> clone (be94bc9b synchronous, 6b28ff99 metered) plus the seal's full-map +> scans cost ~2-3× resident world per landblock publication, so an +> N-landblock ring cost O(N²) and the far ring never converged. F4 measured +> median 19,736 leaves / 3.64 ms per publication; C3c made it user-visible +> (late monster pop-in, extended/stuck portal space, portal-exit pop-in, +> nine-stop soak failure 20260802-143157) but did not cause it. Fix: O1 +> per-prefix installed-key ledger; O2 per-landblock delta commit +> (restores be94bc9b's O(changed) apply); O3 empty staging root + +> commit-time reflood (retail init_objects → recalc_cross_cells) + journal/ +> rebase/retirement machinery deleted. Reference the O1/O2/O3 commits here +> when they land. + +## 6. Milestones/roadmap + +No phase-table change needed: this is Modern Runtime infrastructure follow-up +inside the active campaign context; the C3c smoke-test findings list in +ISSUES (#278 additions) should get items (d)/(e)/(f)-class re-observed after +the soak gate passes. + +## 7. Commit-message notes for the slice commits + +- O1: `fix(physics): #280 O1 - per-prefix installed-key ledger; seal scans and + landblock removals become O(prefix keys)` — behavior-identical; new test + CollisionSealWorkIsIndependentOfResidentWorldSize. +- O2: `fix(physics): #280 O2 - restore per-landblock delta commit (be94bc9b + shape) at PhysicsEngine.CommitLandblockReplacement` — notes: staging-slot + owner-list widening (direct-staged owners), staging-root revoke, zero-byte + commit asserts → O(target payload) bounds (commit-time reflood + dictionary + node inserts allocate; world-size independence pinned by the O3 test). +- O3: `fix(physics): #280 O3 - empty staging root; commit-time reflood + (init_objects → recalc_cross_cells); delete journal/rebase/retirement + machinery` — 9 mechanism tests deleted, 2 contract tests added. diff --git a/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md b/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md new file mode 100644 index 00000000..79d0499a --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md @@ -0,0 +1,4516 @@ +# Implementer progress — Runtime initial-placement continuation executor + +## Reading phase (complete) +- Read contract, runtime-surface.md, retail-notes.md in full. +- Read source: RuntimeInitialCreateResidenceState.cs (full, 960 lines), + InboundPhysicsStateController.cs (full, 903 lines), + RuntimeAuthoritativePositionRouteClassifier.cs (full, 580 lines), + RuntimeEntityObjectLifetime.cs (full, 2134 lines), + RuntimeEntityDirectory.cs (full), RuntimeEntityRecord.cs (full), + ParentAttachmentState.cs (full), RuntimeInitialCreateAdmissionFreezer.cs (full), + RuntimeSetPositionState.cs (targeted: struct defs, CaptureOwnership, + Begin*/Watch/IsCurrent/TryPeek/Consume/Forget*, PrepareMover/Submit/ + Apply/AcknowledgeProjection/PublishCancellation/Forget/LeaveWorld), + RuntimeInitialCreateResidenceStateTests.cs (full, 2657 lines - harness + helpers: Bind/Spawn/AttachDormantBody/Prepare/ApplyQueuedPosition/ + PositionUpdate/ApplyFreshSuccessor/ConvergeSessionClear/PositionAction/ + SetCompletedAdoptionRevision/EntityObserver/PlacementObserver). + +## Key design decisions made during reading +1. AdoptCompletedPlacement resolves runtime-surface.md 3.1 deadlock: + BeginAcceptedPlacementCore/TryBeginExclusiveAuthoredPlacement all reject + while HasRetainedCompletion(key) is true. Executor must call + ConsumeAcknowledgedPlacement directly (via new AdoptCompletedPlacement on + residence state) BEFORE any Position continuation can begin its own + placement. +2. Movement timestamp reconstruction: AcceptedPhysicsTimestamps has + ServerControlledMove but NOT Movement itself. Reasoned from the + "MOVEMENT_TS consumed before discovering SERVER_CONTROLLED_MOVE_TS stale" + comment: HasTimestampMutation==true always implies gate.MovementTimestamp + advanced to update.MovementSequence (movement gate checked first). So + ApplyAcceptedMotion sets MovementSequence=update.MovementSequence + unconditionally when retained, ServerControlSequence=retained + AcceptedTimestamps.ServerControlledMove exactly. Judgment call - documented + in code comment. +3. MirrorGateTimestamps (reads LIVE gate) must NOT be used by the executor + (would stomp other channels' Timestamps fields with the wrong values from + out-of-band-drift live gate). Refactored ApplyAcceptedMotion to a targeted + field update instead - verified behaviorally identical for the legacy path + since gate/snapshot stay in lockstep there. +4. Circular-ownership constraint: deferred-child replay needs + RegisterEntityWithInitialResidence (internal core with beginInitialResidence + semantics). Contract forbids passing RuntimeEntityObjectLifetime itself. + Resolution: lifetime passes a bound delegate + (WorldSession.EntitySpawn, bool) -> RuntimeEntityRegistrationResult into the + executor's ctor, mirroring the existing retirePriorProjection callback + pattern already used in RegisterEntityCore. Narrow single-purpose seam, + not a reference to the owning type. +5. Publish choke point: lifetime passes bound delegates for PublishEntity and + AcknowledgeProjectionAndPublish (private methods -> delegate via method + group, same pattern as existing callback threading). + +## Implementation phase - STARTING NOW +Next: refactor InboundPhysicsStateController.cs to extract ApplyAccepted* +methods shared by legacy TryApply* and the new executor. + +## Implementation progress (continued) +- InboundPhysicsStateController.cs refactored: ApplyAccepted{ObjDesc,Pickup, + CreateParent,Parent,Vector,State,Motion,Position} methods extracted, legacy + TryApply* re-expressed as gate+shared-apply. Removed now-unused + MirrorGateTimestamps. Build clean, 89-test focused gate green after refactor. + FOUND+FIXED a bug during this pass: ApplyAcceptedMotion's retainPayload:false + branch must ALSO stamp nested Physics.Timestamps.Movement/ServerControlledMove + (not just top-level fields) - two pre-existing tests + (RejectedServerControlStillMirrorsConsumedMovementTimestamp, + AutonomousLocalEchoRetainsPayloadButMirrorsAcceptedTimestamps) caught this; + fixed, full 829-test Runtime suite green again. +- RuntimeInitialCreateResidenceState.cs: added CompletedEntry.PlacementAdopted, + AdoptCompletedPlacement, ConsumeExecuted + + RuntimeInitialCreateResidenceExecutorReleaseStatus enum. Updated + IsCompletedCurrent to treat PlacementAdopted as satisfying the placement- + current check without re-querying RuntimeSetPositionState (since adoption + already consumed/removed that tracking entry). Updated AcknowledgeAdoption + to tolerate (skip) re-consuming an already-adopted placement. Build clean, + 48/48 residence tests green. +- New file src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + written (full algorithm: reentrancy latch, per-key Progress with LeaseId + staleness discard, initial tail (adopt+hook+deferred-child replay), FIFO + drain for all 8 continuation kinds incl. SameIncarnationCreate envelope + staging with buffered publish, Position classify-at-execution-time + + placement Begin/Watch/yield/resume lifecycle, ConsumeExecuted release with + Revised-loop). Constructed in all 3 RuntimeEntityObjectLifetime ctors, + wired into BindEventContext, CaptureOwnership (new + InitialCreateExecutorProgressCount field), and the residence + Forget/Clear choke points (deterministic progress discard, not lazy). + Build clean, full 829-test baseline still green after wiring. + +## Judgment calls made (to report) +1. AdoptCompletedPlacement/ConsumeExecuted new API design (see code comments + in RuntimeInitialCreateResidenceState.cs) - resolves the 3.1 deadlock per + contract's exact prescription. +2. Registration callback threaded as a bound delegate + (WorldSession.EntitySpawn, bool) -> RuntimeEntityRegistrationResult from + the lifetime's ctor, NOT a reference to RuntimeEntityObjectLifetime itself + - satisfies "no circular ownership" per contract while still reaching + RegisterEntityWithInitialResidence for deferred-child replay. +3. Position continuation Rejected/RejectedData routes reuse + InboundPhysicsStateController.ApplyAcceptedPosition by passing + PositionTimestampDisposition.Rejected explicitly (that method internally + routes to the timestamp-only-stamp branch) - avoids a second, divergent + "timestamp only" implementation. +4. HasContact/forcePositionRotation/currentLocalVelocity all derived from + canonical.PhysicsBody when attached, falling back to Inputs.HasContact + (bodyless tests) - per contract's explicit "decide one way, document it, + test it" instruction for HasContact, extended consistently to the other + two live-body-derived facts. +5. ParentAttachmentState.Resolve/CommitProjection has zero callers anywhere + in AcDream.Runtime (grep-verified) - confirmed host-driven, not wired. + Parent/CreateParent continuation apply commits the position-timestamp-only + snapshot mutation + AdvancePositionAuthority/LeaveWorld/Forget/publish + only; does NOT invent a second resolve/commit path. +6. ObjectTableWiring.ApplyEntitySpawn (via RuntimeEntityObjectLifetime. + ApplyAcceptedSpawn) has zero callers anywhere in AcDream.Runtime + (grep-verified) - WeenieDescription tail action commits + RefreshSnapshot+AdvanceCreateAuthority+publish only, explicitly does NOT + drive the object table (host cutover work, out of scope). +7. ResidentCellCleanup implemented as an assert-the-invariant check + (claimed+celless must be IsDeferred) rather than building a destruction + mechanism; the "no cell claimed, no weenie" destruction-mark branch is + left as a recorded no-op (needs live object-table wiring not in scope). +8. Reentrant Execute() for the same RuntimeEntityKey while one is already on + the call stack fails closed via a HashSet latch. +9. Stale Progress (LeaseId mismatch, e.g. GUID/key reuse) is discarded AND + the CURRENT Execute call returns RejectedAuthority (not silently + re-created + retried in the same call) - matches contract's literal + "discard it and fail closed" wording; caller must retry once more. + +## Major design fix discovered during test-driven debugging +Found a genuine architecture conflict: RuntimeInitialCreateResidenceState's +IsCompletedCurrent staleness check compares LIVE record.PositionAuthorityVersion/ +CreateIntegrationVersion/FullCellId/PlacementCommitVersion against values +FROZEN at Complete()-time. But the executor's OWN Apply* methods legitimately +advance these SAME fields while draining (AdvancePositionAuthority, +AdvanceCreateAuthority, SetFullCell, the physics engine's own +AdvancePlacementCommit on a continuation's own SetPosition commit) - this +made Complete()/ConsumeExecuted incorrectly treat the executor's own +controlled progress as an external race and reject with RejectedAuthority. +Root-caused via a debug bisection test (temporarily added, then removed). + +Fix: added CompletedEntry.Expected{PositionAuthorityVersion, +CreateIntegrationVersion,FullCellId,PlacementCommitVersion} - a SEPARATE, +executor-maintained baseline distinct from the FROZEN receipt.Token (which +must stay byte-identical for Complete()'s token-identity match to keep +working across retries). IsCompletedCurrent now compares against +entry.Expected* instead of receipt.Token.*/receipt.FullCellId/ +receipt.PlacementCommitVersion directly. New method +AdvanceExecutorBaseline(record, token) re-syncs Expected* from the record's +CURRENT live values; the executor calls it (a) at the top of ExecuteCore's +loop before every Complete() call (covers a pending continuation placement's +host-driven commit, which happens BETWEEN Execute() calls), and (b) +unconditionally after every ApplyContinuation call in the drain loop (covers +in-call mutations before a yield). Verified this does NOT weaken the +EXISTING admission-slice regression tests (CorruptedPostAcknowledgementAuthorityRetiresProofAndLease +etc.) since those tests never call AdvanceExecutorBaseline - an external, +non-executor-driven bump to these fields is still correctly detected as +stale. All 48 residence tests + 16 new executor tests + 829 baseline pass +together (845 total). + +## Test suite status: 16/16 new tests green, 845/845 total Runtime tests green +Covered: A (2 tests: basic completion + hook; mixed simple continuation +order), C (2: envelope atomicity/buffered-publish/stage-order incl. +PreTailDescriptionAdaptation+Pickup decomposition; envelope retry +non-duplication), D (5: local ordinary interpolate, local teleport placement +lifecycle w/ yield+resume, remote near interpolate, remote far +SetPositionSimple+StopInterpolating, parented-initial AwaitFreshPosition/no +placement), E (2: deferred-child replay consumes exact AdmissionId + +registers child through canonical route; stale AdmissionId cannot consume a +replacement - via ParentAttachmentState directly), G (2: reentrant Execute +for same entity fails closed; reentrant delete of the parent during deferred +child replay abandons without resurrection), H (1: stale Progress LeaseId +via reflection-planted entry discards + fails closed + clean retry), I (2: +reset during AwaitingContinuationPlacement converges every ledger; dispose +after successful execution converges IsConverged). +NOT separately tested (time-budget / defensibility tradeoffs - noted for +final report): F's literal "failure injection between every envelope stage" +(only end-to-end retry-after-full-drain covered, not synthetic mid-stage +crash injection); J (relies on the existing assembly-level +RuntimeDependencyBoundaryTests, which automatically covers the new file - +no new file added since the check is assembly-wide, not per-file); the +ResidentCellCleanup destruction-mark branches (only the "claimed+resident" +and the assert-invariant path are exercised, not the "no cell, no weenie" +destruction-mark path, which is explicitly a recorded no-op pending future +object-table wiring per code comment); SameIncarnationCreate's Position +stage mid-envelope yield+resume (D covers standalone Position yield/resume; +the envelope's OWN Position-stage yield path shares the identical +ApplyPositionAction code but is not independently exercised by an envelope +test with a position stage). + +## ROUND 2: Reviewer feedback (Finding 1, Gaps 2-4, Finding 5 amendment) +Coordinator sent review findings after round-1 delivery. Working through: +- FINDING 5 (production regression, addressed FIRST since flagged most + urgent): ApplyAcceptedMotion's legacy caller was stamping + update.MovementSequence (wire's own stale/rejected value) instead of + gate.MovementTimestamp (post-call gate value) for the timestamp-only + branch. TryAcceptMovementEvent has 3 rejection flavors and only 1 of them + (stale ServerControlledMove) actually advances MOVEMENT_TS; the other two + (bad instance, stale MOVEMENT_TS) leave the gate untouched but the OLD + buggy code would still stamp the wire's stale value into the snapshot. + FIX: re-parameterized ApplyAcceptedMotion to take explicit + (movementSequence, acceptedServerControlledMove) inputs instead of + deriving movementSequence from `update` internally - ONE shared body, two + explicit-input callers. Legacy caller now passes + gate.MovementTimestamp/gate.ServerControlledMoveTimestamp (exact + pre-refactor behavior in all 3 flavors). Executor caller passes + action.Movement.Value.MovementSequence/action.AcceptedTimestamps.ServerControlledMove + (safe there since retention itself gates on genuine acceptance). Added 2 + regression tests to InboundPhysicsStateControllerTests.cs comparing the + FULL EntitySpawn before/after for (1) stale MOVEMENT_TS, (2) instance + mismatch - both assert byte-identical snapshots. 15/15 + InboundPhysicsStateControllerTests pass, 64/64 executor+residence tests + still pass after the signature change. + +- FINDING 1 fixed: narrowed the pre-Complete() AdvanceExecutorBaseline call + in ExecuteCore to only fire when progress.PendingContinuationPlacement.IsValid + (the only legitimate between-calls mutation window). Added 2 regression + tests (ExternalPositionAuthorityMutation.../ExternalFullCellMutation...) + that directly drive Complete()+AdoptCompletedPlacement to the + "completed+adopted, no pending placement" state, then externally mutate + PositionAuthorityVersion/FullCellId, then assert the NEXT Execute() call + observes RejectedAuthority, publishes nothing, and converges. VERIFIED + these tests actually catch the regression: temporarily reverted the guard + to unconditional, confirmed both tests fail (Completed instead of + RejectedAuthority), then restored the fix and confirmed they pass again. + 18/18 executor tests pass (16 + 2 new). + +- GAP 4 done: extended RuntimeInitialCreateExecutedAction with a new + optional ResidentCellCleanupDisposition field + + RuntimeResidentCellCleanupDisposition enum (ResidentUnmarked/ + DeferredUnderLostCellOwnership/NoCellClaimedDestructionMarked). + ApplyResidentCellCleanup now RETURNS the disposition instead of just + asserting. (a) ResidentCellCleanupUnmarksWhenCellClaimedAndAlreadyResident + - engine-backed, real initial placement, same-create Position stage + classifies Interpolate (SameIncarnationCreate source forces + effectiveContact=true, entity already resident so not cellless) -> no + placement needed, ResidentUnmarked recorded. (b) + ResidentCellCleanupFailsClosedWhenClaimedCelllessOutcomeIsNotUnderLostCellOwnership + - PickedUp initial (no placement), same-create Position with + UsePositionFromServer=false classifies NoPositionOperation (no + SetPosition begins at all) -> claimed+celless+not-deferred -> throws + InvalidOperationException, verified via Assert.Throws + message + content. (c) folded into the EXISTING envelope-atomicity test (added + assertion) since that entity already naturally has no claimed cell -> + NoCellClaimedDestructionMarked. 20/20 executor tests pass (18 + 2 new; + case (c) added to an existing test rather than a new one). + +- GAP 3 done, AND IT CAUGHT A REAL BUG: wrote + EnvelopePositionStageRequiringSetPositionYieldsResumesAndPublishesOnceAfterCompletion + (cellless -> SetPosition Position stage forces a mid-envelope yield, + drive prepare/submit/ack, resume, envelope completes). First run: only 1 + of 5 expected Updated events published (expected ObjDesc/Position/State/ + Vector/WeenieDescription). ROOT CAUSE: Publish()'s buffered branch stored + the PER-STAGE "matches" closure (captured at that stage's OWN commit + time, checking one specific AuthorityVersion field) into the buffer - + but WeenieDescription's own AdvanceCreateAuthority() call bumps SIX + authority-version fields at once (Position/State/Vector/Velocity/ + Movement/ObjDesc/CreateIntegration), which invalidated every EARLIER + buffered stage's captured version-equality check by the time the final + flush ran, even though nothing external raced - it was the executor's + OWN later, expected progression. FIX (root cause, not per-callsite + patch): Publish()'s buffered branch now stores a constant `true` + predicate instead of the per-field "matches" closure - IsCurrent (checked + unconditionally by PublishNow) is the only currency guard a buffered + entry needs, since envelope processing dispatches no event until the + flush (no reentrancy window mid-envelope except at a Position yield, + which is independently guarded by ApplyEnvelope's own IsCurrent check + + ResumePendingPlacement). The IMMEDIATE (non-buffered) path is unchanged - + still uses the real matches() check, appropriate there since publish + happens right after each standalone mutation. 21/21 executor tests pass + after the fix (20 + 1 new). + +- GAP 2 done: EnvelopeAbandonedDuringPositionStageYieldPublishesNothingAndConvergesEveryLedger + (delete the entity while AwaitingContinuationPlacement is pending + mid-envelope; asserts ONLY the delete's own Deleted event observed - none + of the envelope's already-committed-but-buffered stages ever publish - + and every ledger converges: residence/progress/active-operation/ + acknowledged-completion counts all zero; a stale retry with the original + token returns RejectedToken). Documented (large comment block, not a + fake test) exactly why the other 6 stage kinds have no reachable yield + boundary (pure synchronous in-memory mutations, no event dispatch until + the buffered flush, so no callback/reentrancy opportunity exists without + adding a diagnostic seam to production code, which is explicitly + forbidden). While building this test, ALSO found and fixed a real (if + currently redundant-with-existing-Forget) defensive gap: DiscardProgress/ + DiscardAll now also forget any in-flight CONTINUATION placement token + (distinct from the residence's own initial-lease placement) - verified + via revert that this specific delete-path test still passes WITHOUT the + fix (TryAcceptDelete's own unconditional Physics.SetPosition.Forget + already cancels any operation for that key), so this is honest + defense-in-depth (DiscardProgress owning cleanup of the state it + introduces) rather than a proven-necessary fix for THIS path - documented + as such in both the code comment and the test comment. + 22/22 executor tests pass (21 + 1 new). + +## ALL 4 REVIEW ITEMS (Finding 1, Gaps 2/3/4) COMPLETE, plus Finding 5 +(production regression) fixed first. + +## ROUND 2 FINAL GATES: ALL GREEN +1. Release build (AcDream.slnx): 0 errors, 21 pre-existing warnings. +2. Focused filter (Residence+Classifier+Executor): 111 passed (89 + 22). +3. Complete Runtime suite: 853 passed (829 + 24: 22 executor + 2 + InboundPhysicsStateController Finding-5 regressions). +4. git diff --check: exit 0, clean. Nothing staged. +Confirmed the narrowed rebaseline (Finding 1) did not break the existing +AwaitingContinuationPlacement yield/resume path - +LocalTeleportContinuationDrivesItsOwnAuthoredPlacementLifecycle still +passes throughout every round of changes. + +## FINAL STATUS (Round 2): ALL GATES GREEN +1. dotnet build AcDream.slnx -c Release: 0 errors, 21 warnings (all + pre-existing, in test projects untouched by this work - confirmed exact + match to the known-count in the task brief). +2. Focused filter (Residence+Classifier+Executor): 105 passed (89 baseline + + 16 new), 0 failed. +3. Complete AcDream.Runtime.Tests: 845 passed (829 baseline + 16 new), 0 + failed. +4. git diff --check: exit 0, clean (only pre-existing line-ending warnings + on files this session did not touch, or normal LF-will-become-CRLF + metadata notices on files this session DID touch - no whitespace-error + content). +Nothing staged (git diff --cached --stat empty) - primary agent to review +and commit. + +## ROUND 3: both independent reviews returned FAIL (3 blockers, 12 majors, +## mandated test-completion list). Combined directive at round3-fixes.md. +## Followed the section-D work order: A1/A2 first, then B1/B2/B3/B4, then +## A3/B5/B6/B9/B10, then B7/B8/B12, then B11, Section C tests throughout. + +### A1 (blocker) — snapshot lockstep. FIXED. +Root cause confirmed exactly as the review described: +InboundPhysicsStateController._snapshots (the legacy merge base) was NEVER +written by the executor's applies - they merged directly against +canonical.Snapshot via the STATIC ApplyAccepted* methods and called +RefreshSnapshot, but _snapshots[guid] stayed frozen at whatever it was when +residence began. The FIRST subsequent legacy TryApplyXxx call would then +re-merge onto that stale base and silently revert every drained fact. +Fix: added gate-less INSTANCE seam methods on InboundPhysicsStateController +(ApplyAccepted{ObjDesc,Pickup,CreateParent,Parent,Motion,State,Vector, +Position,WeenieDescription}Snapshot) that read _snapshots[guid] as the merge +base, run the existing shared static body, write the result back, and return +the merged value. Delegated through RuntimeEntityDirectory (new +ApplyAccepted*Snapshot wrappers calling _inbound.*) since the executor only +holds a RuntimeEntityDirectory reference, not the controller directly. Every +executor Apply*Action now calls the instance seam instead of the static +method. Added the mandated regression test +DrainedAppearanceAndPoseSurviveTheNextLegacyWireApply: drains a standalone +ObjDesc continuation (fresh BasePaletteId), then runs an ordinary legacy +TryApplyVector, asserts the drained palette survives in canonical.Snapshot +after the legacy apply. Verified this fails without the fix by tracing the +exact code path (old static-based merge would read the STALE _snapshots +base and RefreshSnapshot would revert the palette) - did not need to revert +code to prove it since the mechanism is unambiguous from the diff. + +### A2 (blocker) — WeenieDescription merge semantics. FIXED. +ApplyWeenieDescriptionAction now calls the new +ApplyAcceptedWeenieDescriptionSnapshot instance seam, which merges via the +EXISTING private static MergeUntimestampedCreate(retained: _snapshots[guid], +incoming: the raw WeenieDescription packet) instead of a wholesale +RefreshSnapshot of the raw packet. Added the mandated regression test +SameIncarnationWeenieDescriptionMergePreservesRetainedMotionTableId. First +draft of this test used BasePaletteId as the probe field and failed for an +UNRELATED reason I initially misread as a bug: BasePaletteId (and every +other field BuildSameGenerationEvents' Appearance construction touches) is +ALSO independently re-applied by the SAME envelope's OWN dedicated ObjDesc +stage (always present when incoming.Physics is not null) - so a standalone +ObjDesc drain's fresher palette is CORRECTLY superseded by the envelope's +own ObjDesc stage moments later, regardless of A2. Re-designed the test +around MotionTableId, which has NO dedicated envelope stage (WeenieDescription +is the ONLY place it can move) - this cleanly isolates +MergeUntimestampedCreate's "retained wins" rule. Test now: entry 1 = +standalone ObjDesc bump; entry 2 = SameIncarnationCreate whose raw incoming +MotionTableId differs from the entity's original; asserts the ORIGINAL +MotionTableId (retained) survives, not incoming's. + +### B12 (major) — object-table wiring. FIXED; corrected a false "zero +### callers" claim from Round 1/2. +Verified RuntimeLiveEntitySessionController.cs:81 (OnSpawned) drives +Entities.ApplyAcceptedSpawn(canonical, integrationVersion, canonical.Snapshot, +replaceGeneration: Inbound.Disposition is NewGeneration) for EVERY accepted +Create in the non-residence direct-host path - the prior claim of zero +callers was false. ApplyAcceptedSpawn lives on RuntimeEntityObjectLifetime +(needs the ClientObjectTable the executor has no reference to, and cannot +reference RuntimeEntityObjectLifetime directly - circular ownership, same +constraint as the existing _registerDeferredChild delegate). Threaded a new +constructor delegate Func _applyAcceptedSpawn, bound in all 3 RuntimeEntityObjectLifetime +ctors to (canonical, version, spawn, replaceGeneration) => +ApplyAcceptedSpawn(...). ApplyWeenieDescriptionAction now calls it with +replaceGeneration: false always - correct because this tail action ONLY ever +runs for an ExistingGeneration same-incarnation Create (a residence is +admitted into the SameIncarnationCreate FIFO only when preview is +ExistingGeneration; NewGeneration goes through the ordinary top-level +registration path, never this envelope). Added +WeenieDescriptionStageWiresTheObjectTableExactlyOnce: asserts +lifetime.Objects.ObjectCount increases by exactly 1 across a residence drain +whose envelope reaches WeenieDescription (a residence-pending admission +deliberately never wires the object table at the initial Create, so this is +the first point this guid's entry can appear). + +### B1 (major) — shared abandonment routine; typed ResidentCellCleanup +### abandonment; operation-slot-contention as non-abandonment. FIXED. +Added one Abandon(canonical, key) choke point: calls +_residences.Forget(canonical, ...) (retiring the RESIDENCE itself, not just +executor progress - closes a real bug where several rejection paths, +notably ConsumeExecuted's own final-length-mismatch RejectedAuthority branch +and AdoptCompletedPlacement's ConsumeAcknowledgedPlacement-failure branch, +left the completed residence entry sitting fully intact in _completed; a +retry would have re-fetched it via Complete() and REPLAYED every +already-applied continuation from sequence zero), publishes the cancellation, +then DiscardProgress(key) (idempotent defense-in-depth, matching every other +caller's pattern - covers Forget finding nothing to retire). Every +ad hoc "DiscardProgress(key); return RejectedAuthority;" site now routes +through Abandon. ApplyResidentCellCleanup no longer throws for the +claimed+celless+not-deferred invariant violation - returns +RuntimeResidentCellCleanupDisposition? (null signals Abandon); the envelope's +ResidentCellCleanup case checks for null and calls Abandon instead of +letting the exception escape Execute. Rewrote the existing test +ResidentCellCleanupFailsClosedWhenClaimedCelllessOutcomeIsNotUnderLostCellOwnership +from Assert.Throws to assert RejectedAuthority + full ledger convergence + +a stale-token retry returning RejectedToken (proving Abandon actually +retired the residence, not just discarded progress). +Operation-slot contention: added Progress.PositionMergeCommittedForRetry + +PositionMergeCommittedVersion. When TryBeginExclusiveAuthoredPlacement fails +AND the record/its own merge-committed version are still current, this is +NOT abandonment - returns AwaitingContinuationPlacement with +PositionMergeCommittedForRetry left true and NO PendingContinuationPlacement +set, so the NEXT ApplyPositionAction call for the SAME continuation/stage +skips the merge+publish entirely (route = progress.PendingContinuationRoute, +already classified) and retries ONLY the placement-begin - closing the +"double-publish on every retry" hole a naive full re-apply would open. Only +when the record is no longer current OR its PositionAuthorityVersion moved +past the committed merge's own value does this become abandonment. NOT +separately unit-tested (constructing a real operation-slot-contention +scenario needs a second concurrent SetPosition consumer occupying the same +key's slot, which none of the existing test harness helpers construct) - +flagged as a coverage gap in this report; the logic was verified by code +review against RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement's +exact failure conditions (_operations.ContainsKey(key) is the transient +case; HasRetainedCompletion/PositionAuthorityVersion mismatch inside +BeginAcceptedPlacementCore are the genuine-staleness cases my currency +recheck already covers). + +### B2 (major) — apply ordering: mutate -> rebaseline -> publish. FIXED. +Threaded `in RuntimeInitialCreateResidenceToken token` through +ApplyContinuation/ApplyEnvelope/ApplyPositionAction and all 8 non-Position +Apply*Action methods. Each now calls _residences.AdvanceExecutorBaseline +immediately after its own canonical mutation and BEFORE its own Publish call +(previously rebaseline happened in the OUTER drain loop, AFTER +ApplyContinuation returned - i.e. AFTER Publish had already run for the +non-buffered/immediate path, leaving a reentrant-retirement window where a +synchronous Publish observer could see the pre-mutation baseline and +misdetect staleness). Removed the drain loop's blanket +"_residences.AdvanceExecutorBaseline(canonical, token);" call after every +ApplyContinuation - each apply now guarantees its own baseline is current +before any observer can run, so no blanket re-sync belongs there. + +### B3 (major) — residence retirement callback. FIXED. +RuntimeInitialCreateResidenceState.BindRetirementNotification(Action< +RuntimeEntityKey>) - one optional callback invoked in BOTH private Retire +overloads (Entry and CompletedEntry), Forget (both branches), and Clear +(iterating every retired entry) - AFTER the dictionary mutation in each +case. RuntimeEntityObjectLifetime binds it in all 3 ctors, right after +constructing InitialCreateExecution, to +key => InitialCreateExecution.DiscardProgress(key). This closes the gap +where a residence retired through a path OTHER than the executor's own +explicit DiscardProgress call (e.g. TryGetTransaction/TryGetCurrent/Complete/ +AcknowledgeAdoption/AdoptCompletedPlacement/ConsumeExecuted's OWN internal +Retire calls on staleness) would leave the executor's progress AND its +separately-tracked pending continuation placement token orphaned. Kept +ForgetInitialCreateResidence's own explicit DiscardProgress call as harmless +idempotent defense-in-depth (covers the case where Forget finds nothing to +retire at all) and updated its comment to explain the relationship rather +than removing it. + +### B4 (major) — ResumePendingPlacement full record/projection agreement. +### FIXED. +Strengthened to match Complete()'s exact check set: projection.Entity, +SessionLifetimeVersion, PositionAuthorityVersion (BOTH against the +placement token AND against the LIVE canonical.PositionAuthorityVersion - +catches something ELSE moving the record since this placement began, which +the old check could not see), ExactCellId != 0 AND == canonical.FullCellId, +PlacementCommitVersion == canonical.PlacementCommitVersion. Mismatch now +routes through Abandon (was a bare RejectedAuthority before). Renamed the +local placement token variable to placementToken to avoid shadowing the +newly-threaded outer `token` parameter (then removed the parameter again +since the strengthened check never needed the residence token's own +SourcePlacementCommitVersion field - no natural equivalent baseline exists +for a CONTINUATION's placement the way the residence's own token has one +for the INITIAL placement, and adding an unused parameter was worse than +omitting it). + +### A3 (blocker) — HasContact from wire IsGrounded. FIXED. +Removed RuntimeInitialCreateExecutionInputs.HasContact entirely (record now +just UsePositionFromServer/PlayerDistance). ApplyPositionAction's hasContact +now reads `action.Position!.Value.IsGrounded` (WorldSession.EntityPositionUpdate +already carries this field, PositionPack bit 0x4, server-asserted contact at +admission time) - no PhysicsBody?.InContact derivation, no inputs fallback. +Confirmed WorldSession.EntityPositionUpdate.IsGrounded already exists and is +populated by BuildSameGenerationEvents (hardcoded true for +SameIncarnationCreate-sourced positions - matches retail passing arg5=true +directly for that source) and by every test's PositionUpdate helper. Fixed +6 call sites across the test file that constructed +RuntimeInitialCreateExecutionInputs with the now-removed named parameter; +each one's semantic intent (contact true/false) was already independently +preserved by the underlying WorldSession.EntityPositionUpdate.IsGrounded +value at each site, confirmed individually before dropping the parameter +(no test's PASS/FAIL meaning changed). + +### B5 (major) — remove HasAnimations SameIncarnationCreate short-circuit. +### FIXED. +hasAnimations is now `canonical.Snapshot.MotionTableId is {} m && m != 0u` +unconditionally - the `action.PositionSource is SameIncarnationCreate ||` +short-circuit is gone. (Note: the CLASSIFIER's OWN, separate +`effectiveContact = Source is SameIncarnationCreate || HasContact` +short-circuit for the non-local branch is untouched - that is a DIFFERENT +mechanism the review did not flag, confirmed by rereading +RuntimeAuthoritativePositionRouteClassifier.cs's ClassifyAcceptedPosition +before making this change.) + +### B6 (major) — thread route flags through position apply + trace. FIXED. +InboundPhysicsStateController.ApplyAcceptedPosition gained +installPlacementFrame/clearParent bool params. installPlacementFrame gates +the placement-id computation (previously unconditional whenever disposition +was Apply); clearParent gates whether ParentGuid/ParentLocation/ +Physics.Parent get nulled (previously unconditional always-null). Legacy +TryApplyPosition passes true/true (exact prior behavior, verified by +re-deriving the original unconditional logic under installPlacementFrame=true +matches it exactly). The executor passes +installPlacementFrame: route.ApplyPlacementFrameBeforeRouting, +clearParent: route.UnparentBeforeRouting directly - confirmed by rereading +the classifier that UnparentBeforeRouting is false ONLY for the +ForcePosition branch and true for every other accepted route, matching +retail's Gate-A-returns-before-unset_parent structure exactly (no OR/ +redundant condition needed, unlike the pinned hint's phrasing - the route's +own flag already encodes the full decision). Added a NEW execution-rejected +stamp variant (see B10) that also needed these two params threaded through +for its own call site. Extended RuntimeInitialCreateExecutedAction with +ConstrainPhase/StopInterpolating/ZeroVelocity/PreserveHeading/ +SendPositionImmediately/UnparentBeforeRouting, all pulled from the route in +BuildPositionTrace (both the accepted-merge and resume call sites already +pass a fully-populated route, including the RejectedAuthority/RejectedData +factory routes' all-false/None defaults). + +### B7 (major) — deferred-child replay via whole-bucket detach. FIXED. +Added ParentAttachmentState.DetachDeferredCreates(parentGuid) -> +ImmutableArray: atomically removes and returns the +ENTIRE queued bucket for one parent (retail: PartArray::add_child-owning +CreateObject handler detaches the whole netblob list before dispatching, +pseudo-C ~93617 - detach IS the consume, no separate peek-then-remove). +ReplayDeferredChildren rewritten to call this once and iterate the detached +snapshot, rechecking _entities.IsCurrent(canonical) per iteration (unchanged +abandonment semantics) - this structurally eliminates the old peek/consume +loop's stale-AdmissionId race entirely (a Create arriving for the SAME +parent during replay now enqueues into a BRAND NEW queue instance, since the +old one was already removed from the dictionary). Kept TryPeekDeferredCreate/ +ConsumeDeferredCreate/ContainsDeferredCreate/CancelDeferredChildGeneration - +still used by other invariants and by +StaleAdmissionIdCannotConsumeAReplacementQueuedAfterThePeek, which tests +ParentAttachmentState directly (Section C matrix item: the round3-fixes +text asked E-scenarios to go through the executor path - added a NEW +executor-path test, MultipleChildrenQueuedBehindOneMissingParentReplayInFifoOrderThroughTheExecutor, +covering 2 children queued behind one missing parent, both replaying in +order once the parent registers, rather than migrating the existing +ParentAttachmentState-direct test, since that one specifically exercises +the AdmissionId-staleness invariant which is a ParentAttachmentState-level +contract independent of the executor). + +### B8 (major) — rename unreachable ResidentCellCleanup disposition. FIXED. +NoCellClaimedDestructionMarked -> CelllessNoWeenieMarkUnreachable, with an +updated doc comment citing HandleCreateObject retail-notes.md function 1 +lines ~93942-93943 and the shape guarantee at +RuntimeInitialCreateResidenceState.cs:277-284 +(RuntimeInitialCreateResidenceContinuation.HasValidShape enforces +Actions[^2].Kind is WeenieDescription for every admitted envelope, so +retail's matching "no weenie" condition can never be true through this +exact construction). Updated the one test reference +(SameIncarnationEnvelopePublishesNothingUntilEveryStageCommitsThenPublishesInStageOrder). + +### B9 (major) — Parent continuation execution-time revalidation. FIXED. +New ApplyParentContinuation wrapper (called from ApplyContinuation's Parent +case instead of ApplyParentAction directly): re-checks +_entities.TryGetActive(parentGuid) + incarnation match at EXECUTION time; on +mismatch, re-enqueues via _entities.ParentAttachments.Enqueue(parentUpdate) +and records a routine trace entry (Completed, not abandonment) instead of +running ApplyParentAction against a parent that may have been deleted or +replaced between admission and this drain reaching it. Added +ParentContinuationRevalidatesLiveParentAtExecutionAndReDefersOnMismatch: +admits a Parent continuation while the parent is still active, deletes the +parent before the drain runs, asserts the drain still converges cleanly and +the update lands back in ParentAttachmentState's unresolved queue +(UnresolvedRelationCount == 1) rather than crashing or committing against a +gone parent. +(Envelope-side note: RuntimeInitialCreateTailActionKind.Parent has no case +in ApplyEnvelope's switch at all - only CreateParent does, confirmed by +rereading RuntimeInitialCreateResidenceState.cs's HasValidShape/ +SameCreateStage: the position-branch stage only ever admits +CreateParent/Pickup/Position, never standalone Parent - so B9 only applies +to the standalone top-level continuation kind, which is where the fix +landed.) + +### B10 (major) — new stamp variant for execution-time-rejected retained +### Position. FIXED. +Added InboundPhysicsStateController.ApplyAcceptedPositionExecutionRejectedSnapshot +(+ its RuntimeEntityDirectory delegate): stamps Position/Teleport/ +ForcePosition timestamp channels (all three the gate genuinely advanced at +ADMISSION time) without installing any pose/parent/placement field - +distinct from the EXISTING ApplyAcceptedPositionTimestampOnly (the +ADMISSION-time-gate-Rejected case, where only ForcePosition can have moved). +ApplyPositionAction's `!route.Accepted` branch now branches on +action.PositionDisposition: Rejected (admission itself rejected) uses the +existing Rejected-forced merge; Apply/ForcePosition (admission accepted, but +EXECUTION-time classification now rejects) uses the new stamp variant. Not +independently unit-tested with a NEW dedicated test (constructing a retained +action whose ADMISSION disposition is Apply/ForcePosition but whose +EXECUTION-time classification genuinely rejects needs a live-input/record +mismatch crafted between admission and drain - flagged as a coverage gap; +the code path was verified by direct code review of both branches against +InboundPhysicsStateController.ApplyAcceptedPositionTimestampOnly's own +existing doc comment, which independently documents the same +admission-vs-execution distinction this fix formalizes). + +### B11 (major) — Progress creation timing. FIXED. +Execute/ExecuteCore restructured: an existing Progress for a mismatched +LeaseId is discarded AND the call fails closed immediately (preserves the +EXISTING contract/test +StaleProgressLeaseIdIsDiscardedAndFailsClosedThenRetrySucceedsCleanly's +"fail THIS call, retry succeeds fresh" two-step semantics - my first attempt +at B11 broke this test by silently continuing forward in the SAME call +after discarding stale progress; caught immediately by the full-suite run, +reverted to the fail-closed-then-retry shape). A FRESH Progress for the +CURRENT lease id is never created until _residences.Complete(...) actually +reports Completed - PendingPlacement/RejectedToken/RejectedAuthority +outcomes on a call with no PRIOR progress now leave the ownership ledger +(ProgressCount) completely untouched, rather than a placeholder Progress +object sitting in _progress for a residence that has not even resolved its +own initial placement yet. + +## Test suite status after Round 3 +- 5 NEW tests added (all Round 3): DrainedAppearanceAndPoseSurviveTheNextLegacyWireApply + (A1), SameIncarnationWeenieDescriptionMergePreservesRetainedMotionTableId + (A2), WeenieDescriptionStageWiresTheObjectTableExactlyOnce (B12), + MultipleChildrenQueuedBehindOneMissingParentReplayInFifoOrderThroughTheExecutor + (B7), ParentContinuationRevalidatesLiveParentAtExecutionAndReDefersOnMismatch + (B9). +- 1 EXISTING test rewritten from Assert.Throws to typed-abandonment + assertions (ResidentCellCleanupFailsClosedWhenClaimedCelllessOutcomeIsNotUnderLostCellOwnership, + covers B1's ResidentCellCleanup-abandonment path). +- 1 EXISTING test's enum reference updated + (SameIncarnationEnvelopePublishesNothingUntilEveryStageCommitsThenPublishesInStageOrder, + B8 rename). +- 6 EXISTING call sites fixed for the removed HasContact parameter (A3) - + none of their PASS/FAIL semantics changed, only the construction syntax. +- Complete AcDream.Runtime.Tests: 858 passed (853 Round-2 baseline + 5 new), + 0 failed. + +## Known gaps NOT covered by a new dedicated test (time-budget / +## defensibility tradeoffs, reported honestly rather than papered over): +- B1's operation-slot-contention retry path (transient + TryBeginExclusiveAuthoredPlacement failure while the record stays + current) - needs a second concurrent SetPosition consumer occupying the + same key's slot; none of the harness helpers construct that scenario. + Verified by code review against RuntimeSetPositionState's exact failure + conditions instead. +- B10's execution-time-rejected (admission accepted, live classification + rejects) Position stamp path - needs a live-input/record mismatch crafted + between admission and drain. Verified by code review + cross-reference + against ApplyAcceptedPositionTimestampOnly's existing analogous doc + comment instead. +- B2's reentrant-retirement-window closure is a structural/ordering fix + (mutate -> rebaseline -> publish) proven correct by the FULL 858-test + suite staying green (nothing in the existing suite depends on the OLD + ordering) rather than by a dedicated synthetic-reentrancy test - building + a true concurrent-observer reentrancy test that could only pass with the + NEW ordering and fail with the OLD one was judged lower value than the + other coverage gaps given the remaining time budget. +- Section C's full 9-sub-matrix enumeration from round3-fixes.md was not + exhaustively built out; the 5 new tests target the SPECIFIC new/changed + behaviors (A1/A2/B7/B9/B12) plus B1's rewritten test, prioritized over + broad matrix completeness under this round's time budget. + +## ROUND 4: combined re-review findings (round4-fixes.md, R4-1..R4-15). +## Smaller than Round 3; same bar. Implemented in this order: R4-4 (field- +## masked baseline enum/method - foundational, everything else built on it), +## R4-1 (deferred-child replay containment/restore), R4-5+R4-6 (Parent +## discard + trace enums), R4-2 (ResumePendingPlacement forget-before-abandon), +## R4-3 (WeenieDescription apply-window reorder + result check), R4-7/R4-8 +## (test-only trace-flag/wire-vs-body fixes), R4-9 (two mandated tests), +## R4-10 (captured-key threading), R4-11 (ApplyStateAction bool fix), +## R4-12/R4-13 (doc comments + fallback + structural test pin), R4-14 (doc +## comment), R4-15 (register-rows-draft.md rewrite). + +### R4-1 (deferred-child replay containment). FIXED. +ReplayDeferredChildren rewritten: (a) each child's `_registerDeferredChild` +call now runs inside try/catch - an exception records +RuntimeDeferredChildReplayOutcome.Rejected and the loop continues with the +next entry (was: an uncontained exception would have escaped Execute +entirely and stranded every remaining sibling). (b) mid-loop abandonment +(entity no longer current, e.g. a reentrant delete/reset from an earlier +sibling's own registration callback) now calls the NEW +ParentAttachmentState.RestoreDeferredCreates(parentGuid, remainder) - a +new method that PREPENDS the exact unprocessed remainder (original +DeferredParentCreate records, so original AdmissionIds are preserved) ahead +of anything enqueued for the same parent guid after the detach - before +returning false. Previously the whole detached array was simply dropped on +the floor on abandonment; this was a genuine data-loss bug (retail's queued +blobs live on CObjectMaint per-GUID and survive the object; our own +GUID-keyed persistence design already assumed this but the code broke it). +Tests: DeferredChildReplayContainsOneChildsThrowingRegistrationAndContinuesWithSiblings +(3 children behind one parent; child 2's registration is forced to throw +via a reflection-swapped _registerDeferredChild delegate - the standard +fault-injection pattern this file already used for +SetCompletedAdoptionRevision; child 1 and 3 still register, trace shows +Rejected for child 2, no exception escapes Execute) and +DeferredChildReplayRestoresTheUnprocessedRemainderWhenTheParentIsDeletedReentrantlyMidReplay +(2 children; child 1's registration callback reentrantly deletes the +PARENT; child 2's raw Create is confirmed back in the bucket via +ContainsDeferredCreate; asserted residence-lease-count == 1, matching the +EXISTING DeleteDuringDeferredChildReplayAbandonsExecutionWithoutResurrection +precedent - that "1" is child 1's own never-executed residence, not a +leak; first draft of this test wrongly asserted 0 and had to be corrected +after tracing the precedent test's own comment). + +### R4-2 (ResumePendingPlacement leaked the acknowledged completion on +### both failure arms). FIXED. +Both failure arms (projection/record mismatch; ConsumeAcknowledgedPlacement +failure) now call `_physics.SetPosition.ForgetExactPlacement(placementToken)` ++ `PublishCancellation` BEFORE clearing progress.PendingContinuationPlacement +and calling Abandon (forget -> clear -> Abandon, exactly as pinned). +Verified ForgetExactPlacement's own ForgetPlacementCompletionCore already +removes the _acknowledgedPlacementCompletions entry unconditionally (read +the source directly) - no separate ForgetPlacementCompletion call was +needed. Tests: +ExternalFullCellMutationDuringAwaitingContinuationPlacementForgetsThePendingPlacementAndAllowsAFreshOneToBegin +(the reviewer's exact scenario: drive a local-teleport continuation to +AwaitingContinuationPlacement, complete/acknowledge its placement, THEN +mutate FullCellId externally, assert the NEXT Execute fails closed, +AcknowledgedPlacementCompletionCount == 0, and a FRESH +TryBeginExclusiveAuthoredPlacement for the same key succeeds - proving no +retained-completion leak) and +ThirdPartyTryGetTransactionRetireWhileAwaitingContinuationPlacementDiscardsExecutorProgressAndForgetsThePendingPlacement +(R4-9a, see below). VERIFIED both regression tests actually catch the bug: +temporarily stripped the forget/publish calls from both failure arms (kept +a backup copy of the file), reran the R4-2 test - confirmed FAIL +(AcknowledgedPlacementCompletionCount stayed 1, not 0) - then restored the +fix and confirmed both tests pass again. + +### R4-3 (ApplyWeenieDescriptionAction object-table apply window/result). +### FIXED. +Reordered to AdvanceCreateAuthority -> AdvanceExecutorBaseline -> +_applyAcceptedSpawn -> (false result -> return false, letting the EXISTING +caller route to Abandon) -> buffered publish. Previously the rebaseline ran +AFTER _applyAcceptedSpawn and the bool result was silently discarded - +mirrors RuntimeLiveEntitySessionController.cs:87's own gate on that same +call's result (a nested replacement re-entering from within +ObjectTableWiring.ApplyEntitySpawn's own synchronous ObjectAdded/ +ObjectUpdated dispatch must invalidate the remaining tail). Tests: +ObjectTableSubscriberReenteringAWireApplyDuringIngestDoesNotRetireTheResidenceAndTheEnvelopeCompletes +(subscribe to lifetime.Objects.ObjectAdded, reentrantly call TryApplyVector +from inside it - residence survives, envelope completes) and +NestedReplacementDuringObjectTableIngestAbandonsTheWeenieDescriptionStageWithNoFurtherStages +(same subscription point, but reentrantly RegisterEntity a NEWER +incarnation for the SAME guid - Execute returns RejectedAuthority, no +further stages ran). Both use ClientObjectTable's plain C# events directly +(ObjectAdded/ObjectUpdated) rather than reflection - much simpler than +initially planned once I found these were public events. + +### R4-4 (field-masked executor baseline precision). FIXED - foundational, +### done FIRST since every apply method's shape changed. +Added [Flags] enum RuntimeExecutorBaselineFields (PositionAuthorityVersion/ +CreateIntegrationVersion/FullCellId/PlacementCommitVersion) on +RuntimeInitialCreateResidenceState.cs; AdvanceExecutorBaseline now takes a +`fields` parameter and only copies the named field(s) from the live record +into the CompletedEntry's Expected* baseline. Traced every apply method's +ACTUAL field mutations against RuntimeEntityRecord.cs's own method bodies +before assigning masks (not guessed): ObjDesc/Movement(both branches)/ +State/Vector move NONE of the four tracked fields (their own +AdvanceXxxAuthority methods only bump ObjDescAuthorityVersion/ +MovementAuthorityVersion+MovementCommitVersion/StateAuthorityVersion+ +PhysicsStateMutationVersion/VectorAuthorityVersion+VelocityAuthorityVersion +respectively - VelocityAuthorityVersion is NOT one of the four tracked +fields) - AdvanceExecutorBaseline calls REMOVED entirely at these 4 sites. +Position/Parent/CreateParent (applied branch) move PositionAuthorityVersion +only. Pickup moves PositionAuthorityVersion + FullCellId (SetFullCell(0,0)). +WeenieDescription's AdvanceCreateAuthority moves PositionAuthorityVersion + +CreateIntegrationVersion (confirmed against its exact body). The pre- +Complete pending-placement-window rebaseline in ExecuteCore now masks +FullCellId + PlacementCommitVersion only (the sole legitimate between-calls +mutation, RuntimeSetPositionState's own commit machinery). R4-5's NEW +Parent-discard branch (ApplyParentPositionTimestampOnly/ +ApplyCreateParentPositionTimestampOnly) correctly calls NO +AdvanceExecutorBaseline at all - traced that ApplyPositionTimestampOnly +(the shared static body both go through) only writes PositionSequence/ +nested Physics.Timestamps.Position, never any AdvanceXxxAuthority method. +Test: FieldMaskedBaselinePrecisionDetectsAnExternalPositionRaceDuringAnUnrelatedObjDescPublish +(FIFO = ObjDesc then Vector on a parented/no-placement residence; an +observer bumps PositionAuthorityVersion externally during ObjDesc's OWN +publish; asserts the drain detects this at ConsumeExecuted - RejectedAuthority, +not Released - proving ObjDesc's own apply correctly did NOT blanket-rebaseline +and silently absorb the race). + +### R4-5 (stale-parent DISCARD, not re-Enqueue) + R4-6 (trace enums). FIXED. +Added RuntimeParentRelationOutcome{Applied, DiscardedStaleParent} and +RuntimeDeferredChildReplayOutcome{Registered, ReDeferred, Rejected}, both +threaded onto RuntimeInitialCreateExecutedAction (renamed the old +`bool DeferredChildRegistered` field to `RuntimeDeferredChildReplayOutcome? +DeferredChildOutcome`, added a new `RuntimeParentRelationOutcome? +ParentRelationOutcome` field). ApplyParentContinuation's stale-parent +branch (standalone Parent) no longer calls ParentAttachments.Enqueue - +instead calls the NEW ApplyParentPositionTimestampOnly (the SAME +ApplyAcceptedParentSnapshot->ApplyPositionTimestampOnly merge body +ApplyParentAction's own first step already used, but stops there - no +AdvancePositionAuthority/LeaveWorld/Forget/publish) and traces +DiscardedStaleParent. Added the ANALOGOUS revalidation to the envelope's +CreateParent stage too (this never existed before at all - Round 3 B9 only +touched the standalone Parent kind, explicitly noting the envelope path +had no revalidation) via a new ApplyCreateParentContinuation wrapper + +ApplyCreateParentPositionTimestampOnly helper; CreateParentUpdate carries +no ParentInstanceSequence at all (confirmed from its own record definition +and the TryApplyCreateParent doc comment: "unlike standalone ParentEvent it +carries no parent INSTANCE_TS"), so only addressability is revalidated +there, never incarnation match. UPDATED the existing Round 3 B9 test +(renamed ParentContinuationRevalidatesLiveParentAtExecutionAndReDefersOnMismatch +-> ...AndDiscardsOnMismatch): asserts ParentRelationOutcome.DiscardedStaleParent +in the trace and UnresolvedRelationCount == 0 (was asserting 1, i.e. the +OLD re-defer semantics) - this was the ONE pre-existing test that broke +after the R4-5 rewrite, exactly as expected, and was updated per the +directive's explicit instruction. + +### R4-7 (test-only: wire-IsGrounded-vs-body-contact). FIXED. +Parameterized the PositionUpdate test helper with `isGrounded = true` +(default preserves every existing call site's behavior). Deleted the 3 +stale `ForceContact(canonical, inContact: true)` calls + their misleading +"matching this test's ... premise" comments at LocalOrdinaryPosition.../ +RemoteNearContactPosition.../RemoteFarPosition... - none of them affect +routing anymore since Round 3 A3 (HasContact reads ONLY wire IsGrounded). +Added 2 new disagree-both-ways tests: +LocalOrdinaryPositionRouteFollowsWireGroundedTrueWhenBodyContactIsFalse +(wire true / body forced false -> Interpolate, i.e. route follows wire) and +RemotePositionRouteFollowsWireGroundedFalseWhenBodyContactIsTrue (wire +false / body forced true -> NoPositionOperation). Kept the ForceContact +helper itself (still used by these 2 new tests to construct the +disagreement). + +### R4-8 (D-matrix trace-flag assertions). FIXED. +Added the missing ConstrainPhase/UnparentBeforeRouting/HookPhase/ +ZeroVelocity/StopInterpolating assertions to the 5 existing D-matrix tests +(local ordinary, local teleport, remote near, remote far, projectile) per +each route's own classified values (cross-checked against +RuntimeAuthoritativePositionRouteClassifier's exact returned route structs, +not guessed). + +### R4-9 (two missing mandated tests). FIXED. +(a) ThirdPartyTryGetTransactionRetireWhileAwaitingContinuationPlacementDiscardsExecutorProgressAndForgetsThePendingPlacement: +drives a local-teleport continuation to AwaitingContinuationPlacement +(placement begun+watched, NOT yet acknowledged), externally bumps +PositionAuthorityVersion, then calls +lifetime.InitialCreateResidences.TryGetTransaction(canonical, out _) +directly (the third-party path, not through Execute) - asserts it returns +false (staleness detected), then asserts executor progress/residence-lease/ +active-operation/watch/acknowledged-completion counts are ALL zero (B3's +notification edge correctly forgot the pending continuation placement, not +just the residence's own initial-lease placement) and a fresh placement +can begin. (b) is the ExternalFullCellMutationDuringAwaitingContinuationPlacement... +test already covered under R4-2 above. + +### R4-10 (captured-key threading through every Abandon call site). FIXED. +Threaded `RuntimeEntityKey key` as an explicit parameter through +ApplyContinuation/ApplyParentContinuation/ApplyEnvelope/ApplyPositionAction/ +ResumePendingPlacement (all captured ONCE in ExecuteCore from +`canonical.Key is not { } key` at the very top). Replaced all 19 +`canonical.Key ?? default` occurrences (18 Abandon call sites + the +ExecuteCore Released-branch receipt construction) with the threaded `key`. +This is not purely cosmetic: `canonical.Key ?? default` re-derives the key +from the LIVE record at each call site, which produces `default` (WRONG - +does not match the Progress dictionary's actual key) if canonical.Key has +already gone null (e.g. LocalEntityId released) by the time Abandon runs - +DiscardProgress(default) would silently fail to clean up the REAL stale +Progress entry. The threaded key is trusted/stable for the whole Execute +call. ApplyPositionAction's own top guard (`canonical.Key is not {} key`) +was simplified to `canonical.Key != key` since key is now a parameter, not +a fresh pattern-bind. + +### R4-11 (ApplyStateAction BecameHidden currency-failure now returns +### false, not true). FIXED. +Changed `return true;` to `return false;` in the BecameHidden branch's +currency-failure check - the EXISTING caller (`if (!ApplyStateAction(...)) +return Abandon(...)`) already converts false -> Abandon correctly, so no +other change was needed. Documented via a new doc comment on the method +explaining WHY this specific path is not independently unit-tested with a +live reentrancy seam: traced RuntimeCollisionReportingState.LeaveWorld -> +ForceEnd -> EndExpiredObjectCollisions and confirmed it returns immediately +whenever `_owners` has no established collision record for this key (line +~1115-1119) - which is ALWAYS true for a residence-fresh entity that has +never run a real collision batch, so no observer callback can ever fire +from this call site through this harness. Did not fake a seam; documented +per the round's explicit escape valve for this exact finding. + +### R4-12 (ForcePosition parent-retention doc + structural test pin). FIXED. +Added a code comment at InboundPhysicsStateController.ApplyAcceptedPosition's +`parentGuid` computation citing retail Gate A (retail-notes.md function 3, +"GATE A: local-player force-position self-echo shortcut" - the early return +before CPhysicsObj::unset_parent). Extended the EXISTING ForcePosition test +(ForcePositionContinuationRecordsSetPositionSimpleWithPreservedHeadingAndNoParentClear): +attaches a parent via lifetime.Entities.TryCommitParent BEFORE the +ForcePosition update (gate-satisfying positionSequence match verified +against Spawn()'s own default), then asserts BOTH Position and +ParentGuid/ParentLocation are non-null after the drain - pinning the +deliberate combined shape deliberately, per the directive. + +### R4-13 (HasAnimations Physics?.MotionTableId fallback). FIXED. +`hasAnimations` in ApplyPositionAction now reads +`(canonical.Snapshot.MotionTableId ?? canonical.Snapshot.Physics?.MotionTableId) +is {} m && m != 0u` - exact pinned formula. No dedicated new test (NOTE +priority per the directive); existing HasAnimations-adjacent tests +(ForcePosition non-animated route etc.) continue to pass unchanged since +top-level MotionTableId is populated in every existing test fixture. + +### R4-14 (AwaitingContinuationPlacement doc comment). FIXED. +Added a doc comment on the enum value explaining the two distinct flavors +sharing this one status (ordinary token-available case vs Round 3 B1's +operation-slot-contention case where TryGetPendingContinuationPlacement +returns false) and the correct caller action for each. + +### R4-15 (register-rows-draft.md rewrite). FIXED - scratchpad-only, no +### docs/ edits. +Row A: dropped the MoveOrTeleport co-anchor (confirmed retail-notes.md +never decompiled MoveOrTeleport's own internals; the ONLY confirmed +HasAnims call site is inside HandleReceivedPosition itself, line ~92992), +reworded "running cycle" -> "non-empty animation queue (anim_list.head_ != +0)", updated the divergence formula text to match R4-13's new fallback. +Row C: broadened from "the 3 no-placement routes" to "NO route runs a live +ConstrainTo, including SetPosition routes" per the directive, citing the +3 ConstrainTo call sites at retail-notes.md lines ~93007/~93024/~93041. +Row D: rewrote across the THREE ApplyResidentCellCleanup branches - +claimed+celless+undeferred is now correctly described as a typed +ABANDONMENT (not "a recorded fact" - that was wrong even before Round 4, +since Round 3 B1 already changed this to Abandon; the register draft just +hadn't caught up), the deferred flavor correctly delegates retail's +AddObjectToBeDestroyed (93933) to the lost-cell/deferred owners, and the +claimedCell==0 branch is explicitly marked NOT a divergence (structural +shape guarantee, nothing to diverge from). Row E: softened the "no retail +per-step notice" claim - retail DOES emit exactly ONE notice +(ECM_Physics::SendNotice_CreateObject, ACCObjectMaint::CreateObject +0x00558870 step 11) per Create, just not N per-internal-step; kept the +consumer-facing risk warning unchanged. Row F: REMOVED from the register +draft entirely (internal refactor debt between two not-yet-unified +Position-apply paths, not a retail divergence) - replaced with (a) a code +comment on InboundPhysicsStateController.TryApplyPosition's doc comment and +(b) a new "ISSUES draft" section in the scratchpad file for eventual +docs/ISSUES.md inclusion. Added Row G (new): the executor's canonical-cell +semantics (refreshPosition: false), previously only a code comment, now +also a proper register row citing the classifier comment + retail +HandleReceivedPosition-never-writes-a-resident-cell fact. + +## ROUND 4 FINAL GATES: see final report for exact totals. + +--- + +# CUTOVER SLICE C0 — Runtime bridge + live inputs (new implementer session) + +Worked from c0-contract.md (pinned), 2026-08-02-placement-cutover.md, +2026-08-02-cutover-route-inventory.md, 2026-08-02-runtime-continuation- +executor-handoff.md. HEAD at start 27e05b99. + +## Discovery that changed my design: the observer/sink pipe already exists +Before designing C0-1, grepped for `IRuntimePlacementObserver`/ +`IRuntimePlacementProjectionSink` production usage - found +`RuntimePlacementProjectionSubscription` (Runtime-owned, +`IRuntimePlacementObserver`), `RuntimePlacementPresentationSink` (App), +`HeadlessRuntimePlacementProjectionSink` (Headless) ALL already exist and +are production-wired (`GraphicalSessionEventRoute`/`HeadlessSessionHost`). +The cutover-route-inventory.md's "zero production IRuntimePlacementObserver" +claim is stale - superseded by a slice landed after that doc. What's still +genuinely dormant: nothing ever PUBLISHES into the channel in production, +because `Execute`/`RegisterEntityWithInitialResidence` still have zero +production callers. This meant C0-1 could NOT touch App/Headless sink +implementations (hard rule anyway) - the new `ExecutorCompleted` Kind will +sit unhandled by those sinks (return false) until a LATER cutover slice +updates them, but since Execute has no production caller today this never +fires in production. Documented this explicitly in code comments. + +## C0-1: executor → placement-channel completion bridge. DONE. +Design (pinned contract's suggested shape, exactly): extended the +vocabulary, not the plumbing. New `RuntimePlacementProjectionKind. +ExecutorCompleted` (append-only, no exhaustive-switch breaks found anywhere +in src/tests via grep). New `RuntimeSetPositionState.PublishExecutorCompletion +(record, portal=default)`: builds a FRESH token via the SAME +`_nextProjectionSequence` counter every other publish uses (preserves +temporal/exact-head ordering), but derived from the CANONICAL RECORD's +current authority/version/cell facts (`_entities.SessionLifetimeVersion`, +`record.FullCellId`, `_physics.ExpectedCollisionGeneration(record.FullCellId)`) +rather than an Operation snapshot - correct because by the time Execute +reaches `Released`, the residence's/continuation's own operation is already +gone (adopted/acknowledged earlier in the SAME drain). `AcknowledgeProjection` +gained one extra Kind in its existing `Discard`-only fast path +(`Discard or ExecutorCompleted` -> remove + retire quiescence, no Operation +lookup) - there is no operation backing an ExecutorCompleted receipt, exactly +like Discard. +Correlation (the "reachable from/correlated with" ask): did NOT put the rich +internal `RuntimeInitialCreateExecutionReceipt` on the PUBLIC +`RuntimePlacementProjectionSnapshot` (would need public exposure of an +entire internal enum/struct family, or risk CS0053 inconsistent-accessibility +if done wrong) - instead the executor keeps a private +`Dictionary` overwritten +per-key on each completion (bounded by live entity count, never +accumulates), queried via `TryGetCompletionReceipt(in RuntimePlacementProjectionToken)` +which verifies BOTH Entity and Sequence match before returning true - a +host/test correlates purely through the PUBLIC token identity every other +Kind already uses. The executor calls `PublishExecutorCompletion` exactly +once, at `ExecuteCore`'s `Released` exit (canonical still provably current +there). +Tests: `PublishExecutorCompletion_PublishesAcknowledgeOnlyReceiptAndConverges`, +`PublishExecutorCompletion_RespectsExactHeadOrderingAcrossEntities` +(RuntimeSetPositionStateTests.cs, isolated unit level); +`ExecutorCompletion_PublishesOnTheSamePlacementStreamCorrelatedWithTheFullReceipt`, +`ExecutorCompletion_ObservedOnlyAfterAnyContinuationPlacementInFifoOrder` +(RuntimeInitialCreateContinuationExecutorTests.cs, full Execute-drain +integration, the second proving continuation-Place-then-ExecutorCompleted +FIFO ordering). + +## C0-2: Runtime-side live inputs. DONE. +UsePositionFromServer: grepped named-retail decomp for +`CommandInterpreter::UsePositionFromServer`/`SetAutonomyLevel`/ +`autonomy_level` - found `result = this->autonomy_level != 2` (pseudo-C +699510), default `autonomy_level = 2` at construction (699752) AND at +`command_line_autonomy_level` (1088429, itself `0x2` by default) - autonomy +is a STARTUP/command-line-only knob in retail; no in-game caller of +SetAutonomyLevel exists anywhere in the decomp. Added the exact mirror to +`RuntimeCharacterState` (the "character-option owner" the contract named): +`FullAutonomyLevel=2u` const, `AutonomyLevel` (Volatile-read uint, +default 2), `UsePositionFromServer => AutonomyLevel != FullAutonomyLevel`, +`TrySetAutonomyLevel(level)` (rejects >2, exact retail rule). Reset in both +`ResetSession`/`Dispose`; added `AutonomyIsDefault` to +`RuntimeCharacterOwnershipSnapshot`/`IsConverged`. +PlayerDistance: grepped `LiveEntityNetworkUpdateController.cs` (App, +read-only) for the legacy remote path's own distance basis (cutover-routes.md +route 4) - confirmed `Vector3.Distance(worldPos, localPlayerPos)` where +`localPlayerPos = _playerController?.Position ?? Vector3.Zero` (the live +PHYSICS-CONTROLLER position, never a record snapshot). Bound source: +`RuntimeLocalPlayerMovementState.Controller?.Position ?? Vector3.Zero` - +same `PlayerMovementController` type. +Executor: `RuntimeInitialCreateContinuationExecutor.BindLiveInputs(Func, +Func)` (nullable seams, throws on double-bind matching +`BindGeneration`'s convention), `ResolveInputs(canonical, inputs)` computes +the EFFECTIVE `RuntimeInitialCreateExecutionInputs` ONCE per `Execute` call +(bound source wins; unbound falls back to the caller struct field-by-field) - +PlayerDistance uses THIS entity's own currently-accepted position +(`Snapshot.Physics?.Position ?? Snapshot.Position`, the same field +`CanonicalSetupTableId`-adjacent code already trusts) vs the bound live +player position. Documented the one-shot-per-Execute-call granularity as +inherited from the EXISTING `inputs` parameter shape, not a new limitation +I introduced - out of C0-2's scope to refine to per-continuation freshness. +`RuntimeEntityObjectLifetime.BindLiveInputs` forwards to the executor +(mirrors `BindEventContext`'s existing fan-out shape). `GameRuntime.cs` wires +the REAL sources right after `BindEventContext`, since `RuntimeCharacterState`/ +`RuntimeLocalPlayerMovementState` are constructed AFTER `RuntimeEntityObjectLifetime` +in `GameRuntime`'s own sequence (verified exact construction order first). +Tests: `RuntimeCharacterStateTests.cs` (`AutonomyLevel_DefaultsToFullAndMirrorsRetailUsePositionFromServer`, +`ResetSession_RestoresAutonomyLevelToFull`); +`RuntimeInitialCreateContinuationExecutorTests.cs` +(`BindLiveInputs_DrivesClassificationFromTheBoundSourcesInsteadOfTheCallerStruct` +- proves bound-wins AND live-read-not-cached-at-bind-time by flipping the +captured bool between two entities' drains; +`BindLiveInputs_ThrowsOnASecondBindAndUnboundExecutorsUseTheCallerStructUnchanged`). + +## C0-3: exact-Setup mover chain end-to-end. DONE. +New `RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement(record, +token, operationKind, flags, IPreparedCollisionSource, gameTime, out outcome, +placementClass=Ordinary, portal=default, ...scatter/shadow-offset params)`: +reads the CANONICAL Setup table id via the EXISTING private +`CanonicalSetupTableId(record)` (same field `CapturePreparationAuthority` +already trusts - never a caller-supplied id), takes the retail "genuine no +Setup" dummy path (`RuntimeSetPositionMoverSetup.ResolvedAbsent`) when that id +is 0, else calls `collisionSource.ReadSetupCollision(setupTableId)` and maps +Missing/Corrupt -> `RetrySetupUnavailable` (per `RuntimeSetPositionMoverSetup`'s +own doc-comment distinction between "not arrived yet" and "resolved absent" - +never manufactures a fallback while a real read is in flight) or Loaded -> +`RuntimeSetPositionMoverSetup.Resolved(id, data)`, then chains straight into +the EXISTING `PrepareMover` -> `SubmitPreparedPlacement`. Pure wiring - zero +changes to `PrepareMover`/`RuntimeSetPositionMoverPreparer.TryBuild`/ +`SubmitPreparedPlacement`'s own validation semantics (per the contract's +explicit "wiring, not behavior change" constraint) - confirmed by re-reading +both untouched. +Tests (RuntimeSetPositionStateTests.cs, new `FakeCollisionSource : +IPreparedCollisionSource` test double, only `ReadSetupCollision` implemented +- others throw `NotSupportedException` since C0-3 exercises only that one): +`TryPrepareAndSubmitAuthoredPlacement_ChainsSetupReadThroughPrepareMoverToSubmit` +(authored two-sphere Setup reaches `SubmitPreparedPlacement` and +`TryGetPreparedMoverSphereCount` byte-exactly == 2, matching the existing +preparer tests' own expectations) and +`TryPrepareAndSubmitAuthoredPlacement_YieldsRetryOnAMissingSetupReadWithoutMutatingStage` +(Missing status -> `RetrySetupUnavailable`, operation stays +`AwaitingPreparation`/`IsPlacementCurrent` true, no prepared-mover sphere +count recorded - genuinely retryable, not a dead token). + +## C0-4: TryCommitParent/CommitWithdrawal cancellation asymmetries. DONE, +## both confirmed at source exactly as the inventory claimed. +(a) `RuntimeEntityObjectLifetime.TryCommitParent` (944-974 pre-fix) had +NEITHER `ForgetInitialCreateResidence` NOR `Physics.SetPosition.Forget` - +confirmed by direct read, contrasted against the sibling +`CommitPositionChannelUpdate` (used by `TryApplyParent`/`TryApplyCreateParent`) +which has BOTH. Fixed: added the identical +`ForgetInitialCreateResidence` -> `Physics.SetPosition.Forget` -> +`PreferCancellation` -> pass to `AcknowledgeProjectionAndPublish` sequence. +Deliberately did NOT add `Physics.CollisionReports.LeaveWorld` (present in +`CommitPositionChannelUpdate` but outside the contract's explicit +"residence/placement-family cancellation" scope, and I have no retail +citation that a STAGED parent-attach commit should also force a collision +leave-world at this exact point) - flagging this as a considered, deliberate +non-addition rather than an oversight. +(b) `CommitWithdrawal` (1401-1422 pre-fix) called `ForgetInitialCreateResidence` +but not `Physics.SetPosition.Forget` - confirmed by direct read, contrasted +against `TryApplyPickup`/`CommitAcceptedParentCellless`/`TryAcceptDelete` +which all cancel both. Fixed symmetrically (added the ordinary Forget + +PreferCancellation into the existing `cancellation` variable already threaded +to `AcknowledgeProjectionAndPublish`). +Tests, three total, each begins an ACTUAL in-flight SetPosition operation +that has already reached `SubmitPreparedPlacement`'s pending-Place stage (a +still-`AwaitingPreparation`, never-submitted operation produces NO Discard +receipt at all when cancelled - `CancelCoreDeferred` only converts an +EXISTING pending projection into a Discard; there is nothing to discard if +nothing was ever published - this cost one debugging round, see below): +`RuntimeInitialCreateResidenceStateTests. +TryCommitParent_CancelsActiveInitialResidenceAndItsPendingPlacement` +(residence's OWN placement, still unacknowledged, is the thing cancelled - +both Forget calls fire but only one finds anything, `PreferCancellation` +picks it, exactly one Discard observed); `RuntimeSetPositionStateTests. +TryCommitParent_CancelsASeparateActiveOrdinaryPendingPlacement` and +`CommitWithdrawal_CancelsAnActiveOrdinaryPendingPlacementSymmetricallyWithPickup` +(plain `RegisterEntity`, no residence at all - isolates the SECOND, +previously-missing Forget call specifically). All three assert the Discard +sits at the SAME sequence as the original Place (Revision bumped), then +explicitly acknowledge it and assert `PendingProjectionCount == 0` - a +cancelled-but-unacknowledged receipt stays IN the pending set (replaced, not +removed) until a host consumes it, same as every other in-flight-cancel path +in this codebase. + +## Debugging round (all 4 caught by the focused-filter run, all root-caused +## and fixed, not worked around): +1. Three C0-4 tests initially asserted a Discard would be published from + cancelling a placement operation still in `AwaitingPreparation` + (never submitted) - traced `CancelCoreDeferred` and confirmed it only + converts an EXISTING `_pendingProjection` entry to Discard + (`operation.ProjectionSequence != 0UL` gate); an unpublished operation + just disappears from `_operations` with no receipt, which is CORRECT + (nothing was ever promised to a host). Fixed the TESTS to reach + `SubmitPreparedPlacement`'s pending-ack stage first, not the production + code. +2. Two of those same tests then asserted `PendingProjectionCount == 0` + immediately after cancellation - wrong; a Discard REPLACES the pending + entry at the same sequence (Revision+1), it does not remove it. Fixed the + assertions to expect 1, then explicitly acknowledge, then expect 0. +3. `TryCommitParent_CancelsActiveInitialResidenceAndItsPendingPlacement`'s + `Prepare(..., RuntimeSetPositionMoverSetup.ResolvedAbsent)` failed with + `InvalidData` because `Spawn(guid, 1)` in that file defaults + `setupId: 0x02000001u` (nonzero), mismatching `ResolvedAbsent`'s claimed + "no Setup at all". Fixed by passing `setupId: null` explicitly (matching + the file's OWN existing convention for this exact scenario, e.g. + `ResetSnapshotsAllResidenceOwnersBeforeReentrantDiscardObserver`). +4. `BindLiveInputs_DrivesClassificationFromTheBoundSourcesInsteadOfTheCallerStruct` + failed `AcknowledgeProjection` on a SECOND entity's placement - not exact + head. Root cause: the FIRST entity's full drain published its own + `ExecutorCompleted` receipt (C0-1) which I never acknowledged before + moving on to the second entity - this is CORRECT exact-head behavior + (great incidental proof C0-1's ordering guarantee holds), not a bug. + Fixed the test to peek+acknowledge the first completion before + proceeding. + +## Final gates (all green) +1. `dotnet build AcDream.slnx -c Release`: 0 errors, 21 warnings (all + pre-existing, identical set to the executor handoff's baseline - zero + new warnings from this slice). +2. Focused filter (RuntimeInitialCreateContinuationExecutorTests| + RuntimeInitialCreateResidenceStateTests|RuntimeSetPositionStateTests| + RuntimePlacementProjectionSubscriptionTests|RuntimeCharacterStateTests| + RuntimeEntityObjectLifetimeTests): 247/247 passed. +3. Complete `AcDream.Runtime.Tests`: 916/916 passed (903 baseline + 13 new: + 2 C0-1 unit + 2 C0-1 integration + 2 C0-2 executor + 2 C0-2 character-state + + 2 C0-3 + 1 C0-4 residence + 2 C0-4 set-position-state). +4. Complete solution (`dotnet test AcDream.slnx -c Release -m:1`): every + project passed - App 4027/3 skip, Bake 15, Cli 4, Content 124, Core.Net + 762, Core 4242/1 skip, Headless 76, Runtime 916, UI.Abstractions 543. + 0 failed anywhere. +5. `git diff --check`: clean (only pre-existing LF-will-become-CRLF + metadata notices, no whitespace-error content). +6. `git status`: exactly the 9 files this slice touched, plus the 8 + pre-existing protected dirty paths untouched (never staged/committed). + +## Files changed (Runtime + Runtime.Tests only, no App/Headless production, +## no staging/commits) +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs (C0-1 Kind+publish+ + ack branch; C0-3 chain method; +`using AcDream.Content;`) +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + (C0-1 completion-receipt correlation + publish call site; C0-2 + BindLiveInputs/ResolveInputs) +- src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs (C0-2 + BindLiveInputs forwarder; C0-4 TryCommitParent/CommitWithdrawal fixes; + +`using System.Numerics;`) +- src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs (C0-2 autonomy + level/UsePositionFromServer) +- src/AcDream.Runtime/GameRuntime.cs (C0-2 wiring the real sources; + +`using System.Numerics;`) +- tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs (C0-1, + C0-3, C0-4 tests + FakeCollisionSource; +`using AcDream.Content;`/ + `AcDream.Content.Pak;`) +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + (C0-1, C0-2 tests + PlacementObserver fake) +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs + (C0-4 residence-side test) +- tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs (C0-2 + autonomy tests) + +## Still dormant / no production caller flipped (per pinned scope) +Nothing in App/Headless was touched; `Execute`/`RegisterEntityWithInitialResidence` +still have zero production callers (unchanged from the executor handoff). +`RuntimePlacementPresentationSink`/`HeadlessRuntimePlacementProjectionSink` +will need an `ExecutorCompleted`-handling branch added when a LATER cutover +slice (C1+) actually starts calling `Execute` in production - flagging this +explicitly as the next slice's concern, not a gap in C0. + +**SUPERSEDED by the review fix round below**: the App/Headless sink +untouched-claim above no longer holds - F1 sanctioned a scoped exception +(exactly 3 sink files). See the fix-round section for the full disposition. + +--- + +# C0 REVIEW FIX ROUND (F1-F5) + +Both independent reviews returned FAIL with converging findings. Per the +coordinator: all five retail semantic questions verified CLEAN (autonomy_level +!= 2 derivation exact; distance basis matches retail + legacy path fallback; +mover chain preserves prerequisite-B exactness; ExecutorCompleted +unambiguously acknowledge-only; the LeaveWorld omission in TryCommitParent is +REQUIRED per retail set_parent 0x00515A90:283832-283833's single gated +leave_world - a second one would double-leave-world with no retail +counterpart). + +## F1 (MAJOR, arch) - sink acknowledge-and-ignore. FIXED. SCOPE EXPANSION +## SANCTIONED for exactly 3 files, no route flips, no other App/Headless changes. +Root cause: `HeadlessRuntimePlacementProjectionSink.cs:51` (`is not Place -> +false`), `RuntimePlacementPresentationSink.cs:89-96` (`_ -> false`), +`LiveEntityRuntime.cs:969-976` (`_ -> false`) all silently reject +ExecutorCompleted, and `RuntimePlacementProjectionSubscription` treats a +false return on the FIFO head as "leave pending" - the first ExecutorCompleted +reaching a production sink (at a future cutover slice) would permanently wedge +the entire ordered placement stream behind it. Fixed all three: added an +explicit `if (Kind is Discard or ExecutorCompleted) return true;`-shaped +early return BEFORE each file's record-lookup/portal-shape gate (never +letting ExecutorCompleted depend on a lookup that can legitimately fail for +unrelated reasons). Provably inert today - `PublishExecutorCompletion` has +zero production callers (`Execute`/`RegisterEntityWithInitialResidence` are +both unreached) - documented in both the code comments and the new tests. +Tests: `RuntimePlacementPresentationSinkTests.ExecutorCompleted_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone` +(App, mirrors the existing Discard test exactly, proves ack regardless of a +completely bogus/stale token) and +`HeadlessSessionHostTests.ExecutorCompletedReceiptIsAcknowledgeOnlyRegardlessOfRecordValidity` +(Headless, mirrors `PlacementReceiptUsesExactIncarnationAndDiscardIsAckOnly`'s +stale-incarnation half). + +## F2 (MAJOR, both reviewers) - completion-receipt lifecycle. FIXED. +Four sub-fixes, all landed: +1. **Register-before-publish**: `PublishExecutorCompletion` gained a + `beforePublish: Action?` callback invoked + AFTER the token is added to `_pendingProjection` but BEFORE + `PublishPlacement`'s synchronous observer dispatch. The executor's + `ExecuteCore` Released case now registers `_completionReceipts[key]` + inside that callback (capturing `receipt` via a local `completedReceipt` + - an `out` parameter cannot be captured by a lambda) - a subscriber + reading the correlation back from inside its OWN `OnPlacement` now always + finds it. +2. **Ack-driven removal**: new `RuntimeSetPositionState.BindExecutorCompletionAcknowledgement(Action)` + (mirrors `RuntimeInitialCreateResidenceState.BindRetirementNotification`'s + existing one-bound-delegate shape), invoked from `AcknowledgeProjection`'s + ExecutorCompleted branch the moment a host acknowledges. Bound in all 3 + `RuntimeEntityObjectLifetime` constructors to + `InitialCreateExecution.ForgetCompletionReceipt(key, sequence)` - a new + executor method that removes exactly the matching (key, sequence) entry + (exact-sequence-checked, so a NEWER completion under a reused key survives). +3. **DiscardProgress/DiscardAll**: both now unconditionally reap + `_completionReceipts` (Remove/Clear respectively) - DiscardProgress + removes it EVEN WHEN `_progress` no longer tracks the key (the drain + already removed its own Progress entry before publishing the completion), + proven by a dedicated test. +4. **Ownership/convergence**: added `PendingCompletionReceiptCount` to the + executor and folded it into + `RuntimeEntityObjectOwnershipSnapshot`/`IsConverged` (appended as the last + positional field with a `= 0` default, following this record's own + established extension convention) - chosen semantics: non-zero while + unacknowledged, zero exactly at acknowledge, mirroring + `RuntimeSetPositionOwnershipSnapshot.PendingProjectionAcknowledgementCount`'s + existing "unacknowledged receipt is outstanding debt, gated by + IsConverged" shape (documented as such, in contrast with the adjacent + diagnostic-only `ReplayFailureCount`). +Tests (`RuntimeInitialCreateContinuationExecutorTests.cs`): +`ExecutorCompletion_ReceiptIsReadableFromWithinTheSameSynchronousOnPlacementDispatch`, +`ExecutorCompletion_ConvergenceLedgerCountsAnUnacknowledgedReceiptAsOutstandingDebtUntilAcknowledged`, +`ExecutorCompletion_CorrelationEntryIsReapedByDiscardProgress`, +`ExecutorCompletion_CorrelationEntryIsReapedByDiscardAll` (the latter two +split into single-entity tests after discovering combining them with a +second entity in the SAME lifetime hit exact-head contention - see debugging +notes below). + +## F3 (MAJOR, arch) - nullable local-player-position fallback. FIXED. +`GameRuntime.cs:280`'s `context.Movement.Controller?.Position ?? Vector3.Zero` +fabricated a distance basis of literal (0,0,0) whenever the login-window +drain ran before the local player's own controller existed - a nearby remote +entity could misclassify as >96m and hard-snap where retail would +interpolate. Fixed per the contract's own fallback rule: `_localPlayerPosition` +is now `Func?` (was `Func?`), `BindLiveInputs`'s parameter +type updated to match, `ResolveInputs` now does +`_localPlayerPosition?.Invoke() is { } localPlayerPosition` (both "unbound" +AND "bound-but-returns-null" fall back to the caller struct's PlayerDistance +identically), and `GameRuntime.cs` now binds +`() => context.Movement.Controller?.Position` directly (a `PlayerMovementController?.Position` +already yields `Vector3?` via null-conditional propagation - no `?? Vector3.Zero` +needed or wanted). Test (both directions, per the ask): +`BindLiveInputs_PlayerDistanceIsReadFromANonNullBoundSourceAndFallsBackToTheCallerStructWhenNull` - +entity 1 has a bound NON-null near position (proves the bound value, not the +caller struct's far value, wins -> Interpolate); entity 2 flips the SAME +bound source to null (proves it falls back to the caller struct's far value, +not Vector3.Zero -> SetPositionSimple/StopInterpolating). + +## F4 (MINOR) - TryCommitParent comment narrowed. FIXED. +Rewrote the C0-4(a) comment: no longer claims "the SAME flow every sibling +relation commit uses" wholesale (which would imply LeaveWorld too) - now +states the Forget/PreferCancellation sequence is shared for THIS part of the +job, and explicitly documents the deliberate LeaveWorld omission citing +retail `set_parent` (0x00515A90, lines 283832-283833)'s single gated +leave_world call, which this method's staged/deferred-replay commit already +represents - a second LeaveWorld here would double-leave-world with no +retail counterpart. + +## F5 (NOTEs). FIXED. +(a) `TrySetAutonomyLevel`'s doc comment now notes retail's setter ALSO sends +`SendAutonomyLevelEvent` (pseudo-C 699550) and that any FUTURE host exposure +of this setter must carry the equivalent outbound event, not just the field +write. +(b) `FullAutonomyLevel`'s doc comment corrected from "No in-game caller of +CommandInterpreter::SetAutonomyLevel exists" (implying zero callers anywhere) +to the precise claim: exactly ONE retail caller exists, the startup +construction path at pseudo-C 94102 (the constructor's own default at 699752 +is a direct field write, not a SetAutonomyLevel call, so it doesn't count as +a second caller). + +## Debugging round for the F2/F3 tests (both caught by the test run, both +## root-caused, not worked around) +1. `ExecutorCompletion_CorrelationEntryIsReapedByDiscardProgressAndDiscardAll` + (combined, single lifetime, two entities) failed at the SECOND entity's + `CompleteInitialPlacement` - root cause: the FIRST entity's + ExecutorCompleted receipt was left UNACKNOWLEDGED in `_pendingProjection` + (by design, to prove DiscardProgress reaps the correlation cache + independent of the normal ack path) - but that means it permanently sat + at the exact head, blocking ANY later entity's Place receipt from ever + being acknowledged (DiscardProgress only touches the correlation cache, + never `_pendingProjection` itself - a deliberate, narrow scope). Fixed by + splitting into two single-entity tests (`...ReapedByDiscardProgress`, + `...ReapedByDiscardAll`), each with its own fresh lifetime - eliminates + the exact-head contention entirely rather than working around it. +2. `BindLiveInputs_PlayerDistanceIsReadFromANonNullBoundSourceAndFallsBackToTheCallerStructWhenNull` + hit the SAME class of bug for the SAME reason (entity 1's completed drain + left an unacknowledged ExecutorCompleted blocking entity 2). Fixed by + inserting an explicit peek+acknowledge of entity 1's completion between + the two entities (matching the pattern already established in + `BindLiveInputs_DrivesClassificationFromTheBoundSourcesInsteadOfTheCallerStruct` + from the prior round). + +## Fix-round final gates (all green) +1. `dotnet build src/AcDream.Runtime/AcDream.Runtime.csproj -c Release`: 0/0. +2. `dotnet build tests/AcDream.Runtime.Tests -c Release`: 0/0. +3. Complete `AcDream.Runtime.Tests`: 921/921 passed (916 + 5 new: 4 F2 + 1 F3; + see above for exact names). +4. `dotnet build tests/AcDream.App.Tests -c Release`: 0 errors, 3 pre-existing + warnings (CS8767, unrelated to this change). +5. `dotnet build tests/AcDream.Headless.Tests -c Release`: 0/0. +6. `dotnet test tests/AcDream.App.Tests -c Release --no-build`: 4028/4028 + passed, 3 skips (4027 baseline + 1 new: the F1 App-sink test). +7. `dotnet test tests/AcDream.Headless.Tests -c Release --no-build`: 77/77 + passed (76 baseline + 1 new: the F1 Headless-sink test). +8. `dotnet build AcDream.slnx -c Release --no-incremental` (clean rebuild for + an authoritative count): 0 errors, EXACTLY 21 warnings (matching the + documented baseline precisely - zero new warnings across the whole fix + round, including the 3 sanctioned sink-file edits). +9. Complete solution (`dotnet test AcDream.slnx -c Release -m:1`): every + project green - App 4028/3 skip, Bake 15, Cli 4, Content 124, Core.Net + 762, Core 4242/1 skip, Headless 77, Runtime 921, UI.Abstractions 543. 0 + failed anywhere. +10. `git diff --check`: clean (only pre-existing LF/CRLF metadata notices; + confirmed by grepping the raw output for anything OTHER than that + pattern - zero matches). +11. `git status`: exactly the C0 file set plus 5 NEW files from this fix + round (the 3 sanctioned sink files + their 2 test files), plus the 8 + pre-existing protected dirty paths untouched. Nothing staged. + +## Files touched in THIS fix round (in addition to the C0 file set above) +- src/AcDream.App/World/LiveEntityRuntime.cs (F1) +- src/AcDream.App/World/RuntimePlacementPresentationSink.cs (F1) +- src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs (F1) +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs (F2: beforePublish + param + BindExecutorCompletionAcknowledgement + ack-branch notification) +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + (F2: ForgetCompletionReceipt/PendingCompletionReceiptCount/DiscardProgress+ + DiscardAll reap/register-before-publish call site; F3: nullable field/ + ResolveInputs) +- src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs (F2: ownership + snapshot field + CaptureOwnership wiring + acknowledgement binding; F3: + BindLiveInputs signature; F4: comment) +- src/AcDream.Runtime/GameRuntime.cs (F3: nullable binding call site) +- src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs (F5a/F5b: comments) +- tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs (F1 + test) +- tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs (F1 test) +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + (F2 x4 + F3 x1 tests) + +--- + +# C2 — placement allocation budget (new implementer session) + +Worktree: C:\Users\erikn\.codex\worktrees\af5e\acdream (branch codex/port-claude-agents) +HEAD verified at session start: 6460596b56cd72a2c6d96e757b33da879a805b6d + +## Baseline reproduction + +- `dotnet build AcDream.slnx -c Release` green, 21 pre-existing warnings in + unrelated files (not introduced by this session). +- Ran `WarmedImmediateCommitAllocationIsMeasuredBeforeProductionCutover` + (tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs:253). + Baseline via temporary forced-failure instrumentation (reverted before any + real edits; git diff was clean after revert): **2032 B/op**, stable across + 3 repeat runs. NOT 1880 as the stale research doc + (docs/research/2026-07-31-canonical-set-position.md lines ~326-333) + states -- the number drifted upward since C0 landed additional + bookkeeping (ExecutorCompleted receipt plumbing etc). This is already + within 16 bytes of tripping the 2048 cap on its own -- confirms urgency. + +## Bisection (temporary instrumentation, reverted before real edits) + +Added static accumulator fields + GC.GetAllocatedBytesForCurrentThread() +brackets around: Apply() -> BeginAcceptedPlacementCore (BEGIN) and +SubmitPreparedPlacementCore (SUBMIT); inside SubmitPreparedPlacementCore: +prefix-to-SetPosition-call (PREFIX), the `_physics.Engine.SetPosition(...)` +call itself (SETPOSITION), CommitCanonical (COMMIT), PublishProjection +(PUBLISH), tail/Outcome (TAIL); AcknowledgeProjection (ACK, wrapped core in +try/finally). + +Result (per-op, averaged over 1000 measured iterations, 64 warmups): +``` +TOTAL=2032 BEGIN=752 SUBMIT=1160 SETPOSITION=584 COMMIT=0 PUBLISH=208 ACK=120 +PREFIX=144 MID=0 TAIL=0 +``` +Outer brackets are self-consistent to the byte: BEGIN+SUBMIT+ACK = +752+1160+120 = 2032 = TOTAL exactly. The inner SUBMIT subdivision only sums +to 936 (144+584+0+208), leaving ~224 B/op I could not pin down further +within budget (possibly SortedDictionary rebalancing spillover attributed +oddly across adjacent brackets, possibly a measurement-granularity artifact +of many small brackets in one method -- the coarse brackets are trustworthy, +the finest subdivision is not). Decided not to chase further since the two +big, well-understood root causes below match the project's established +"pool the envelope, cache the delegate" pattern and account for the +majority of the budget. + +## Root causes identified (confirmed by direct code reading + the + bisection above) + +1. **BEGIN (752 B, `BeginAcceptedPlacementCore`)**: `new Operation { ... }` + (private sealed class Operation, ~25 properties incl. embedded + RuntimeSetPositionCommand / PhysicsSetPositionResult structs) allocated + FRESH on every accepted-placement call; the old Operation for the same + entity key is simply dropped (`_operations[key] = replacement;`) and + becomes garbage every single call. + +2. **SETPOSITION (584 B, two call sites: SubmitPreparedPlacementCore + ~line 2402 and RetryDeferred ~line 3536)**: + `report => _physics.HandleSetPositionCollisions(operation.Record, ..., + canonicalCommand.GameTime, ...)` is a closure capturing `this` + + `operation` (+ `canonicalCommand` at the first site) -- a fresh + compiler-generated display-class allocated on EVERY call. Confirmed + every field the closure reads is already reachable from `operation` + alone (`operation.Command.GameTime == canonicalCommand.GameTime` because + `operation.Command = canonicalCommand;` runs earlier in the same + method) -- the closure captures nothing that isn't already sitting on + `operation`. Fixable with ONE delegate cached for the RuntimeSetPositionState + instance's lifetime, reading the "current operation" off a small + reusable Stack pushed/popped around the SetPosition call + (defends against theoretical re-entrant/nested SetPosition calls inside + PhysicsEngine -- TransitionScratchArena's ActiveDepth/Capacity gate + implies nesting is possible at that layer, even though + HandleSetPositionCollisions itself never calls back into + RuntimeSetPositionState). + +## Plan +1. Fix the closure (lowest risk, no behavior change) -- both call sites. +2. Pool the Operation object (`_operationPool`, convert `required {get;init;}` + to settable + add a `Reset(...)`, return retired Operations to the pool + at every site that currently discards one for good). Audit every + `_operations.Remove(...)` / displacement site so nothing else still + holds the recycled instance (per "no workarounds": a pool that + resurrects stale state is worse than the allocation it replaces). +3. Re-measure; tighten the gate to the new number with justified headroom. + +## Fixes implemented + +1. Cached collision-report delegate (fixes the closure at both + `_physics.Engine.SetPosition` call sites: SubmitPreparedPlacementCore and + RetryDeferred). Added `CollisionCallbackContext` (readonly record struct) + plus `Stack _collisionCallbackContexts` plus + `Func + _handleSetPositionCollisionsCallback` (built ONCE in the constructor, + bound to instance method `HandleSetPositionCollisionsCallback` which + reads `_collisionCallbackContexts.Peek()`). Each call site now does + Push(context) / try { SetPosition(request, cached delegate) } / finally + { Pop() }. The stack (not a single field) defends nested/re-entrant + SetPosition calls at the PhysicsEngine layer. + +2. Operation pooling (fixes `new Operation` in + `BeginAcceptedPlacementCore`). Converted every `Operation` property from + `required ... { get; init; }` to plain `{ get; set; }`, added + `ResetAllFieldsToDefault()`, added `_operationPool` (`Stack`, + capped at 64), `RentOperation()` / `RetireOperationToPool(Operation)`. + `BeginAcceptedPlacementCore` now rents+field-sets instead of + `new Operation {...}`. Operations are retired at the 3 places an + Operation is permanently removed from `_operations`: both branches inside + `AcknowledgeProjection` Place/non-lost-cell paths, and inside + `CancelCoreDeferred` (the single removal chokepoint every `CancelCore` + overload funnels through). + + CRITICAL BUG FOUND AND FIXED during verification: the first pass reset + fields at RETIREMENT time (inside RetireOperationToPool). This broke + `ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation` - + `CommitCanonical` remote branch builds a `ContactCommitGuard` that + captures the live `Operation`, then invokes `remote.HitGround()` / + `LeaveGround()` - and retail lets that callback synchronously call BACK + INTO `BeginAcceptedPlacement` for the SAME entity, which displaces and + retires the very operation the guard is holding, mid-guard. + `guard.IsCurrent()` (`IsCanonicalPlacementCommitCurrent`) reads that + operation ORIGINAL PositionAuthorityVersion/SpatialAuthorityVersion AFTER + the callback returns - resetting those fields at retirement time zeroed + them out from under the still-executing outer frame, making an in-flight + valid commit look stale and silently dropping its shadow update (test + caught it: shadow.Position stayed at the pre-spawn 10,20,7 instead of the + committed 13,18,~6.95). Fix: move the reset from RetireOperationToPool to + RentOperation (reset happens the moment an instance is about to be + handed out for reuse, not the moment it is taken out of `_operations`). + A retired-but-not-yet-rented instance now keeps its true last-known field + values until something actually reuses it - any outer frame with a + captured reference gets a brief, safe, read-only window on stale-but- + correct data instead of zeroed garbage. Full 921/921 Runtime tests pass + after this fix (before the fix: 920/921, this exact test failing). + + Lesson worth carrying into memory: reentrant displaced-operation pooling + must reset at RENT time, never at RETIREMENT time, whenever a captured + reference (guard/closure/local) might still read the object fields after + retirement but before the next real use. + +3. Eliminated LINQ `.First()` boxing on `_pendingProjection` + (SortedDictionary<ulong, RuntimePlacementProjectionSnapshot>). Added + `FirstPendingProjection()` using a plain `foreach` (resolves to the + concrete struct-returning `GetEnumerator()`, not the boxing + `IEnumerable<T>` interface one that `Enumerable.First<T>` + forces). Replaced all 3 call sites (`TryPeekProjection`, + `AcknowledgeProjection` guard, `HasPendingProjectionThrough`). + +## Root cause: SortedDictionary Add allocations (about 208 B/op) and + AcDream.Core PhysicsEngine internals (about 520 B/op) - NOT fixed + +Confirmed via targeted diagnostic instrumentation (added, measured, fully +reverted before finalizing - git diff was clean after each revert): +- `_pendingProjection.Add(sequence, snapshot)` (PublishProjection) costs + about 208 B/op - SortedDictionary red-black tree node allocation, inherent + to the BCL type with no pooling hook. Replacing `_pendingProjection` data + structure to chase this would touch 10+ methods relying on its ordering + and FIFO-peek semantics (quiescence tracking, withdrawal acknowledgement, + etc.) - judged too invasive and risky for the remaining reward given the + result is already well under the 2048 cap. +- `_physics.Engine.SetPosition` (AcDream.Core/Physics/PhysicsEngine.cs) + costs about 520 B/op split as INIT=144 (InitializeSetPositionTransition), + INNER=344 (SetPositionInternal/scatter solve), FINAL=32 (the + `queryFootprint.OrderedIds.ToImmutableArray()` call - a genuine single- + element ImmutableArray materialization from the outdoor-adjustment query + footprint, not an artifact). RENT=0 (Transition pooling already zero- + alloc). This lives entirely inside AcDream.Core, shared physics + infrastructure used far beyond RuntimeSetPositionState - out of this + slice Runtime-only hard-rule scope, and not touched. +- Confirmed `HandleSetPositionCollisionReports` / + `RuntimeCollisionReportingState.HandleReports` (Runtime-side) do NOT + allocate in the test steady state (no collisions ever occur - + collidedObjectIds stays empty, no OwnerState ever gets created for this + entity) - ruled out as a contributor. + +## Final verification + +- `dotnet build AcDream.slnx -c Release`: 0 errors, 21 pre-existing warnings + (all in files untouched by this session - confirmed identical to the + pre-session baseline build). +- `WarmedImmediateCommitAllocationIsMeasuredBeforeProductionCutover`: passes + at the new `Assert.InRange(allocated / iterations, 1L, 1_536L)` gate. + Measured value stable at exactly 944 B/op across 5+ repeat runs (down + from 2032 B/op baseline, 53.5% reduction). 1,536 keeps about 60% headroom. +- Complete `AcDream.Runtime.Tests`: 921/921 pass (0 skips). +- Complete solution (`dotnet test AcDream.slnx -c Release -m:1`): every + project green - App 4028/3 skip, Bake 15, Cli 4, Content 124, Core.Net + 762, Core 4242/1 skip, Headless 77, Runtime 921, UI.Abstractions 543. + 10,716 total, 0 failed, 4 skipped (all pre-existing skips). +- `git diff --check`: clean (only the same pre-existing LF/CRLF metadata + notices on the 8 protected dirty files from before this session; grepped + for anything else - zero matches). +- `git status`: exactly 2 files changed by this session + (src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs, + tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs) plus + the 8 pre-existing protected dirty paths, untouched, exactly as they were + at session start. Nothing staged, nothing committed, HEAD unchanged at + 6460596b56cd72a2c6d96e757b33da879a805b6d. + +## Files touched this session +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs (net +340/-69 lines + vs the C0 baseline: Operation class made poolable plus + ResetAllFieldsToDefault, CollisionCallbackContext plus cached delegate + plus stack, RentOperation/RetireOperationToPool, FirstPendingProjection, + BeginAcceptedPlacementCore restructure, both SetPosition call sites, both + AcknowledgeProjection removal branches, CancelCoreDeferred retirement + call site) +- tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs + (26 lines: only the regression gate comment and threshold, from 2_048L to + 1_536L - zero other test changes; the fix required no test edits beyond + the gate itself, confirming the pooling/delegate/LINQ changes are fully + behavior-preserving) + +# C2 review-fix round (F1-F4) + +Both independent reviews FAILED the original C2 landing with four findings. +Fixed all four; re-verified B/op unchanged (944, same as before this round) +and the complete Runtime + solution suites green. + +## F1 (MAJOR, retail) - CommitCanonical post-callback reads/writes + +Root cause: CommitCanonical read operation.PositionAuthorityVersion/ +SpatialAuthorityVersion/Command.GameTime/PreviousContact/PreviousOnWalkable/ +Key/Command.ShadowWorldOffsetX/Y AFTER invoking the ground-edge HitGround/ +LeaveGround callbacks (via PhysicsObjUpdate.CommitSetPositionContactTransition). +A synchronous cancel-then-begin (or begin-twice) chain for the SAME entity +retires-then-rents (LIFO) the SAME Operation instance mid-callback, so those +post-callback reads could observe a reset-or-repurposed operation. + +Fix: hoisted every scalar CommitCanonical still needs into locals BEFORE the +callback (operationKey, positionAuthorityVersion, spatialAuthorityVersion, +sourceVelocityAuthorityVersion, commandGameTime, previousContact, +previousOnWalkable, shadowWorldOffsetX/Y). ContactCommitGuard now captures +positionAuthorityVersion/spatialAuthorityVersion as plain values instead of +holding an Operation reference. IsCanonicalPlacementCommitCurrent's Operation +parameter was replaced with the two explicit scalar parameters. + +FALSE START (caught by full-suite regression, not left in): first pass ALSO +added an operationToken identity comparison inside +IsCanonicalPlacementCommitCurrent, intending to detect the exact repurposing. +This broke two EXISTING tests +(ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation and, +after fixing that, exposed a status regression in the same test) because +retail's OWN contract is that an in-flight ground-edge commit for a +DISPLACED operation must still complete its physical settle (contact +transition + shadow sync) - adding identity-based rejection there +incorrectly aborted a commit the test explicitly requires to succeed. +Reverted the token check from IsCanonicalPlacementCommitCurrent entirely +(doc comment there now explains why identity must NOT be checked at that +layer). The REAL self-aliasing hazard was two levels up: `BeginAcceptedPlacementCore`'s +final `IsCurrent(replacement)` check (after PublishPlacement can reentrantly +retire+rent the SAME `replacement` instance for an inner Begin) and +`SubmitPreparedPlacementCore`/`RetryDeferred`'s post-CommitCanonical decision +of Cancelled-vs-Committed (CommitCanonical can legitimately succeed for a +displaced operation - the OUTER caller's own invocation still must not +publish a Place projection nobody will ever acknowledge). Fixed both by +comparing a LOCAL, pre-reentrancy token/parameter (`token` already a +parameter in SubmitPreparedPlacementCore; hoisted `operationToken` added to +RetryDeferred; the `token` local already existed in +BeginAcceptedPlacementCore) against a FRESH `_operations` lookup - never +against the potentially-repurposed operation reference's own fields. + +Regression test: ReentrantCancelThenBeginRecyclesInstanceButCollisionReportUsesPreCallbackValues. +Drives Cancel(publishWithdrawal:false) then BeginAcceptedPlacement from +inside OnHitGround - LIFO pool guarantees the SAME instance is retired then +immediately rented for the new ("recycled") operation, a strictly more +adversarial recycle than the pre-existing test. Asserts (via +CollisionReportObserver subscribed to CollisionReports) that the +environment-collision report's RecipientWasInContact reflects the ORIGINAL +pre-callback PreviousContact, and that the report fires at all (proving +PreviousOnWalkable also carried through correctly - the report only fires +when !previousOnWalkable && body.OnWalkable, so a corrupted +PreviousOnWalkable would silently suppress it). Verified DISCRIMINATING: reverted +the hoist locally, confirmed the test fails (empty report collection), then +restored the fix. + +## F2 (MAJOR arch + MINOR retail) - pool invisible to reset/dispose ledger + +_operationPool was never cleared by ClearOwnedState (called from both +ResetSession and Dispose), so up to 64 pooled instances could retain full +previous-generation entity graphs across a session boundary. Added +`_operationPool.Clear()` inside ClearOwnedState (comment explains why this +is safe unlike RetireOperationToPool: a session clear cannot be reentered +from inside itself). Added `PooledOperationCount` to +RuntimeSetPositionOwnershipSnapshot (new trailing field, single +construction site updated), deliberately EXCLUDED from IsConverged (doc +comment explains pooled idle capacity is legitimate mid-session). + +Regression tests: OperationPoolClearsOnResetSession, +OperationPoolClearsOnDispose - both drive an Apply+Acknowledge cycle to get +>=1 pooled operation, assert PooledOperationCount >= 1, then call +ResetSession/Dispose and assert it drops to exactly 0. Verified +DISCRIMINATING: commented out the `_operationPool.Clear()` line, confirmed +both tests fail (1 instead of 0), restored the fix. + +## F3 (MINOR arch) - no-self-aliasing invariant + InPool guard + +Reordered BeginAcceptedPlacementCore: RentOperation() now happens AFTER the +displaced operation's CancelCoreDeferred retire (previously rent happened +first). This lets a single-entity churn cycle legitimately reuse the exact +retired instance (LIFO) instead of drawing a different one - safe because +every field this method needs from `displaced` was already captured into +locals before the retire point (unchanged from the original C2 landing). +Added `Operation.InPool` (bool, defaults false): set true in +RetireOperationToPool right before pushing, cleared in +ResetAllFieldsToDefault (called from RentOperation right after popping). +RetireOperationToPool now throws InvalidOperationException if called on an +instance that is already InPool (double-retire without an intervening rent +would silently duplicate the instance in the pool stack). + +This reorder, on its own, reintroduced a DIFFERENT self-aliasing hazard at +BeginAcceptedPlacementCore's own final line (`IsCurrent(replacement) ? token +: default`) - caught by the EXISTING test +ReentrantBeginDuringDiscardCannotBeOverwrittenByOuterBegin (a +PublishPlacement-triggered reentrant Begin during a Discard notification can +retire+rent the SAME `replacement` instance for an inner operation, making +`IsCurrent(replacement)` a self-referential tautology that wrongly reports +"still current"). Fixed by comparing `_operations[key].Token` against the +LOCAL `token` (captured at function entry, immune to reentrant corruption) +instead of calling `IsCurrent(replacement)`. + +No new dedicated F3 test beyond the reflection test (F4) and the +pre-existing ReentrantBeginDuringDiscardCannotBeOverwrittenByOuterBegin, +which now exercises the corrected self-aliasing check directly. + +## F4 (MINOR arch) - two remaining new Operation() sites + completeness net + +Converted the two surviving `new Operation { ... }` object-initializer +sites (inside ParkCollisionResidents and CreateWithdrawalOperation) to +RentOperation() + field assignment, so all construction flows one path. +Both call sites confirmed safe to route through the pool (no live displaced +operation exists at either construction point - ParkCollisionResidents +explicitly skips keys already in `_operations`; CreateWithdrawalOperation's +sole caller, Cancel, always runs CancelCoreDeferred against the same key +immediately before). + +Added OperationResetAllFieldsToDefaultTouchesEveryDeclaredField: a +reflection-based test (Operation is `private`, so ONLY reflection can reach +it from a test project even with InternalsVisibleTo) comparing +`typeof(Operation).GetFields(Instance|NonPublic|Public)` against a +hardcoded, maintained list of the 33 expected backing-field names (derived +from a plain property-name list transformed to `k__BackingField` +form). Verified DISCRIMINATING: added a temporary dummy property to +Operation, confirmed the test fails with a clear collection-diff showing +the new backing field, removed it. + +## Final verification + +- Release build: 0 errors, 21 pre-existing warnings (unchanged from + baseline, zero new). +- WarmedImmediateCommitAllocationIsMeasuredBeforeProductionCutover: still + passes at the 1,536L gate. Re-measured exact value 3x: 944 B/op, IDENTICAL + to before this fix round - confirms every F1-F4 hoist/guard is + stack-only/negligible (one extra bool field, one extra int in a value-type + snapshot struct - no heap allocation added). +- Complete AcDream.Runtime.Tests: 925/925 (921 + 4 new: 1 F1 regression + 2 + F2 reset/dispose + 1 F4 reflection). +- Complete solution (dotnet test AcDream.slnx -c Release -m:1): every + project green - App 4028/3 skip, Bake 15, Cli 4, Content 124, Core.Net + 762, Core 4242/1 skip, Headless 77, Runtime 925, UI.Abstractions 543. + 10,720 total, 0 failed. +- git diff --check: clean (same pre-existing LF/CRLF metadata notices on the + now-9 touched-by-someone files - the 8 pre-existing protected dirty paths + plus RuntimeSetPositionState.cs itself; RuntimeSetPositionStateTests.cs + shows no notice at all). +- git status: still exactly the 2 files this session owns + (RuntimeSetPositionState.cs, RuntimeSetPositionStateTests.cs) plus the 8 + pre-existing protected dirty paths untouched. Nothing staged, nothing + committed, HEAD unchanged at 6460596b56cd72a2c6d96e757b33da879a805b6d. + +## Files touched this fix round +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs (net diff vs C2 + landing: 800 lines changed - Operation.InPool + doc, ResetAllFieldsToDefault + InPool line, CommitCanonical hoisting rewrite, ContactCommitGuard/ + IsCanonicalPlacementCommitCurrent signature change, IsVelocityCurrent + overload, BeginAcceptedPlacementCore reorder + final-check fix, + SubmitPreparedPlacementCore + RetryDeferred post-commit currency checks, + RuntimeSetPositionOwnershipSnapshot.PooledOperationCount + + ClearOwnedState pool clear, ParkCollisionResidents + CreateWithdrawalOperation + routed through RentOperation) +- tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs (net + diff: 255 lines - 4 new tests + System.Reflection using) + +# C2 round 3 (rent-after-retire regression) + addendum (A1/A2) + +The F3 reorder (rent AFTER retire, so a single-entity churn cycle reuses +the exact retired instance) makes `IsCurrent(Operation)` (ReferenceEquals + +six field comparisons read off the SAME instance) a tautology once a +reentrant cancel-then-begin recycles that instance for a different logical +operation at the same key. Every one of the ~20 `IsCurrent(operation)` call +sites needed auditing: either convert to a captured-token-vs-fresh-lookup +check, or prove (with a per-site comment) that no reentrancy point +intervenes between the last fresh lookup and the check. + +## The fix + +Extracted `IsOperationStateConsistent(Operation)` from `IsCurrent`'s six +non-identity field comparisons. `IsCurrent(Operation)` is now +`_operations.TryGetValue(operation.Key, out current) && +ReferenceEquals(current, operation) && IsOperationStateConsistent(operation)` +- same behavior as before, just factored so the new helper below can share +the state check. Added: + +``` +private bool IsCurrentByToken( + RuntimeEntityKey key, + in RuntimeEntityPlacementToken capturedToken, + [NotNullWhen(true)] out Operation? operation) +``` + +Does a FRESH `_operations.TryGetValue` + `current.Token == capturedToken` + +`IsOperationStateConsistent(current)`, and hands back the fresh (possibly +different-instance) `Operation` on success. `[NotNullWhen(true)]` lets +callers reuse a single `out` binding across an `if (!IsCurrentByToken(...)) +return ...;` guard without a separate null-forgiving cast. + +`CancelCore(Operation expected, ...)` (ReferenceEquals shape - the OTHER +regression source the reviewer named, "shares the shape with a worse +outcome: cancelling the newer operation") became +`CancelCore(RuntimeEntityKey key, in RuntimeEntityPlacementToken +expectedToken, bool preserveLostFamily = false)`: fresh +`_operations.TryGetValue(key, ...)` + `current.Token != expectedToken` -> +no-op. All 5 callers converted (ForgetExactPlacement, +RetireDormantLocalActivation, RetireDormantLocalActivationToken, +SubmitPreparedPlacementCore's post-CommitCanonical-failure branch, +RetryDeferred's equivalent) - each now passes its own captured +`(key, token)` instead of an `Operation` reference. + +## The exact bug the reviewer traced (Cancel path) - FIXED + +`Cancel(record, bool)` creates a withdrawal operation, installs it at the +key, then calls `PublishPlacement(cancelledOld)` (reentrancy point: an +`IRuntimePlacementObserver` subscriber can call Begin/Cancel for the same +entity from inside this synchronous dispatch), then previously checked +`IsCurrent(operation)` against the now-possibly-recycled reference. Fixed: +capture `operation.Token` into a local BEFORE `PublishPlacement`, then +`IsCurrentByToken(key, capturedToken, out operation)` afterward. + +## Subtler hazard found during the audit, not named by the reviewer: +## RebindQuiescedDeferredOperations + +`foreach (Operation operation in _operations.Values.ToArray())` snapshots +live REFERENCES. An earlier iteration's `RetryDeferred` call (itself +reentrancy-exposed) can recycle a LATER iteration's not-yet-reached +Operation instance before the loop reaches it - a stale-reference bug +independent of the Cancel/CancelCore ones. Fixed by snapshotting +`(Key, Token)` VALUE pairs first, then resolving fresh via +`IsCurrentByToken` at the top of each iteration instead of trusting the +snapshotted reference. + +## Addendum A1 (retail MINOR) - CommitCanonical's 4 post-callback writes + +`CommitCanonical`'s tail (`operation.ExactCellId/Result/WakeableLostCell/ +EnteringWorldFromCelllessResidence = ...; CancelLostFamilyDeadlines(operation)`) +still targeted the poolable instance directly. With rent-after-retire, a +nested cancel-then-begin recycles the instance before this block runs, and +a bare Begin never advances PlacementCommitVersion - invisible to the +settle-layer record-state checks - so the writes could land on the WRONG +(freshly-begun) operation. Fixed: hoisted `operationToken = operation.Token` +(already had `operationKey` from F1) at the SAME pre-callback point as +every other F1 hoist, then gated the whole write block behind a fresh +`_operations.TryGetValue(operationKey, out currentOperation) && +currentOperation.Token == operationToken` check - skip the writes (not the +whole commit) if the token no longer matches, matching retail's +already-unconditional physical settle (only Runtime's OWN bookkeeping is +conditional). The final `IsCanonicalPlacementCommitCurrent(..., +requireSpatialRoot: true)` return is UNCHANGED - still identity-agnostic, +per the layer-separation rule below. + +## Addendum A2 (doc hygiene) - stale comment on RetireOperationToPool + +The old comment claimed `IsCanonicalPlacementCommitCurrent` "additionally +compares the operation's Token by value" - that guard was a round-3 FALSE +START (see the F1 section above) that was reverted before F1 even landed; +the comment was never updated and contradicted the real mechanism. +Rewritten to state the true safety contract: hoisted locals (F1) + +call-site captured-token-vs-fresh-lookup (this round), with the settle +path deliberately identity-agnostic per retail's unconditional +SetPositionInternal completion. + +## Layer separation (unchanged, reaffirmed by the retail reviewer) + +`IsCanonicalPlacementCommitCurrent` takes NO identity/Token parameter, by +design - retail's SetPositionInternal settle completes unconditionally for +a displaced operation (see +ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation, from the +F1 false-start above). Identity gates belong ONLY at Runtime-owned +publication/cancellation/ownership decisions (SubmitPreparedPlacementCore's +Cancelled-vs-Committed decision, Cancel's withdrawal publish, CancelCore's +match check, CommitCanonical's A1 bookkeeping-write gate) - never at the +settle/currency check itself. Did not touch this layer in round 3 beyond +re-confirming it via the reverted false-start being re-tried and failing +the same way it did in round 2 (not re-attempted this round - the round-2 +false-start's lesson was already incorporated). + +## Per-site audit table (all sites this class calls +`IsCurrent(Operation)`/`ReferenceEquals` on an Operation across a +reentrancy-exposed span) + +CONVERTED to captured-token-vs-fresh-lookup (IsCurrentByToken or +CancelCore(key, token)): +1. `Cancel(record, bool)` post-`PublishPlacement(cancelledOld)` check - the + reviewer's named bug. +2. `SubmitPreparedPlacementCore` post-`_physics.Engine.SetPosition(...)` + check (first of two, immediately after the collision-callback-bearing + call). +3. `SubmitPreparedPlacementCore` post-`SetPosition` second check (after the + `TryGetBlockingQuiescence`/deferred branch, before `CommitCanonical`). +4. `SubmitPreparedPlacementCore`'s `CancelCore(token.Entity, token)` call on + `CommitCanonical` failure - the reviewer's named CancelCore-shape bug. +5. `RetryDeferred`'s two analogous post-`SetPosition` checks (same shape as + #2/#3, via hoisted `operationToken`). +6. `RetryDeferred`'s `CancelCore(operationToken.Entity, operationToken)` + call on `CommitCanonical` failure (same shape as #4). +7. `RebindQuiescedDeferredOperations`'s per-entry resolve - converted from a + snapshotted-REFERENCE loop to a snapshotted-(Key,Token) loop + fresh + `IsCurrentByToken` (the subtler hazard found during the audit, not named + by the reviewer). +8. `ForgetExactPlacement`'s `CancelCore(token.Entity, token)` call. +9. `RetireDormantLocalActivation`'s `CancelCore` call. +10. `RetireDormantLocalActivationToken`'s `CancelCore` call. +11. `CommitCanonical`'s A1 bookkeeping-write gate (fresh lookup + + `currentOperation.Token == operationToken` before the + ExactCellId/Result/WakeableLostCell/EnteringWorldFromCelllessResidence/ + CancelLostFamilyDeadlines writes) - addendum A1. + +PROVEN SAFE WITH COMMENT (fresh lookup + Token/state check on the same line +or immediately prior, nothing reentrant intervenes before the check runs): +12. `IsPlacementCurrent` - fresh lookup + Token check inline, no + reentrancy between them. +13. `PrepareDormantLocalActivationOwnership` - fresh lookup + Token check + earlier in the same method, dormant-family code with no production + callers. +14. `PrepareMover` - same shape as #13. +15. `IsExactPreparedPlacementCurrent` - same shape. +16. `TryEvaluateDormantLocalActivation` - ReferenceEquals variant, safe + because `handleCollisions: null` on this call means nothing reentrant + can run before the check. +17. `IsDormantLocalActivationPrephaseCurrent` - fresh lookup + Token check + earlier in the same method. +18. `IsDormantLocalActivationResponseCurrent` - same shape. +19. `CommitDormantLocalActivationPostCollision` - entry AND final-return + checks, one comment block covering both; dormant family, no production + callers. +20. `IsDormantLocalActivationCommitCurrent` - same shape as #17/#18. +21. `IsExactDormantLocalActivationCurrent` - same shape. +22. `SubmitPreparedPlacementCore`'s own entry `ownsToken` check - the + function's own validation, nothing reentrant between the fresh lookup + and this check. +23. `AcknowledgeProjection`'s entry check - fresh lookup + + ProjectionSequence check, no reentrancy in between. +24. `CommitCanonical`'s entry check (`!result.IsCommitted || + !IsCurrent(operation)`) - every caller (SubmitPreparedPlacementCore, + RetryDeferred) passes an operation freshly re-verified immediately + before calling CommitCanonical. +25. `CommitCollisionGeneration`'s per-entity loop - added during THIS pass + (not flagged by the reviewer, found while double-checking every + `_operations.TryGetValue` site in the file for completeness). Iterates + an array of KEYS (never Operation references), fresh lookup + full + WakeableLostCell/ExactCellId/CollisionPrefix/CollisionGeneration shape + check every iteration - immune to the snapshotted-reference class of + bug even though it calls the reentrancy-exposed `RetryDeferred` + per-entity. + +OUT OF SCOPE (different class entirely, not an Operation-identity site): +26. `ParkCollisionResidentsForQuiescence`'s `ReferenceEquals(current, + state)` - checks `CollisionPrefixQuiescence` identity (a class that is + never pooled), unrelated to the Operation pool this round's regression + lives in. + +## Round 3 tests + +`ReentrantBeginDuringCancelPublishCannotBeOverwrittenByOuterWithdraw` - +the reviewer's named Cancel-path scenario: Apply (pending Place +projection) -> observer reentrantly Begins on the Discard notification +during Cancel's PublishPlacement -> asserts only the Discard delta +published (a stale-reference bug would add a second Withdraw delta +stamped with the inner operation's state) and the inner token is still +`IsPlacementCurrent`. VERIFIED DISCRIMINATING: reverted Cancel's +`IsCurrentByToken` check back to `IsCurrent(operation)`, reran - failed +exactly as predicted (`Assert.Single` saw 2 deltas, the second a Withdraw +carrying the inner operation's PlacementCommitVersion=2/Sequence=2) - +restored the fix. + +`ReentrantCancelThenBeginDuringCommitFailureLeavesInnerOperationUncancelled` +- the CancelCore-shape scenario: ground-edge HitGround callback does +cancel-then-begin (recycling the instance for `inner`) AND calls +`lifetime.Entities.AdvancePlacementCommit(record)` a second time BEFORE +creating `inner` (so `inner` snapshots the already-advanced value and +stays internally self-consistent, while the OUTER commit's +`canonicalCommitVersion`, captured before the callback, now mismatches) - +this makes `CommitCanonical`'s post-callback `IsCanonicalPlacementCommitCurrent` +check fail for the outer commit without needing a full nested SetPosition +round-trip, driving `SubmitPreparedPlacementCore` into +`PublishCancellation(CancelCore(token.Entity, token))`. Asserts +`outcome.Status == Cancelled` and `inner` is still `IsPlacementCurrent` +afterward. VERIFIED DISCRIMINATING: reverted `CancelCore(key, token)`'s +Token check to accept any match by key alone (simulating the old +ReferenceEquals-without-identity shape), reran - failed exactly as +predicted (`IsPlacementCurrent(inner)` false, the sabotaged check retired +`inner`'s instance out from under it) - restored the fix. + +## Final verification (round 3 + addendum) + +- Release build (`dotnet build AcDream.slnx -c Release`): 0 errors, 21 + pre-existing warnings, all in test files this session did not touch (App/ + Core test projects) - unchanged from the F1-F4 round. +- `WarmedImmediateCommitAllocationIsMeasuredBeforeProductionCutover`: + passes at the existing 1,536L gate - token captures added this round are + all stack-only locals/struct fields, no new heap allocation. +- Complete AcDream.Runtime.Tests: 927/927 (925 F1-F4 baseline + 2 new: the + Cancel-path and CancelCore-shape regressions). +- Complete solution build (`dotnet build AcDream.slnx -c Release`): 0 + errors. +- `git diff --check`: exit 0, clean (same pre-existing LF/CRLF metadata + notices on the same pre-existing dirty files, RuntimeSetPositionState.cs + included - no whitespace-error content). +- `git status`/`git rev-parse HEAD`: still exactly the 2 files this session + owns (RuntimeSetPositionState.cs, RuntimeSetPositionStateTests.cs) plus + the same pre-existing dirty paths (AGENTS.md, + PlayerModeController.cs, PlayerInteractionMovementSink.cs, + LiveAnimationPresentationContext.cs, RuntimeRemotePhysicsUpdater.cs, + CellTransitTests.cs, Issue133DungeonTeleportPrefixTests.cs, + A8CellAudit.csproj) untouched by this session. Nothing staged, nothing + committed, HEAD unchanged at 6460596b56cd72a2c6d96e757b33da879a805b6d. + +## Files touched this round + +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs (IsCurrent split + into IsCurrent + IsOperationStateConsistent, new IsCurrentByToken helper, + CancelCore(Operation) -> CancelCore(key, token) signature change + 5 + caller conversions, Cancel/SubmitPreparedPlacementCore/RetryDeferred/ + RebindQuiescedDeferredOperations converted call sites, + CommitCanonical's A1 bookkeeping-write token gate, ~14 proven-safe-site + comments, RetireOperationToPool doc-comment rewrite (A2), + CommitCollisionGeneration audit comment) +- tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs (2 + new tests: ReentrantBeginDuringCancelPublishCannotBeOverwrittenByOuterWithdraw, + ReentrantCancelThenBeginDuringCommitFailureLeavesInnerOperationUncancelled) + +--- + +# C3 implementer session (2026-08-02) — spawn-frequency host cutover + +Worktree/branch/HEAD verified against the pinned contract before starting: +a32aba35d1d945b9d3194a84e70facf74a7d7608. Read in full: c3-contract.md, +docs/plans/2026-08-02-placement-cutover.md, docs/research/ +2026-08-02-cutover-route-inventory.md (routes 1+8 + all cross-cutting +sections), docs/research/2026-08-02-canonical-body-writer-map.md, docs/ +research/2026-08-02-runtime-continuation-executor-handoff.md. + +## C3-1 — DONE, tested, gated. (Runtime-only prerequisite a) + +Design: rather than widen any internal enum's accessibility (the contract's +explicit preference), added a small PUBLIC projection surface next to the +existing public placement-receipt types (`RuntimePlacementProjectionKind`/ +`Token`/`Snapshot` are already public; the executor's own receipt/trace types +are `internal`): + +- `RuntimeInitialCreateTeleportHookPhase`, `RuntimeInitialCreatePositionDisposition`, + `RuntimeInitialCreatePositionConstrainPhase` — public 1:1 projections of the + internal `RuntimeTeleportHookPhase`/`RuntimeAuthoritativePositionDisposition`/ + `RuntimePositionConstrainPhase` enums (`RuntimeAuthoritativePositionRouteClassifier.cs:38,49,63`). +- `RuntimeInitialCreatePositionRouteFact` — one Position continuation's route + facts (Sequence/Disposition/HookPhase/ConstrainPhase/StopInterpolating/ + ZeroVelocity/PreserveHeading/SendPositionImmediately), projected from + `RuntimeInitialCreateExecutedAction` trace entries where + `Kind == Position` only (every other action kind stays internal-only — + widening the full action-kind vocabulary was explicitly what the contract + said to avoid). +- `RuntimeInitialCreatePlacementCompletion` — the top-level public shape + (Entity, FullCellId, TeleportHookPhase, PositionRouteFacts, + ReplayedDeferredChildCount), built ONCE by a new private + `RuntimeInitialCreateContinuationExecutor.ProjectCompletion` at the exact + completion site (`ExecuteCore`'s `Released` case, where `completedReceipt` + is built) and cached in `_completionReceipts`'s tuple (extended from + `(Sequence, Receipt)` to `(Sequence, Receipt, Public)`) — so a host polling/ + retrying `TryGetInitialCreateCompletion` never re-allocates + (the "allocation-conscious" requirement). +- New internal `RuntimeInitialCreateContinuationExecutor.TryGetCompletion` + reads the cached projection by exact token identity (same Entity/Sequence + correlation rule as the existing `TryGetCompletionReceipt`). +- New PUBLIC `RuntimePlacementProjectionChannel.TryGetInitialCreateCompletion( + RuntimeGenerationToken, in RuntimePlacementProjectionToken, out + RuntimeInitialCreatePlacementCompletion)` — generation-gated like every + other channel method, thin passthrough to the executor. The channel now + takes the executor as a 3rd internal ctor parameter; all 3 + `RuntimeEntityObjectLifetime` constructors updated to pass + `InitialCreateExecution`. + +Files touched: +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + (+219/-0 net insertions: new public types, ProjectCompletion + 3 enum + mappers, TryGetCompletion, _completionReceipts tuple widened, completion + site wired). +- src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs (+32/-1: + new ctor param + field, TryGetInitialCreateCompletion). +- src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs (+9/-6: all 3 + ctors pass InitialCreateExecution into the channel). +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + (+178: 4 new tests — ProjectsHookPhaseCellAndReplayCount [local-player + login correctly reports AfterEnterWorld hook phase, empty route facts], + ProjectsPositionRouteFactsForConstrainInterpolationBinding [teleport- + advanced continuation's route facts round-trip through the public + projection], RejectsWrongGeneration, ReturnsFalseAfterAcknowledgeReapsTheCorrelationEntry). + +Gates run for C3-1: focused Residence|Classifier|Executor|SetPositionState| +PlacementProjectionChannel filter 237/237; complete AcDream.Runtime.Tests +931/931 (927 baseline + 4 new); Release build of the full solution 0 +errors/21 pre-existing warnings; complete solution +`dotnet test AcDream.slnx -c Release -m:1` with +`ACDREAM_PAK_PATH=/c/Users/erikn/Documents/Asheron's Call/acdream.pak` — +every project green (App 4028/3 skips, Bake 15/0, Cli 4/0, Content 124/0, +Core.Net 762/0, Core 4242/1 skip, Headless 77/0, Runtime 931/0, +UI.Abstractions 543/0 — zero failures anywhere). `git diff --check` clean +(only the same pre-existing LF/CRLF notices on pre-existing dirty files). +Nothing staged, nothing committed. `git status` confirms only the 4 files +above are newly dirty beyond the 8 pre-existing protected paths (which I +did not touch — I read PlayerModeController.cs for C3-2 investigation but +made ZERO edits to it). + +## C3-2/C3-3/C3-4 — NOT implemented this session. Stopped with evidence +## after investigation surfaced a materially larger scope than the four +## input docs describe. Full findings below for whoever picks this up. + +I read (in full or targeted-full) beyond the four input docs: +`PlayerModeController.cs` (all 623 lines), `RuntimeLocalPlayerPhysicsPublicationState.cs` +(all 1033 lines), `RuntimeLocalPlayerMovementState.cs` (all 374 lines), +`EntityPhysicsHostComposition.cs` (all 82 lines), `RuntimeInitialCreateResidenceState.Begin`/ +`Own` (full), `RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement`/ +`SubmitPreparedPlacementCore` (targeted), `RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate` +(full), `DatLiveEntityProjectionMaterializer.RegisterAnimation` (full), plus +targeted greps across `RuntimeInitialCreateContinuationExecutorTests.cs` and +`RuntimeLocalPlayerPhysicsPublicationStateTests.cs` for the test harness's +own "intended recipe" (tests are the only place the FULL local-player +publication recipe is exercised end-to-end today). + +### Finding A — confirmed: no accessibility blocker, no accidental +pre-built orchestrator (cross-check of the route-inventory's own claim) + +Re-verified independently: `RuntimeLocalPlayerPhysicsPublicationState`'s +Prepare/Commit/EvaluateActivation/CommitActivation/FinalizeActivation chain +has ZERO production callers (only +`tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs`). +Unlike C1 ("satisfied by existing mechanism" — a pleasant surprise the plan +doc recorded), there is no similar hidden orchestrator tying +`RegisterEntityWithInitialResidence`'s residence lease to the publication +lifecycle automatically. The wiring described by C3-2/C3-3's bullets +genuinely does not exist anywhere, dormant or otherwise. + +### Finding B — the local-player initial-placement circular dependency +(confirmed by direct read, not previously named in any of the 4 docs) + +- `RuntimeEntityObjectLifetime.RegisterEntityWithInitialResidence` → + `InitializeAcceptedCreateResidence` → `InitialCreateResidences.Begin` + (`RuntimeInitialCreateResidenceState.cs:556-607`) → `Own` + (`:731-...`) — `Own` synchronously calls + `_setPosition.TryBeginExclusiveAuthoredPlacement(record, ..., route.OperationKind)` + (`:746-751`) whenever `route.PerformsSetPosition` is true. This runs + **at wire CreateObject time**, inside `LiveEntityRuntime.RegisterLiveEntity` + today's call site (once flipped) — i.e. it opens a `RuntimeEntityPlacementToken` + operation in stage `AwaitingPreparation` immediately, for EVERY entity + including the local player, and the lease (`lease.Placement`) sits open + until something completes it. +- `RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate` + (`RuntimeAuthoritativePositionRouteClassifier.cs:205-273`, full read) + gives EVERY TopLevel Create with a valid wire position — local player, + remote, projectile alike — `Disposition.SetPosition` uniformly + (`route.PerformsSetPosition` is true for all of them; only Parented/ + PickedUp Creates get `AwaitFreshPosition`, which does NOT open a + SetPosition operation). So this circular dependency is not local-player- + specific in its trigger — it applies to the SAME `Own()` call for every + top-level Create. +- For the LOCAL PLAYER specifically, that open placement operation can only + be completed by driving `RuntimeLocalPlayerPhysicsPublicationState`'s full + chain against the EXACT SAME `lease.Placement` token (confirmed by reading + `RuntimeLocalPlayerPhysicsPublicationStateTests.cs:2263-2300`'s `Fixture.RepreparePlacement`/ + `Prepare` helpers: `BeginAuthoredPlacement` → `PrepareMover` → `Owner.Prepare(record, + placement, command, options, activationPreparation, out token)` → + `Commit(token, out activationToken)` → `EvaluateActivation` → + `CommitActivation` — `Commit`'s own body + (`RuntimeLocalPlayerPhysicsPublicationState.cs:391-397`) calls + `_physics.SetPosition.PrepareDormantLocalActivationOwnership(candidate.Record, + candidate.Body, candidate.PreparedActivation.Token.Placement)`, i.e. it + attaches the freshly-built candidate body to THAT EXACT placement token). + The generic `SubmitPreparedPlacement`/`TryPrepareAndSubmitAuthoredPlacement` + path (the one C3-2's bullet 3 names for `AwaitingContinuationPlacement`'s + pending-token flavor) **cannot** be used for the local player's OWN initial + placement: `SubmitPreparedPlacementCore` requires + `operation.Record.PhysicsBody is not { } body` to already be non-null + (`RuntimeSetPositionState.cs:2610`) — there is no local-player body yet at + Create time, so this path structurally rejects it. Only the publication + lifecycle can attach the FIRST body to an already-open placement token. +- TODAY, `PlayerModeController.BuildControllerAndCamera` runs LATER than + Create-time (gated by `PlayerModeAutoEntry.cs:214-230`'s + `IsPlayerEntityPresent && IsWorldReady` per-frame check) and never touches + `lease.Placement`/the publication state at all — it does its own unrelated + `_physics.Resolve`/`ResolvePlacement` (PlayerModeController.cs:409-430). + So after the flip, the residence lease's placement (and therefore the + ENTIRE executor drain — deferred-child replay, the AfterEnterWorld + teleport-hook request, every continuation) stays stuck in `PendingPlacement` + from Create time until whatever replaces `BuildControllerAndCamera` drives + the publication chain AND separately calls `InitialCreateExecution.Execute(...)` + a SECOND time afterward to actually drain the FIFO. This second `Execute` + call is not optional — `Execute`'s own doc comment and the executor + handoff both say a caller re-invokes `Execute` after the placement token + in the trace is acknowledged; nothing does this automatically. +- Practically workable simplification found: `RuntimeLocalPlayerPhysicsPublicationState.Prepare` + calls `DiscardCurrent()` on entry (`:305`) before installing a new + candidate, so a full "retry the whole Prepare→Commit chain from scratch + next frame" is safe UNTIL `Commit()` succeeds (at which point `_activation` + is populated and a subsequent `Prepare` call correctly rejects via + `CanPrepare`'s `_activation is null` guard — the retry loop must switch to + re-driving `EvaluateActivation`/`CommitActivation` on the SAME + `activationToken`, not re-`Prepare`ing). This means `PlayerModeAutoEntry`'s + EXISTING per-frame "keep calling TryEnter until it returns true" loop can + likely be reused rather than inventing a wholly new scheduler — but + `PlayerModeController` needs a small piece of cross-frame state (at least + the pending `activationToken` when Commit succeeded but Evaluate/Commit- + Activation hasn't finished) that does not exist today. This is a genuinely + new resumable mini-state-machine, not a one-line change. +- The animation-sequencer hook attachment + (`AttachCycleVelocityAccessor`/`ObjectScale`/`AttachAnimationRootMotionSource`/ + `Motion.RemoveLinkAnimations`/`InitializeMotionTables`/`CheckForCompletedMotions`/ + `DefaultSink`, `PlayerModeController.cs:378-396`) currently happens on the + controller BEFORE placement resolve, via the existing + `RuntimeLocalPlayerMovementState.BeginMotionPreparation` lease + (`:102-117`). `RuntimeLocalPlayerPhysicsPublicationState.Prepare` builds + its OWN controller internally and does not return a reference to it before + `Commit()` — but since `BeginMotionPreparation` only needs a controller + REFERENCE (no ordering requirement relative to the publication state's own + internal stages), it appears safe to call `BeginMotionPreparation` on + `_movement.Controller` immediately AFTER `Commit()` succeeds (controller + is `RuntimeOwnedDormant` at that point, not yet `RuntimePublished`) and + BEFORE `EvaluateActivation`/`CommitActivation` — this avoids needing any + NEW Runtime API to expose the candidate controller pre-Commit. Flagged + as "appears safe" (not "verified safe") — needs a dedicated conformance + test before trusting it. + +### Finding C — a SECOND, previously-unstated capability gap: no production +path constructs a NON-local entity's FIRST PhysicsBody for a residence-driven +Create (this blocks Route 1/8 for remote/projectile Creates just as much as +the local-player gap blocks it for the local player) + +- `SubmitPreparedPlacementCore` (`RuntimeSetPositionState.cs:2584-2629`) + requires `operation.Record.PhysicsBody is not { } body` (`:2610`) for + EVERY entity, not just the local player — confirmed by reading the full + method; there is no branch that constructs a body when none exists. +- `TryPrepareAndSubmitAuthoredPlacement` (`RuntimeSetPositionState.cs:1562-1625`, + the "C0-3" mover-chain call the contract names for continuation + placements) is a thin wrapper around `PrepareMover` + `SubmitPreparedPlacement` + — it inherits the SAME pre-existing-body requirement. Its own doc comment + says "a residence lease's own Placement/Route.OperationKind/ + Route.SetPositionFlags are exactly the token/kind/flags this takes" — + true for the TOKEN shape, but silent on the body precondition. + `ClassifyCreate` gives Remote/Projectile TopLevel Creates the exact same + `Disposition.SetPosition` as the local player (see Finding B) — so a + fresh remote humanoid/monster Create's residence placement would ALSO + reject via this same guard today. +- The ONLY production body-construction call I found for a NEWLY-CREATED + (non-local) entity is `DatLiveEntityProjectionMaterializer.RegisterAnimation`'s + `_runtime.GetOrCreatePhysicsBody(spawn.Guid, incarnation => new PhysicsBody{...})` + (`DatLiveEntityProjectionMaterializer.cs:1003-1016`) — but this is gated + behind `physicsStatic` (`FinalPhysicsState & PhysicsStateFlags.Static`, + line 961) AND a resolved animation sequencer (`animation.Sequencer is {}`) + — i.e. it is a narrow special case for STATIC decorative animated objects + (banners, torches), never reached for an ordinary moving humanoid/monster + spawn. `RuntimePhysicsState.GetOrCreatePhysicsBody` (public, + `RuntimePhysicsState.cs:1623`) is presumably the right general-purpose + tool to reuse, but nobody calls it for the general case, and none of the 4 + input docs name this as a Route-1 capability gap (the closest hits — + route-inventory's "Gap 2" and body-writer-map's summary — are both scoped + explicitly to the LOCAL PLAYER controller/body atomicity problem, not to + ordinary remote entities). +- Building this out requires retail-fidelity decisions (what a fresh + non-static remote/projectile body's default orientation/scale/friction/ + elasticity/velocity should be at Create time, mirroring whatever retail's + `enter_world`/object-creation path does) that none of the 4 docs specify + and that I should not invent without the grep-named-first workflow this + project mandates for AC-specific behavior. + +### Why I stopped here rather than pushing an implementation + +Both findings B and C are genuine, evidence-backed (file:line cited) gaps +in the CONTRACT's own assumed shape, not just "this is a lot of code." +Landing C3-2+C3-3 correctly needs, at minimum: (1) a new resumable +mini-state-machine in PlayerModeController/PlayerModeAutoEntry driving +Prepare→Commit→EvaluateActivation→CommitActivation→(second) Execute across +frames; (2) the equivalent for headless (which has its own per-tick +`TryCompletePortal`-shaped loop already, per route-inventory's route 8 +section, that a similar chain would need to extend); (3) a NEW general +first-body-construction step for non-local residence-driven Creates, +requiring retail research this session did not do; (4) deletion of the +now-superseded duplicate authorities across ~6 files; (5) new App.Tests/ +Headless.Tests integration tests; (6) the connected lifecycle/reconnect +gate against a live ACE, which is itself explicitly one of this project's +few "stop and get user verification" events. Given the project's own +standing rules — no workarounds, no guessing at retail behavior, dual- +reviewed shape for anything this load-bearing, and "stop and brainstorm +when the observed scope diverges from the plan's assumed shape" — pushing +a rushed implementation of the single most sensitive path in the client +(both hosts' login/placement) within this session's remaining budget was +judged higher-risk than landing C3-1 clean and handing back precise, +citable findings for a properly scoped follow-up session (likely its own +C3-2a "local-player initial-placement orchestration" + C3-2b "first-body +construction for residence-driven Creates" split, each with its own +dual-review pass, mirroring how C0/C1/C2 were each already run). + +No files under C3-2/C3-3/C3-4's scope were edited: PlayerModeController.cs, +LiveEntityRuntime.cs, DatLiveEntityProjectionMaterializer.cs, +RuntimeLiveEntitySessionController.cs, HeadlessSessionWorldProjection.cs, +and RuntimeLocalPlayerMovementState.cs (the C3-4 Controller-setter seal) +are all untouched by this session (confirmed via `git status`). + +## Gate 4 (connected) — not reached, not skipped/faked + +The exact lifecycle/reconnect harness is real and located per the route +inventory: `tools/run-connected-world-lifecycle-gate.ps1` (drives capped + +uncapped-reconnect sessions against local ACE on 127.0.0.1:9000) and +`tools/run-connected-r6-soak.ps1` (canonical nine-stop route). Both require +an already-listening local ACE. I did not attempt to launch/verify ACE +reachability because there is no production code change from C3-2/C3-3/C3-4 +to gate yet — running the connected harness against C3-1's Runtime-only +addition would exercise nothing new (C3-1 has no host caller in this +session) and would misrepresent the gate as having validated the cutover. +Whoever lands C3-2/C3-3/C3-4 must run this gate for real, with a live ACE, +per the contract's gate 4 and the project's own "visual verification is the +one thing that requires stopping for the user" rule. + +## Files touched this session (C3-1 only) + +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs +- src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs +- src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + +Nothing staged, nothing committed, HEAD unchanged at a32aba35d1d945b9d3194a84e70facf74a7d7608. + +## C3-1 review-fix round (same session, 2026-08-02) + +Coordinator relayed two review passes on C3-1's diff: + +1. Architecture PASS with one MINOR: `MapHookPhase`/`MapDisposition`/ + `MapConstrainPhase`'s catch-all `_ =>` arms silently folded an unmapped + future internal enum value into `None`/`NoPositionOperation` instead of + failing loudly. Fixed: every declared value now has an explicit arm + (added the previously-implicit `None`/`NoPositionOperation` cases) and + the catch-all now `throw new ArgumentOutOfRangeException(...)` with a + message naming both the mapper method and the public enum to update. + Also fixed `ProjectCompletion`'s `action.PositionDisposition ?? + RuntimeAuthoritativePositionDisposition.NoPositionOperation` fallback: + verified (grep-confirmed `BuildPositionTrace` is the sole producer of + `Kind.Position` trace entries, always passing `route.Disposition`, a + non-nullable enum) that null is NOT a legitimate state at that call + site specifically (it legitimately IS null for non-Position action + kinds elsewhere in the trace, per the field's own doc comment - just + not reachable here since the loop already filters to + `Kind == Position`) - replaced the silent fallback with an explicit + `InvalidOperationException` throw + comment explaining why. +2. Retail-conformance PASS with two documentation-only addenda (no code + changes): (a) `RuntimeInitialCreatePositionRouteFact`'s doc comment now + states explicitly that `UnparentBeforeRouting`/ + `ApplyPlacementFrameBeforeRouting` ("unset_parent"/"SetPlacementFrame") + are NOT projected because the executor's own merge + (`ApplyAcceptedPositionSnapshot`'s `clearParent`/`installPlacementFrame` + params, confirmed by direct read at the `ApplyPositionAction`/envelope + call sites) already applies both to the canonical snapshot before the + trace entry is built - a host must not re-apply them; the struct only + carries facts still DEFERRED to the host. (b) + `RuntimeInitialCreatePlacementCompletion`'s doc comment now states + `PositionRouteFacts`'s ARRAY ORDER (not `Sequence`) is authoritative - + confirmed multiple Position trace entries from one same-incarnation + envelope share one continuation `Sequence` (only `Stage`, not + projected, distinguishes them internally), and `ProjectCompletion` + preserves trace/FIFO-drain order by construction. + +New test per the reviewer's ask ("same guard shape as +`OperationResetAllFieldsToDefaultTouchesEveryDeclaredField`"): +`EnumProjectionMapsHaveEqualArityAndEveryInternalValueRoundTripsByName` in +`RuntimeInitialCreateContinuationExecutorTests.cs` - reflection-invokes the +three private static `Map*` methods against every declared value of their +internal source enum, asserting (a) equal arity between the internal and +public enum, and (b) every mapped public value's `.ToString()` name equals +the internal value's name (the mappers are literal 1:1 name mirrors by +design). Sabotage-verified twice, both reverted after confirming failure: +(1) added a member ONLY to internal `RuntimeTeleportHookPhase` - failed on +the arity assertion ("RuntimeTeleportHookPhase has 5 values but +RuntimeInitialCreateTeleportHookPhase has 4"); (2) added the SAME member to +BOTH the internal and public enum (arity equal) without adding a mapping +arm - failed via the reflection-invoked `MapHookPhase` throwing +`ArgumentOutOfRangeException` exactly as designed. Both sabotage edits +fully reverted; confirmed clean via `git diff` on the touched file showing +no residual change. + +Gates re-run after the fix: focused +Residence|Classifier|Executor|SetPositionState|PlacementProjectionChannel +filter 242/242 (241 + 1 new); complete AcDream.Runtime.Tests 932/932 (931 + +1 new); Release build of the full solution 0 errors/21 pre-existing +warnings; `git diff --check` clean (only pre-existing LF/CRLF notices, same +files as before). `git status` shows the same files as the prior C3-1 +checkpoint dirty, PLUS one file I did NOT touch: +`docs/plans/2026-08-02-placement-cutover.md` now shows a diff decomposing +C3 into C3a/C3b/C3c based on my earlier Finding B/C report - this was made +externally (not by this session; I never opened that file for editing this +round) and is left exactly as found, unstaged. Nothing staged by me, +nothing committed, HEAD unchanged at +a32aba35d1d945b9d3194a84e70facf74a7d7608. + +Files touched this round (all within C3-1's original scope, no new files): +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + (Map* explicit arms + throws, ProjectCompletion null-check, doc-comment + addenda on RuntimeInitialCreatePositionRouteFact and + RuntimeInitialCreatePlacementCompletion) +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + (+1 new test, +using System.Reflection) + +================================================================ +C3a -- Runtime first-entry conductor (dormant) -- implementer session +================================================================ + +## Mandatory first step: full reads completed + +Read in full before writing any code: +- src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs + (1,033 lines). +- tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs + (2,698 lines, all ~35 tests). +- docs/research/2026-07-31-remaining-physics-campaign-handoff.md (497 lines, + full) -- route-1's own required order, lines 280-292. +- docs/plans/2026-08-02-placement-cutover.md (166 lines, full). +- docs/research/2026-08-02-canonical-body-writer-map.md (681 lines, full). +- docs/research/2026-08-02-runtime-continuation-executor-handoff.md (211 + lines, full). +- src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs (1,260 + lines, full). +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + (Execute/ExecuteCore, lines 1-250 and 860-1080). +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs targeted sections: + RuntimeEntityPlacementStage enum (30-39), TryBeginExclusiveAuthoredPlacement/ + PrepareDormantLocalActivationOwnership/BeginAcceptedPlacementCore + (1170-1467), PrepareMover/TryPrepareAndSubmitAuthoredPlacement/ + IsExactPreparedPlacementCurrent (1480-1652), SubmitPreparedPlacementCore + (2575-2700), RetryDeferred (3986-4020), AcknowledgeProjection (2913-3006). +- Existing test fixtures for reuse patterns: + tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + (EngineLifetime/Bind/Spawn/AttachDormantBody/CompleteInitialPlacement, + lines 1-130 and 4963-5150) and + tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs + (FakeCollisionSource, TryPrepareAndSubmitAuthoredPlacement tests, + lines 2660-2760, 3296+). + +## Step-graph (states x transitions x owning method) + +[residence Begin -- ALREADY DONE at registration, outside conductor scope] + RuntimeEntityObjectLifetime.RegisterEntityWithInitialResidence + -> RuntimeInitialCreateResidenceState.Begin + -> RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement + (opens Operation, stage=AwaitingPreparation) + -> RuntimeSetPositionState.WatchPlacementCompletion(placement) + yields: RuntimeInitialCreateResidenceLease { Token, Route, Placement } + +Stage.AwaitingMoverPreparation (conductor entry point) + precondition: RuntimeInitialCreateResidenceState.TryGetCurrent(record, out + lease) && lease.Token == residenceToken + if !lease.Route.PerformsSetPosition (Parented/PickedUp -- never true for a + real login, kept for structural completeness): + -> Stage.Acknowledged (skip straight to Execute) + else: + RuntimeSetPositionState.TryPrepareAuthoredMover <- NEW extracted method + (Setup-read via IPreparedCollisionSource, then PrepareMover; stage + stays AwaitingPreparation; sets authority.Prepared=true) + RetrySetupUnavailable -> yield AwaitingCollisionSource (retry same stage) + Prepared -> Stage.MoverPrepared, holds RuntimeSetPositionCommand + +Stage.MoverPrepared + RuntimeLocalPlayerPhysicsPublicationState.Prepare(record, lease.Placement, + command, options, activationPreparation, out pubToken) + (validates IsExactPreparedPlacementCurrent -- REQUIRES mover already + prepared; off-canonical body+controller build against scratch clock) + RejectedAuthority -> abandon (Discard progress; nothing to undo, Prepare + never mutates on failure) + Prepared -> Stage.PublicationPrepared, holds pubToken + +Stage.PublicationPrepared + RuntimeLocalPlayerPhysicsPublicationState.Commit(pubToken, out + activationToken) + -> internally: RuntimeSetPositionState.PrepareDormantLocalActivationOwnership + (the designed seam -- binds Operation.Body, sets DormantLocalActivation + =true; requires stage STILL AwaitingPreparation + record.PhysicsBody + still null) + -> candidate.Controller.CommitRuntimeOwnership + + candidate.Record.SetPhysicsBody(body) (record now has a body) + RejectedToken/RejectedAuthority -> abandon (Publication.Commit already + self-discards its own candidate on RejectedAuthority; conductor drops + its own progress entry) + Committed -> Stage.PublicationCommitted, holds activationToken + +Stage.PublicationCommitted / Stage.Evaluated (single combined retry point -- + see "CommitActivation resume safety" note below) + RuntimeLocalPlayerPhysicsPublicationState.EvaluateActivation(activationToken, + out receipt) + RejectedToken/RejectedAuthority -> abandon + Evaluated/DeferredCell/RejectedPlacement -> receipt.IsValid in all three; + proceed to CommitActivation in the SAME Advance call (mirrors every + publication test: Evaluate and CommitActivation are always chained, + never yielded between) + RuntimeLocalPlayerPhysicsPublicationState.CommitActivation(receipt, out + projection) + (internally drives: ground phase -> HitGround/LeaveGround -> post-ground + -> collision dispatch -> post-collision -> FinalizeActivation, which is + the SAME retail-staged commit already tested) + Committed -> Stage.ActivationCommitted, holds projection + DeferredCell / RejectedPlacement -> yield AwaitingActivation, stage stays + PublicationCommitted (retry re-runs BOTH EvaluateActivation AND + CommitActivation next Advance call -- safe even for the internal + AwaitingFinalShadowPreparation resume path, see note below) + RejectedAuthority -> abandon + +Stage.ActivationCommitted + RuntimeSetPositionState.AcknowledgeProjection(projection.Token) + ("Place receipt -> acknowledgement" -- the SAME ack any host uses; moves + the watched placement token into _acknowledgedPlacementCompletions, + operation.Stage -> AwaitingCommitAcknowledgement, operation removed from + _operations) + false -> yield AwaitingReceiptAcknowledgement (retry same stage -- only + fails if not the exact FIFO head; single-entity tests never hit this, + documented for completeness) + true -> Stage.Acknowledged + +Stage.Acknowledged (or skipped-straight-here for a non-SetPosition route) + RuntimeInitialCreateContinuationExecutor.Execute(record, residenceToken, + inputs, out executionReceipt) + (internally: RuntimeInitialCreateResidenceState.Complete -- now succeeds + because IsPlacementCurrent(lease.Placement)==false and + TryPeekAcknowledgedPlacement succeeds -- -> AdoptCompletedPlacement -> + AfterEnterWorld hook -> deferred replay -> FIFO drain -> ConsumeExecuted) + Completed -> conductor Stage.Completed (progress removed) -> terminal + PendingPlacement -> yield AwaitingReceiptAcknowledgement (residence still + sees the operation as unacknowledged/current -- should not normally + recur once we've truly acknowledged, kept as a defensive yield) + AwaitingContinuationPlacement -> yield AwaitingContinuationPlacement + (a LATER Position continuation needs its own placement -- entirely the + EXECUTOR's own concern from here; conductor just passes the status + through, mirroring "Execute (FIFO drain) -> ExecutorCompleted receipt" + being the conductor's LAST step, not something it re-implements) + RejectedToken/RejectedAuthority -> abandon + +Typed yields exposed (RuntimeLocalPlayerFirstEntryStatus): Completed, +AwaitingCollisionSource, AwaitingActivation, AwaitingReceiptAcknowledgement, +AwaitingContinuationPlacement, Contention (reentrancy-guard-only -- mirrors +the executor's own `_executing.Add(key)` fail-closed pattern), RejectedToken, +RejectedAuthority. "awaiting-preparation" from the contract's five named +yields is folded into AwaitingCollisionSource / the MoverPrepared -> +PublicationPrepared span (see reconciliation below) rather than kept as an +eighth separate value -- documented at the enum declaration. + +## Reconciliation against route-1 + CONTRADICTION FOUND (resolved, did not +## silently redesign) + +Campaign handoff route-1 order (2026-07-31-remaining-physics-campaign-handoff.md:280-292): + 1. register identity cellless + 2. begin initial/remote-create placement before hydration + 3. load exact Setup mover + 4. prepare the atomic Runtime controller/body relationship + 5. submit canonical SetPosition + 6. publish presentation only from Place + 7. acknowledge, then enable player mode/simulation + +Step 3 (mover) precedes step 4 (controller/body prepare) here. + +The C3a contract's OWN restated PURPOSE-section order instead reads: + "... atomic Commit binding the body to the residence's EXACT placement + token (PrepareDormantLocalActivationOwnership is the designed seam) + -> authored-mover preparation + submission (C0's + TryPrepareAndSubmitAuthoredPlacement ...)" +i.e. it places mover-prep AFTER Publication Prepare/Commit -- the OPPOSITE of +route-1's own order 3-then-4. + +Verified against the actual staged semantics that this restated order is +IMPOSSIBLE and additionally that the named mechanism cannot be reused as +worded: +1. RuntimeLocalPlayerPhysicsPublicationState.CanPrepare + (RuntimeLocalPlayerPhysicsPublicationState.cs:886-889) requires + _physics.SetPosition.IsExactPreparedPlacementCurrent(record, placement, + command) to ALREADY be true. IsExactPreparedPlacementCurrent + (RuntimeSetPositionState.cs:1627-1651) requires + authority.Prepared && authority.PreparedCommand == command -- i.e. + PrepareMover MUST have already succeeded for this exact command BEFORE + Publication.Prepare can even be called. Mover-prep cannot happen after + Commit; it structurally gates entry into Prepare. +2. TryPrepareAndSubmitAuthoredPlacement (RuntimeSetPositionState.cs:1562-1625) + is PrepareMover followed unconditionally by SubmitPreparedPlacement. + SubmitPreparedPlacementCore (RuntimeSetPositionState.cs:2584-2630) + requires operation.Record.PhysicsBody is not {} body -- i.e. a body must + ALREADY exist. Before Commit, the record has no body (Commit is what + attaches one); calling this fused method before Commit would reject. + Calling it AFTER Commit (as the contract's restated order implies) would + NOT reject -- the body now exists -- but RetryDeferred's own comment + (RuntimeSetPositionState.cs:3988-3993) states explicitly: "The local- + player activation lease owns its dormant body/controller and must + re-enter through the same sealed evaluation/commit path ... it must + never bypass that path through the ordinary remote CommitCanonical + tail." SubmitPreparedPlacementCore has no DormantLocalActivation + exclusion check, so calling it post-Commit would silently route the + operation through the wrong (ordinary) commit tail in parallel with the + dormant Evaluate/Commit/FinalizeActivation chain -- corrupting state. +3. Confirmed empirically: the EXISTING executor test suite + (RuntimeInitialCreateContinuationExecutorTests.cs's + AttachDormantBody/CompleteInitialPlacement helpers, lines 5007-5073) + treats even isLocalPlayer: true fixtures via a direct + Entities.SetPhysicsBody + ordinary SubmitPreparedPlacement -- NEVER + through RuntimeLocalPlayerPhysicsPublicationState -- because for a + record that never sets DormantLocalActivation, the ordinary tail is + exactly correct. This is a test-only substitute for what C3a's conductor + now performs for real; it is not evidence that the ordinary tail is ever + valid for a DormantLocalActivation operation. + +Resolution: route-1's own order (mover-prep BEFORE the controller/body +prepare step) is correct and consistent with every tested invariant; the +C3a contract's restated PURPOSE-section prose transposed the two steps. +Per the contract's own instruction ("STOP with file:line evidence" rather +than silently redesigning the PUBLICATION CHAIN), this is flagged here with +full citations; the conductor is implemented using route-1's order because +(a) that is what the contract explicitly told me to reconcile against, and +(b) it is the only order that satisfies the publication chain's own +staged/tested preconditions without changing a single line of already- +tested code. The publication chain itself (Prepare/Commit/EvaluateActivation/ +CommitActivation/FinalizeActivation) is NOT modified or reinterpreted -- only +the CONDUCTOR's call order was corrected relative to the contract's prose. + +Mechanism correction: "C0's TryPrepareAndSubmitAuthoredPlacement" as named in +the contract is the WRONG vehicle for the local-player dormant path for the +reason in point 2 above (it ends in SubmitPreparedPlacement, forbidden +once DormantLocalActivation is set). RuntimeSetPositionState.cs gained one +new internal method, TryPrepareAuthoredMover, extracted verbatim from +TryPrepareAndSubmitAuthoredPlacement's FIRST HALF (Setup-read + PrepareMover +call only, no Submit) -- a pure, behavior-preserving refactor. +TryPrepareAndSubmitAuthoredPlacement itself now calls this shared helper +then submits, unchanged in every observable respect (its own two existing +tests, TryPrepareAndSubmitAuthoredPlacement_ChainsSetupReadThroughPrepareMoverToSubmit +and ..._YieldsRetryOnAMissingSetupReadWithoutMutatingStage, stay green +unmodified). The conductor calls ONLY the new TryPrepareAuthoredMover half. + +## CommitActivation resume safety note (why one retry stage suffices) + +Verified that re-running EvaluateActivation before every CommitActivation +retry -- rather than adding a THIRD stage that resumes CommitActivation alone +for the AwaitingFinalShadowPreparation internal resumption path -- is safe: +CommitActivation's own top-of-method resume check +(activation.PendingFinalCommit.Status is AwaitingFinalShadowPreparation, +RuntimeSetPositionState.cs:498-506) fires only AFTER re-validating +activation.Receipt == receipt; a fresh EvaluateActivation call sets +activation.Receipt to match whatever it just returned, so passing that +same fresh receipt back into CommitActivation satisfies the equality check +and the stored PendingFinalCommit (untouched by the extra Evaluate call) +still drives the correct resume via FinalizeActivation. Confirmed +DeferredCell/RejectedPlacement from CommitActivation clear +activation.Receipt to default (RuntimeSetPositionState.cs:546,628) -- so a +fresh Evaluate is REQUIRED, not just tolerated, on those two outcomes. The +one cost is a redundant extra Engine.SetPosition resolve in the (rare, +contention-only) AwaitingFinalShadowPreparation case -- not a correctness +issue, and simpler than tracking a fourth stage. + +## Files this session will add/touch + +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs -- extract + TryPrepareAuthoredMover (new internal method; TryPrepareAndSubmitAuthoredPlacement + now delegates to it, unchanged behavior). +- src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs -- NEW, + the conductor. Dormant; no production caller; constructed only in tests. +- tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs + -- NEW. + +No changes to RuntimeLocalPlayerPhysicsPublicationState.cs, +RuntimeInitialCreateResidenceState.cs, RuntimeInitialCreateContinuationExecutor.cs, +RuntimeEntityObjectLifetime.cs, or GameRuntime.cs (C3c wires production +callers and residence-retirement fan-out; that is explicitly out of C3a's +scope). One consequence documented for C3c: because +RuntimeInitialCreateResidenceState.BindRetirementNotification is a single- +subscriber seam already bound to InitialCreateExecution.DiscardProgress +inside RuntimeEntityObjectLifetime's constructor, the conductor built here +does NOT receive a push notification on external residence retirement; it +relies on lazy re-validation at the top of every Advance call plus an +explicit Forget(key) a future host can call. C3c will need to either fan the +single notification out to both subscribers or route it through the +conductor. + +## Implementation complete — gates passed + +Final design (RuntimeLocalPlayerFirstEntryState.cs, 423 lines) — a five-stage +resumable machine (AwaitingMoverPreparation -> MoverPrepared -> +PublicationCommitted -> ActivationCommitted -> Acknowledged), one +`Advance(record, residenceToken, options, activationPreparation, +collisionSource, gameTime, inputs, out receipt)` entry point, an +`_executing` HashSet reentrancy guard mirroring the executor's own, a +`Progress` class (LeaseId + Stage + the exact token/receipt/projection +structs) keyed by `RuntimeEntityKey`, and a `Discard`/`Forget` pair that +unconditionally calls `Publication.Discard`/`DiscardActivation` (both +harmless no-ops against a default/unreached-stage token). + +Bugs found and fixed during test-driven verification (all via a temporary +diagnostic build with Console.WriteLine probes, removed before the final +commit-ready state): +1. **EvaluateActivation's overloaded DeferredCell status.** Once a PRIOR + CommitActivation call has registered a lease as awaiting a specific cell + (`IsDormantLocalActivationAwaitingCell`), a REPEATED EvaluateActivation + call that is still not ready returns DeferredCell WITHOUT populating its + receipt (stays default/invalid) — confirmed against + `DeferredCommitWaitsThenRearmsSameLeaseAfterExactGenerationWake` in the + publication suite, which asserts exactly `waiting.IsValid == false` on + that repeat and never calls CommitActivation with it. The conductor now + checks `evalReceipt.IsValid` before ever calling CommitActivation, + short-circuiting straight to AwaitingActivation when it is false, instead + of blindly forwarding an invalid receipt (which CommitActivation's own + `!receipt.IsValid` guard would reject as RejectedAuthority). +2. **Re-acknowledging an already-consumed projection token.** An earlier + draft used a single "Acknowledged" stage value to mean both "just + committed, ack not yet attempted" and "ack already succeeded", causing a + retry AFTER a successful acknowledgement (e.g. because Execute yielded + AwaitingContinuationPlacement) to call AcknowledgeProjection a second + time against a token AcknowledgeProjection had already removed from its + FIFO — always failing. Split into two distinct stages + (ActivationCommitted = ack not yet attempted; Acknowledged = ack done, + only Execute remains) so the ack call only ever runs once per projection. +3. **RejectedToken vs RejectedAuthority at the residence-lookup checks.** + Mirrored `RuntimeInitialCreateResidenceState.Complete`'s own convention: + "nothing was ever tracked for this key" (no Progress entry, residence not + found) is RejectedToken; "something WAS in flight and just got + invalidated" (Progress entry existed, now stale) is RejectedAuthority — + matching the executor's identical split for the analogous case. + +Real (not test-only) findings surfaced by writing the tests, documented in +the test file itself: +- `RuntimeEntityRecord.Key` is computed from a nullable `LocalEntityId` and + becomes `null` the instant `ReleaseLocalId` runs (part of delete's + teardown in `RuntimeEntityObjectLifetime.CompleteProjectionRetirement`). + `Advance`'s entry check (`record.Key is not {} key -> RejectedToken`) + EXACTLY mirrors `RuntimeInitialCreateContinuationExecutor.Execute`'s own + entry check — once Key is null, the conductor cannot compute its own + dictionary key to reach stale progress at all. A caller that wants + deterministic cleanup after full teardown must capture the + `RuntimeEntityKey` BEFORE deletion and call `Forget(key)` explicitly; this + is a pre-existing convention in the codebase (the executor has the + identical limitation), not a defect introduced here. +- `RuntimeLocalPlayerPhysicsPublicationState` holds exactly ONE global + `_candidate`/`_activation` (instance fields, not per-key) — correct, since + there is only ever one local player — but it means an orphaned, un-Forgot + first-entry attempt for a stale incarnation will structurally block a + fresh incarnation's own `Publication.Prepare` (CanPrepare requires + `_activation is null`) until `Forget` runs. Proven by + `DeleteAndSameGuidReincarnationRequiresForgetBeforeTheFreshIncarnationCanUseThePublicationSlot`. +- `Movement.ResetSession()` proactively nulls Publication's `_activation` + directly (unlike delete, which only makes it stale via + `_entities.IsCurrent`/epoch checks) — so a retry after ResetSession sees + EvaluateActivation report RejectedToken (activation genuinely gone), not + RejectedAuthority (activation found but stale) — and, because + ResetSession never touches `RuntimeEntityRecord.Key`, ordinary retry alone + (no captured-key Forget) reaches Discard and converges. + +### Final gate results + +- `dotnet build src/AcDream.Runtime/AcDream.Runtime.csproj -c Release`: + 0 errors, 0 warnings. +- `dotnet build AcDream.slnx -c Release`: 0 errors, 21 pre-existing warnings + (same count/files as the C3-1 checkpoint; none new). +- Focused filter + `FullyQualifiedName~Publication|~Residence|~Executor|~SetPositionState|~FirstEntry`: + 308/308 passed. +- Complete `AcDream.Runtime.Tests`: 944/944 passed (932 baseline + 12 new), + 0 skips. +- Complete solution (`dotnet test AcDream.slnx -c Release --no-build -m:1`): + every project reports 0 failed — App 4028/3 skips, Bake 15/0, Cli 4/0, + Content 124/0, Core.Net 762/0, Core 4242/1 skip, Headless 77/0, Runtime + 944/0, UI.Abstractions 543/0. +- `git diff --check`: clean (only pre-existing LF/CRLF notices on the same + eight paths as every prior checkpoint; AGENTS.md is the only one with a + real, pre-existing, untouched content diff; `RuntimeSetPositionState.cs` + shows exactly my own 60/8 insertion/deletion extraction, nothing else). +- No staging, no commits, HEAD unchanged at `277ef5d0`. + +### Files touched (final) + +- `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` — extracted + `TryPrepareAuthoredMover` (new internal method, Setup-read + PrepareMover + only); `TryPrepareAndSubmitAuthoredPlacement` now delegates to it then + submits, byte-identical observable behavior (its own two existing tests + pass unmodified). +- `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs` — NEW, + 423 lines. Dormant; zero production callers (verified by the focused/ + complete/full-solution gates above, which exercise it only from the new + test file). +- `tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs` + — NEW, 12 tests: full-sequence happy path; AwaitingCollisionSource retry + +resume; AwaitingActivation retry+resume (generation wake); AwaitingReceipt + Acknowledgement retry+resume (FIFO-head contention via a second entity's + unacknowledged Place); AwaitingContinuationPlacement propagation+resume; + reentrant Advance during a collision callback (Contention, outer call + still completes); two retry-idempotency tests (mover-preparation and + AwaitingActivation stages never re-create a candidate/duplicate a body); + mid-flight delete-during-collision-callback abandonment (no Place/shadow + published, full convergence); delete-while-AwaitingActivation requiring an + explicit captured-key Forget; ResetSession mid-flight converging through + ordinary retry; delete+same-GUID-reincarnation requiring Forget before the + fresh incarnation can use Publication's one global slot. + +RuntimeLocalPlayerPhysicsPublicationState.cs, RuntimeInitialCreateResidenceState.cs, +RuntimeInitialCreateContinuationExecutor.cs, RuntimeEntityObjectLifetime.cs, and +GameRuntime.cs are all untouched, exactly as scoped. + +================================================================ +Review round 2 -- F1 (MAJOR) + F2 (MINOR) fixes +================================================================ + +## F1 (MAJOR) -- ack-failure authority re-validation + +Root cause confirmed: the ActivationCommitted stage treated EVERY +AcknowledgeProjection failure as the generic "not yet FIFO head" case. +TryAcceptDelete -> CompleteProjectionRetirement -> Physics.SetPosition.Forget +-> CancelCore rewrites the SAME pending slot from Place to Discard with a +bumped Revision; the RuntimePlacementProjectionToken struct this class +already cached in progress.Projection can then never match the FIFO head +again, so AcknowledgeProjection would fail forever -- infinite +AwaitingReceiptAcknowledgement for a dead entity, Progress retained, +IsConverged false forever, exactly as reported. + +Fix: added IsAcknowledgementStillPending(record, residenceToken, expected), +called on every failed acknowledge before deciding retryable vs abandon. +Two checks, either failing means authority moved: +1. _residences.TryGetCurrent(record, out lease) && lease.Token == + residenceToken -- the SAME residence-lookup pattern stage0/stage1 + already use. +2. _physics.SetPosition.TryPeekProjection(out head) -- if the FIFO head + belongs to THIS entity (head.Token.Entity == expected.Entity) but is no + longer the exact Place token expected (kind changed, or revision + bumped), authority for THIS SPECIFIC placement moved even if the + residence lookup alone would not have caught it. A head belonging to a + DIFFERENT entity is the genuine "not our turn yet" case and stays + retryable. +Both failing -> Discard(key) + RejectedAuthority. Read +RuntimeSetPositionState.TryPeekProjection directly (Runtime-internal-to- +internal), not through the public generation-gated +RuntimePlacementProjectionChannel -- this class is part of Runtime, not an +external host crossing that boundary, exactly like its existing direct +AcknowledgeProjection call. + +Real discovery made while testing this: FinalizeActivation nulls +Publication's OWN tracked `_activation` the INSTANT CommitActivation's +final commit succeeds (RuntimeLocalPlayerPhysicsPublicationState.cs +FinalizeActivation, `_activation = null;` right after +TryApplyDormantLocalActivationFinalCommit succeeds) -- the controller is +genuinely live/published from that point on, not a discardable in-progress +candidate. So once Stage.ActivationCommitted is reached, +Publication.DiscardActivation is ALREADY a no-op on the controller/body; +abandoning a stuck acknowledgement never retroactively un-publishes an +already-live entity -- that is ordinary entity teardown's job, not this +class's. Documented on Discard's own doc comment and confirmed empirically +(a diagnostic build showed PendingActivationCount == 0 already at the FIRST +ack attempt, before any authority change). + +New test: +`DeleteWhileAwaitingReceiptAcknowledgementAbandonsInsteadOfRetryingForeverAndConverges` +-- reaches ActivationCommitted (blocked behind another entity's own +unacknowledged Place, same technique as the existing FIFO-head test), then +calls the EXACT narrower mechanism TryAcceptDelete itself uses +(`Physics.SetPosition.Forget(record, releasePreparedMover: true)` + +`PublishCancellation`) directly rather than a full entity delete -- this +was deliberate: a FULL delete now ALSO retires the residence, and F2's +automatic retirement fan-out (below) would converge everything before a +second Advance ever ran, masking whether THIS authority-recheck code path +itself works. The narrower call proves the fix independent of F2's +wiring. Asserts RejectedAuthority, ActiveCount/PendingActivationCount +converge to 0, and the UNRELATED entity's own placement remains +unaffected and acknowledgeable. + +## F2 (MINOR) -- ownership fold + cleanup wiring + +(a) RuntimeInitialCreateResidenceState.BindRetirementNotification converted +from a single nullable Action field (throw-on-second-bind) to a +`List>` (ordered, registration-order invocation via +a new private NotifyRetirement(key) helper). Null-arg throw preserved +(ArgumentNullException.ThrowIfNull); the "already bound" throw is gone by +design since multiple subscribers are now the point. All 5 existing +invocation sites (Forget x2, Clear's two loops, Retire(Entry), +Retire(CompletedEntry)) now call NotifyRetirement instead of +`_retirementNotification?.Invoke`. + +(b) RuntimeLocalPlayerFirstEntryState's constructor no longer takes +RuntimeLocalPlayerPhysicsPublicationState (RuntimeEntityObjectLifetime is +constructed BEFORE Publication exists -- GameRuntime builds +RuntimeLocalPlayerMovementState and attaches its publication only after the +entity-object lifetime). Added the SAME late-bind pattern already used +throughout this class family (BindGeneration, BindRetirementNotification, +BindLiveInputs, RuntimeLocalPlayerMovementState.PhysicsPublication's own +throws-if-unbound accessor): `BindPublication(publication)` (bind-once, +throws on null/double-bind) + a private `Publication` accessor that throws +if unbound. All internal `_publication.X` call sites became `Publication.X`. +Added `DiscardAll()` mirroring the executor's own (discards every tracked +key's candidate/activation, then clears `_progress`). +RuntimeEntityObjectLifetime now constructs `LocalPlayerFirstEntry` in all 3 +constructors (right after InitialCreateExecution, same pattern), binds a +SECOND retirement notification (`key => LocalPlayerFirstEntry.Forget(key)`, +alongside the executor's existing one), and `BeginSessionClear` calls +`LocalPlayerFirstEntry.DiscardAll()` right after +`InitialCreateExecution.DiscardAll()`. `RuntimeEntityObjectOwnershipSnapshot` +gained `LocalPlayerFirstEntryActiveCount = 0` (trailing default, matching +the file's existing convention), folded into `IsConverged` and into +`CaptureOwnership()`'s construction. GameRuntime.cs itself was NOT touched +(BindPublication is never called in production) -- deliberate: since +Advance is never called in production, `_progress` stays permanently empty, +so Forget/DiscardAll never actually dereference Publication regardless of +binding state; wiring the production BindPublication call is left as a +natural part of C3c's Advance-caller work, not manufactured here. + +(c) Updated the two named tests plus my own new delete tests to use +`Lifetime.LocalPlayerFirstEntry` (bound via `.BindPublication(Publication)`) +instead of a separately-constructed conductor instance -- this is what +actually exercises the real wiring; a standalone instance would never see +the fan-out at all. +- `DeleteWhileAwaitingActivationRequiresForgetOfTheCapturedKeyToDiscardTheDormantActivation` + renamed to `DeleteWhileAwaitingActivationConvergesAutomaticallyThroughTheRetirementFanOut`: + delete alone now converges ActiveCount/PendingActivationCount to 0 with + NO explicit host Forget call; a follow-up Advance is a safe RejectedToken + no-op (Key already null). +- `DeleteAndSameGuidReincarnationRequiresForgetBeforeTheFreshIncarnationCanUseThePublicationSlot` + renamed to `DeleteAndSameGuidReincarnationAutomaticallyFreesThePublicationSlotForTheFreshIncarnation`: + the fresh incarnation's own Prepare now succeeds immediately after delete, + no Forget call in between. +No standalone-conductor variant was kept -- delete-triggered convergence +IS the production path once RuntimeEntityObjectLifetime owns construction, +so an explicit-Forget test would only be meaningful for a conductor built +outside the lifetime, which is not a real usage shape this slice needs to +cover (the F1 test's narrower Physics.SetPosition.Forget-only scenario +already demonstrates the authority-recheck's own logic independent of the +fan-out, satisfying that documentation need instead). + +Fixture.Dispose ordering bug found and fixed along the way: disposing +Movement (which tears down Publication) BEFORE Lifetime (whose Dispose runs +BeginSessionClear, which now reaches LocalPlayerFirstEntry.DiscardAll -> +Publication.Discard for any still-tracked entity) threw +ObjectDisposedException whenever a test left real progress untracked at +teardown (e.g. the AwaitingActivation retry-idempotency test, which never +completes or deletes within the test body). Fixed by disposing Lifetime +FIRST. Documented as a real ordering constraint for whoever eventually +disposes GameRuntime in production, since the identical dependency exists +there (RuntimeEntityObjectLifetime's conductor holds a bound reference to +Publication via BindPublication). + +## Final gate results (round 2) + +- `dotnet build src/AcDream.Runtime/AcDream.Runtime.csproj -c Release`: + 0 errors, 0 warnings. +- `dotnet build AcDream.slnx -c Release`: 0 errors, 21 pre-existing warnings + (unchanged). +- Focused filter + `FullyQualifiedName~Publication|~Residence|~Executor|~SetPositionState|~FirstEntry`: + 309/309 passed (308 + 1 new F1 test). +- Complete `AcDream.Runtime.Tests`: 945/945 passed (932 baseline + 13 new), + 0 skips. +- Complete solution (`dotnet test AcDream.slnx -c Release --no-build -m:1`): + every project reports 0 failed -- App 4028/3 skips, Bake 15/0, Cli 4/0, + Content 124/0, Core.Net 762/0, Core 4242/1 skip, Headless 77/0, Runtime + 945/0, UI.Abstractions 543/0. +- `git diff --check`: clean. Modified files now include + `RuntimeEntityObjectLifetime.cs` and `RuntimeInitialCreateResidenceState.cs` + in addition to the round-1 `RuntimeSetPositionState.cs` -- all three + diffs are additive/expected (64, 39, 68 changed lines respectively via + `git diff --stat`); the pre-existing eight dirty paths are otherwise + unchanged (line-ending noise only). +- No staging, no commits, HEAD unchanged at `277ef5d0`. + +## Files touched (round 2 additions) + +- `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs` -- + now 663 lines (was 540): F1's IsAcknowledgementStillPending; F2's + BindPublication/Publication accessor replacing the constructor + parameter; DiscardAll(). +- `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs` -- + BindRetirementNotification multicast conversion. +- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` -- + LocalPlayerFirstEntry property + construction in all 3 ctors + second + retirement-notification bind + BeginSessionClear wiring + + RuntimeEntityObjectOwnershipSnapshot field/IsConverged/CaptureOwnership + fold. +- `tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs` + -- now 827 lines (was 758): 1 new F1 test; Fixture now binds/uses + `Lifetime.LocalPlayerFirstEntry` instead of a standalone instance; fixed + Dispose ordering; 2 tests renamed and rewritten for automatic + convergence per F2(c). + +GameRuntime.cs remains untouched (see F2(b) note above for why). No +production caller of Advance anywhere. + +================================================================ +Review round 3 -- H1 + H2 hardening (final verdicts: retail PASS, +architecture PASS) +================================================================ + +## H1 -- NotifyRetirement snapshot before iterating + +`RuntimeInitialCreateResidenceState.NotifyRetirement` iterated the live +`_retirementNotifications` List<> directly. A subscriber binding a NEW +notification from inside a retirement callback it is itself receiving +(unreachable today -- only 2 subscribers exist, neither rebinds -- but +becomes reachable the instant C3c adds a runtime-bound third subscriber) +would throw "Collection was modified" on the very next iteration step. +Fixed per the reviewer's exact instruction, matching +`RuntimeEntityObjectEventStream`'s own copy-on-write dispatch precedent: +`foreach (... in _retirementNotifications.ToArray())`. `ToArray()` (not a +`Volatile`-guarded array swap like the event stream) was the right +granularity here since binding only ever happens a handful of times at +construction, never on a hot per-frame path -- documented on the method's +own doc comment with that reasoning spelled out. + +New test (`RuntimeInitialCreateResidenceStateTests.cs`): +`RetirementNotificationBoundReentrantlyDuringDispatchDoesNotCorruptTheCurrentIteration` +-- binds a notification that, on its first invocation, reentrantly binds a +THIRD one; triggers a real retirement via `Forget`; asserts no exception, +that the newly-bound subscriber does NOT see the in-flight retirement (not +required to), and that it DOES see the next one. + +## H2 -- unbound-Publication transactional failure + +Root cause confirmed: `AdvanceCore` had no check for an unbound +`_publication` before mutating anything. An Advance call in the window +before a host calls `BindPublication` would run the FULL authored-mover +Setup-read/PrepareMover call (mutating `RuntimeSetPositionState`'s own +`_preparedMovers`) and create/store this class's own `Progress` entry +BEFORE the FIRST `Publication` dereference (inside the `MoverPrepared` +stage) throws -- leaving a poisoned `Progress` entry in `_progress` that a +LATER, unrelated `Discard`/`DiscardAll` call (from a retirement +notification or session-clear fan-out) would ALSO throw on, corrupting +someone else's teardown. + +Fix: `AdvanceCore`'s very first statement (before `_progress.TryGetValue`, +before the ABA check, before anything) is now `_ = Publication;` -- the +existing throws-if-unbound accessor, referenced purely for its side +effect, so the whole call fails transactionally with nothing yet mutated. +Also hardened `Discard`/`DiscardAll` to tolerate an unbound `_publication` +defensively (`if (_publication is null) return;` before touching +`Publication.Discard`/`DiscardActivation`) -- both documented as +structurally unreachable post-H2 (a `Progress` entry can only exist if +`Advance` already ran, which now requires a bound `Publication` first) and +guarded anyway as belt-and-suspenders so no future caller shape can turn +an already-surfaced `Advance` failure into a SECOND throw from inside an +unrelated fan-out. + +New tests (`RuntimeLocalPlayerFirstEntryStateTests.cs`): +- `AdvanceWithUnboundPublicationThrowsTransactionallyBeforeAnyStateMutation` + -- constructs a bare `RuntimeEntityObjectLifetime` (its own + `LocalPlayerFirstEntry` is naturally unbound, since only this test file's + own `Fixture` calls `BindPublication`), registers a residence, calls + `Advance` with no bound Publication, asserts the throw AND that the + residence lease/`ActiveCount` are completely untouched, THEN + binds a real Publication and confirms the SAME token still drives + correctly to `AwaitingActivation` -- proving nothing was corrupted by the + failed attempt. +- `BindPublicationTwiceThrows` -- the standard bind-once guard test, + matching every other `BindX` method in this class family. + +## Final gate results (round 3, last of the slice) + +- `dotnet build src/AcDream.Runtime/AcDream.Runtime.csproj -c Release`: + 0 errors, 0 warnings. +- `dotnet build AcDream.slnx -c Release`: 0 errors (warning count reported + as 0 on this incremental rebuild since no other project's files changed + and MSBuild skipped re-analyzing them as up-to-date; the prior two + rounds already confirmed 21 pre-existing warnings, all in untouched test + files, with a from-scratch build). +- Focused filter + `FullyQualifiedName~Publication|~Residence|~Executor|~SetPositionState|~FirstEntry`: + 312/312 passed (309 + 3 new: 1 H1 + 2 H2). +- Complete `AcDream.Runtime.Tests`: 948/948 passed (932 baseline + 16 new + across the whole C3a slice), 0 skips. +- Complete solution (`dotnet test AcDream.slnx -c Release --no-build -m:1`): + every project reports 0 failed -- App 4028/3 skips, Bake 15/0, Cli 4/0, + Content 124/0, Core.Net 762/0, Core 4242/1 skip, Headless 77/0, Runtime + 948/0, UI.Abstractions 543/0. +- `git diff --check`: clean. Modified files now additionally include + `tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs` + (+47/-0, the new H1 test) alongside + `RuntimeInitialCreateResidenceState.cs` (54 changed lines, up from 39 in + round 2 -- the ToArray snapshot + doc comment) and the unchanged round-1/2 + files. The pre-existing eight dirty paths remain line-ending noise only. +- No staging, no commits, HEAD unchanged at `277ef5d0`. + +## Files touched (round 3 additions) + +- `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs` -- + `NotifyRetirement` now snapshots via `ToArray()`. +- `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs` -- + now 690 lines (was 663): upfront unbound-Publication check in + `AdvanceCore`; `Discard`/`DiscardAll` unbound-Publication tolerance. +- `tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs` + -- +1 new H1 test. +- `tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs` + -- now 922 lines (was 827): +2 new H2 tests. + +This is the last round of code changes for C3a per the coordinator's +message. GameRuntime.cs remains untouched throughout the whole slice; no +production caller of Advance anywhere. + +## C3b implementer progress (remote body construction at Create) + +- Verified HEAD d62b9950 on codex/port-claude-agents; 8 protected dirty paths untouched. +- Read: plan (C3b scope), float-gates doc (3 byte-certain gates), retail-notes.md + (CreateObject 0x00558870 order; set_description 0x00514F40 order; PhysicsDesc::UnPack), + writer map (6 canonical SetPhysicsBody writers), PhysicsBody.cs, + RuntimeInitialCreateResidenceState.cs, RuntimeLocalPlayerFirstEntryState.cs (C3a shape), + RuntimeSetPositionState mover/submit/ack/park/retry paths, RuntimePhysicsState bind sites, + route classifier, C3a test harness. +- Resolved SetMotionTableID(0) semantics from pseudo-C: CPhysicsObj::SetMotionTableID + 0x00512780 (pc:280528) fails ONLY when part_array==0 (005127da) or + MotionTableManager::Create fails for a NONZERO id (CPartArray::SetMotionTableID + 0x005186E0, pc:286732, 0051872f); id==0 skips manager creation (0051871f) and + returns 1 -> gate PASSES for zero id. CPhysicsObj skips MakeMovementManager for + INVALID_DID (005127ca). +- Recovered PhysicsDesc ctor defaults 0x0051D4D0 (pc:292056): friction "33s?" = + 0x3F733333 = 0.95f; elasticity 0.05f; translucency 0 (memset); scale 1; state 0x400c08. +- Recovered set_elasticity 0x0050FD40 (pc:277817): <0 -> 0; <=0.1 -> value; >0.1 -> 0.1. + Cross-checked ACE PhysicsGlobals.MaxElasticity = 0.1f (PhysicsObj.cs:3586-3599). +- Design: RuntimeRemoteFirstEntryState (Entities/, no publication chain) with stages + MoverPreparation -> BodyConstruction (via canonical RuntimePhysicsState.GetOrCreatePhysicsBody + factory, retail set_description order) -> Submit (lease.Placement) -> Withdraw/Place ack + (TryPeekProjection loop for deferred parks) -> Execute. Pure builder + RuntimeRemoteBodyDescription + construction receipt for gate proofs. +- IMPLEMENTED: RuntimeRemoteBodyDescription.cs (288 L, pure set_description-ordered + construction + gated receipt), RuntimeRemoteFirstEntryState.cs (620 L, six-stage + resumable conductor, no publication chain), lifetime wiring (+51/-2: construction in + all 3 ctors, third multicast retirement binding, BeginSessionClear DiscardAll, + RemoteFirstEntryActiveCount snapshot field + IsConverged clause), tests (879 L, 29 + tests, all pass first run). +- GATES: Runtime build 0W/0E; solution build 0 errors, 21 pre-existing warnings none in + changed files; focused filter (Residence|Executor|SetPositionState|FirstEntry| + RemoteEntry) 247/247; complete Runtime 977/977; git diff --check clean; 8 protected + dirty paths + AGENTS.md untouched; nothing staged/committed. +- No conflicts stopped on: motion-table zero-id semantics resolved from pseudo-C + (gate passes for id 0); elasticity clamp recovered (0..0.1) + ACE cross-check; + PhysicsDesc ctor defaults recovered for absent-wire fields. +- REVIEW ROUND (arch M1/M2 + retail R1/R2) closed: construction receipt now rides the + terminal Advance out-param (M1); shared RuntimeFirstEntryAcknowledgement.IsStillPending + used by BOTH conductors + new delete-after-commit-before-ack abandonment test (M2); + movement branch re-gated on buffer non-emptiness per UnPack's buff_length!=0 assignment + (R1, both-ways tests; parser's empty-buffer wrapper confirmed at CreateObject.cs:597); + elasticity NaN -> 0f per retail's first-arm unordered (R2a, ACE divergence noted); + friction NaN skip kept + commented per gates-doc quirk (R2b; doc addendum text in + final report); translucency NaN matches retail apply-bucket already (noted). +- FINAL GATES: focused 252/252; complete Runtime 982/982; build 0 errors, no warnings in + changed files; git diff --check exit 0; nothing staged; protected paths untouched. + +# ============================================================ +# C3c — THE HOST FLIP (new session) +# ============================================================ + +## Reading phase (complete) +- Verified worktree HEAD 78f1eb18 on codex/port-claude-agents; 8 dirty files = protected list. +- Read: c3c-contract.md, plan doc C0-C3b notes, route inventory routes 1+8 + cross-cutting, + canonical-body-writer-map, both conductors, executor surface, channel + subscription + + retry slot, both sinks, both session routes, GameRuntime, RuntimeLocalPlayerMovementState, + PlayerModeController (protected-noise), LiveEntityRuntime placement family, materializer, + HeadlessSessionWorldProjection, RuntimeLiveEntitySessionController, publication Prepare, + RuntimeFirstEntryAcknowledgement, synchronous event-stream dispatch, AcknowledgeProjection + (strict FIFO-head, non-idempotent), PublishExecutorCompletion (snapshot carries body + position/orientation + token.ExactCellId; residence released before publish). + +## Design conclusions +1. One acknowledger per receipt: conductors consume their own initial Place/Withdraw + (proven by AwaitingReceiptAcknowledgement tests). Both host sinks must return false + (leave-at-head) for Place/Withdraw of an entity with an ACTIVE initial-create + residence (TryGetInitialCreateResidence discriminator). +2. ExecutorCompleted = the presentation-binding receipt (C3-1's purpose). Graphical sink + applies Place-shaped presentation from its snapshot (celless => ack-and-ignore); + returns false until sidecar+backend ready (existing FIFO-head retry semantics). +3. Drive points: graphical = post-hydration in the Create flow + wrap the per-frame retry + slot callback (drive conductors then RetryPending). Continuation placements completed + via C0 fused TryPrepareAndSubmitAuthoredPlacement + AcknowledgeProjection. +4. GameRuntime first act: LocalPlayerFirstEntry.BindPublication(Movement.PhysicsPublication) + after AttachPhysicsPublication (GameRuntime.cs:260-265). + +## C3c implementation state at session end +- ALL FIVE SCOPE ITEMS IMPLEMENTED in production code; complete solution builds 0 errors. +- Runtime tests 982/982 GREEN (3 direct-sink tests rewritten to started-generation + driven-conductor form). +- Headless tests 74/77 (3 failures: WorldProjectionHydratesCanonicalMovementAndTeleportState + 2 others — old + SynchronizeLocalPlayer expectations; fixtures need conductor-driven rework). +- App tests 3,865/4,031 passing, 163 failing after central fixture repair (LiveEntityRuntimeFixture now binds + generation 1). Remaining classes: (a) ~90 fixtures with private lifetimes and no generation bound + ("cannot acquire a structurally valid initial residence lease"); (b) ~40 hand-built spawns failing + HasConsistentCreateIdentityAndParent ("inconsistent instance or parent projections"); + (c) ~30 behavior-expectation updates (suppressed-until-receipt visibility, FullCellId staying 0 for + undriven residences). +- New contract-required integration tests NOT yet written; connected gates NOT reached (automated gates + not green — per gate order, stopped and reported). +- Nothing staged or committed. Protected dirty paths untouched except the sanctioned surgical + PlayerModeController.cs touch (flagged). + +## Continuation session (fixture repair) +- App: 443 -> 163 -> 93 -> 50 failing (3,978/4,031 passing). Repairs, all mechanical, NO assertion changes: + (1) generation binds: LiveEntityRuntimeFixture (all 5 overloads), LiveEntityHydrationControllerTests fixture, + EquippedChildProjectionWithdrawalTests fixture (own lifetime + production drive controller + landblock + collision generation); + (2) consistent-spawn repair: new shared tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs + (WithConsistentPhysics extension deriving the nested PhysicsDesc from flattened fields), applied to + CreateSupersessionRecovery, Vfx light, DeferredLifecycle, LocalPlayerTeleport, LiveAppearanceAnimation, + StreamingFrame builders; per-file physics blocks for LiveEntityPhysicsHostOwnershipTests (19->0) and + EquippedChildProjectionWithdrawalTests child/embedded-parent spawns (23->0); + (3) conductor-completion where legacy direct application now requires a released residence + (NoPositionCreateParent test: CompleteFirstEntry before TryApplyCreateParent). +- Headless: 77/77 GREEN. The 3 SynchronizeLocalPlayer-era tests rewritten CONDUCTOR-DRIVEN with the real + host wiring (host.Start -> RegisterEntityWithInitialResidence -> drive controller constructed BEFORE + registration -> HeadlessSessionWorldProjection pump). All original assertions preserved verbatim + (controller identity, LocalEntityId, positions, PortalSpace/InWorld, CenterCount 3/2, receipt-validation + no-authority invariants). Coverage not shrunk; WorldProjectionHydratesCanonicalMovementAndTeleportState now + doubles as a headless first-entry integration flow. +- Runtime: 982/982 GREEN (incl. probe-restoration build). +- Autowalk probes RESTORED at the Runtime site (diagnostic-owner pattern, PhysicsDiagnostics.ProbeAutoWalkEnabled): + [autowalk-target] + [autowalk-end reason=interrupt] in RuntimeLocalPlayerPhysicsPublicationState.Prepare's + host/motion closures; [autowalk-end reason=complete] retained App-side on the approach-lifetime MoveToComplete. + +## Expectation changes +(Continuation 4 — the logged pass. Clause key: (1) suppressed-until-receipt +visibility; (2) undriven-residence semantics; (3) residence-gated +Place/Withdraw with ExecutorCompleted as the presentation-binding receipt; +(4) sealed-setter lifecycle routing. Where the true mechanism is committed +C3a/C3b/C3-1 residence design the clause labels do not literally name — +SameIncarnationCreate FIFO staging (AD-59), conductor-built bodies at Create +(C3b), no create-authority advance outside the residence transaction — the +line cites the closest clause PLUS the design mechanism; all such lines are +flagged [interp] for coordinator veto.) + +1. CurrentGameRuntimeAdapterTests.DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace + → old: both hosts register + hand-driven apply chain produce identical + entity-object traces → new: at the deliberate zero generation BOTH hosts + refuse the initial Create transactionally with the identical structural + error ("cannot acquire a structurally valid initial residence lease"), + identical EMPTY traces, zero entity/object counts → clause (2). Full + driven-flow parity moved to the new C3c integration tests. +2. UpdateFrameOrchestratorTests.GameplayInputOwnersUseTypedSeamsWithoutGameWindowBackReferences + → old pinned PlayerModeController source order: PreparePositionForCommit + → EnterChaseMode → SelectStableHostWithoutRebind → SyncPose → + InstallOrRebind → SetPosition(initial) → CommitPreparedPosition → + `_controllerSlot.Controller = controller` → IsPlayerMode=true → new pinned + order: controller.IsRuntimePublished gate → EnterChaseMode → SyncPose → + `_hostSlot.Host = playerHost` → IsPlayerMode=true → clause (4) (the + deleted markers were exactly the App-side controller construction+commit). +3. LiveEntityHydrationControllerTests.CompletedSpatialRecovery_DetectsCreateVersionDriftWithoutNestedHydrationRequest + → old: PositionSequences [1,1,2], last purpose CreateSupersessionRecovery, + InstalledCreateIntegrationVersion 2UL → new: [1,1], SpatialRecovery, 1UL + → clause (3) [interp]: a post-residence same-generation Create is + description-only at registration; its position churn flows through the + freshness-gated events tail; create authority advances only inside the + residence transaction, so no drift-replay fires. +4. LiveEntityHydrationControllerTests.TimestampCallbackFresherSameGeneration_StopsOuterCreateVersion + → old: single materialize from the nested newer spawn (PositionSequences[0] + == 2) → new: single materialize from the admission-frozen seq-1 create + (== 1); the seq-2 facts commit at the executor drain in array order → + clause (3) [interp, AD-59 FIFO staging]. +5. LiveEntityHydrationControllerTests.ObjectRemovalCallbackFresherSameGeneration_WinsOuterReplacement + → old: last materialize seq 3 + DoesNotContain(2, Skip(1)) → new: last + materialize is the admission-frozen seq-2 replacement + DoesNotContain(3) + (the seq-3 facts commit at the drain and never re-materialize) → + clause (3) [interp, AD-59]. +6. LiveEntityHydrationControllerTests.FailedCompletedSupersession_RemainsPendingUntilExactRetry (both rows) + → old: InstalledCreateIntegrationVersion == (retryFromLandblock ? 2 : 3) + → new: == 2 in both rows (the retry retransmit no longer advances create + authority) → clause (3) [interp]. Drift probe swapped from a nested + OnCreate to record.Canonical.AdvanceCreateAuthority() (the drain-stage + advance) so the retained-obligation/exact-retry machinery under test + still fires; every other assertion verbatim. +7. LiveEntityInboundAuthorityGateTests.Position_CanonicalInventoryObjectIsAcceptedBeforeProjectionExists + → old: accepted.PositionAuthorityVersion == 2 (post-apply) → new: == 1 + (admission-time; the merge is queued behind the pending residence and its + authority advance commits at the drain) → clause (2). +8. Probe swaps with ALL assertions verbatim (logged for transparency, + mechanism = same-generation Creates no longer advance create authority at + registration; modeled by record.Canonical.AdvanceCreateAuthority(), the + exact drain-stage advance) [interp, clause (3)]: + - LiveEntityHydrationControllerTests.CompletedSupersessionReadyFailure_RetriesWholeCommitBoundary (both rows) + - LiveEntityHydrationControllerTests.ReadyPublisher_RevalidatesExactRecordBetweenEveryStage (3 rows) + - LiveEntityHydrationControllerTests.ReadyPublisher_RevalidatesAfterReentrantRenderProjectionCallback + - LiveEntityHydrationControllerTests.EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback + - LiveEntityCreateSupersessionRecoveryTests.Recovery_CreateVersionAdvanceDuringAppearance_StopsLaterOwners +9. LiveEntityHydrationControllerTests.ParentSupersession_CompletesAtExactAttachedReadyBoundary (both rows) + → scenario repairs: (a) parent registered up front (retail queues a child + Create under an unaddressable parent — R5-1 QueueBlobForObject — instead + of applying it); (b) drift probe as in item 8; (c) the OnSpawnAction hook + now mirrors the production relationship owner's sticky-residence + conversion (AwaitRuntimePlacement → LegacyImmediate) at the + world→attached kind transition. Assertions verbatim → [interp, clause (3)]. +10. LiveEntityHydrationControllerTests.PositionAfterPickup_ReentersWithSameEntityBodyAndResources + → body scaffolding changed from seeding a fresh PhysicsBody to capturing + the conductor-built canonical body (C3b bodies-at-Create; factory now + throws if missing — a STRONGER assertion). Identity-preservation + assertions verbatim → [interp, C3b]. +11. LiveEntityRuntimeTests.PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder + → old: seeded RemoteMotionRuntime bodies observe state sync across + bind/state arrival orders → new: the conductor-built canonical bodies + are observed (C3b never-clobber forbids seeding a replacement); "arrival + order" is now SetState-FIFO'd-before-the-drain vs + SetState-after-completion; the two expected state-flag transforms are + UNCHANGED → [interp, C3b + clause (3)]. + +## Remaining at session end (App 50) +- LiveEntityHydrationControllerTests 21 (supersession/recovery semantics + 5 "publication owner not bound" + = the fixture drive needs a bound publication chain for isLocalPlayer leases: add + RuntimeLocalPlayerMovementState+Identity+PublicationState+BindPublication to that fixture); +- RuntimePlacementPresentationSinkTests 4 (sink behavior changed by design - residence gate + ExecutorCompleted + presentation; these need logged expectation updates per contract clauses); +- LiveEntityRuntimeTests 4, LiveEntityPresentationControllerTests 4, remaining consistent-spawn stragglers in + builders that already carry physics blocks (field-level mismatches), CurrentGameRuntimeAdapterTests 2 + (deliberate zero-generation binds), misc singles. +- NOT STARTED: the five contract integration tests; Release build; complete solution (-m:1, ACDREAM_PAK_PATH); + connected gates (blocked behind green automated gates per the pinned gate order). +- No production regression found: the one suspicious trace (FullCellId=wire-cell in a pickup-supersession test) + was ruled out against ApplyAcceptedSpawn (object-table only, no cell write) - it is a fixture-flow artifact. +- Post-log continuation: hydration fixture publication chain bound (Movement/Identity/PublicationState + + BindPublication + ServerGuid) -> hydration 21 -> 18; App total 50 -> 47 (3,981/4,031). Runtime 982/982 and + Headless 77/77 re-verified green after all repairs. Nothing staged; protected paths still untouched + (PlayerModeController touch remains the one sanctioned exception). + +## Continuation 2 (guard-zone stop) +- Hydration fixture now also carries the REAL RuntimePlacementProjectionSubscription (ack-only sink mirroring + production rules: Discard/ExecutorCompleted ack, Place/Withdraw left for conductors) - fixture is now the + full production wiring shape (generation + collision generation + publication chain + drive pump + + subscription). Hydration failures unchanged at 18 => the FIFO-wedge hypothesis is ruled OUT; the 18 + supersession/recovery failures are NOT a simple fixture gap. +- STOP under instruction-1 guard: the observed symptom (nested same-generation Create during + RecoverProjection no longer triggers CreateSupersessionRecovery; Assert.Throws sees no throw at line 1247) + means the initial residence is STILL ACTIVE when the nested create arrives, i.e. the drive after the first + OnCreate did not complete the conductor in this fixture. Before editing ANY of these 18 tests' assertions I + need to establish WHY the conductor yields here (candidate: something in the fixture's stub materializer / + spawn shape leaves the placement deferred or the executor pending) - because if the same yield can happen in + PRODUCTION's composed path, this is a real flip defect (login supersession burst leaving residences pending), + not an expectation update. That determination requires a focused diagnostic run I could not complete in the + remaining session budget. +- Expectation changes so far: STILL ZERO. +- Totals at stop: Runtime 982/982, Headless 77/77, App 3,981/4,031 (47 failed, 3 skipped). +- Items 2-4 (zero-generation adapter tests, physics-block mismatch stragglers, five integration tests, + Release/solution/connected gates) not reached. + +## Continuation 3 — DIAGNOSTIC + CLASSIFICATION (coordinator-directed) +- Instrumented RuntimeFirstEntryDriveController.DriveOne (yield status + FIFO head + residence-active + + FullCellId per step, file-logged); ran FailedCompletedSupersession_RemainsPendingUntilExactRetry. +- YIELD CHAIN (before fix): first and only drive step yielded RejectedToken with residenceActive=False and + fullCell=0x01010001 ALREADY COMMITTED — the residence was retired out-of-band before the pump ever ran. +- ROOT CAUSE: the hydration fixture's RecordingMaterializer called the internal + LiveEntityRuntime.MaterializeLiveEntity overload WITHOUT the residence parameter -> LegacyImmediate -> + legacy RebucketLiveEntity fall-through (LiveEntityRuntime.cs:777) -> CommitRebucket committed the wire cell + and advanced placement/spatial authority -> residence IsCurrent detected the out-of-band commit and retired + the lease -> conductor correctly RejectedToken. +- CLASSIFICATION: FIXTURE ARTIFACT. Production's route-1 materializer + (src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs, MaterializeProjection: residence = + retainedRecord?.MaterializationResidence ?? AwaitRuntimePlacement) never takes LegacyImmediate for an + initial world create, so production cannot reach this retirement path. Post-fix diagnostic re-run: + status=Completed, fullCell committed by the CONDUCTOR, FIFO drained — the drive converges. +- Fixture fixed at the choke point (stub materializer now passes AwaitRuntimePlacement; subscription upgraded + to a production-mirroring FixturePlacementSink: Discard ack, ExecutorCompleted -> + TryApplyInitialCreateCompletionPresentation, Place/Withdraw residence-gated -> + TryApplyRuntimePlacementProjection). Diagnostics stripped; Runtime rebuilt 0 errors. +- Result: hydration failures 18 -> 19 (group did NOT shrink; composition changed — the flows now complete + their conductors and fail on POST-flip semantic assertions: supersession/recovery expectations against + suppressed-until-receipt visibility + conductor-committed cells). These 19 + the rest of the inventory are + now genuinely the logged-expectation-update pass (guard rules unchanged, zero changes logged so far). +- Totals at stop: Runtime 982/982, Headless 77/77, App 3,980/4,031 (48 failed, 3 skipped). No staging/commits. + +## Continuation 4 — expectation-update pass + integration tests + gate ladder +- Session start state re-verified: HEAD 78f1eb18, protected dirty paths intact, nothing staged. +- Full App run (Release): 47 failed / 3,981 passed / 3 skipped of 4,031 (one fewer + than the Continuation-3 count of 48; the inventory below is the current truth). +- Classified inventory: Hydration 19; Withdrawal 6; Sink 4; LiveEntityRuntime 4; + Presentation 4; RemotePhysicsUpdater 2; CurrentGameRuntimeAdapter 2; singles 6 + (LifecycleStress, UpdateFrameOrchestrator, LiveSessionResetPlan, + InboundAuthorityGate, CreateSupersessionRecovery, LiveAppearanceAnimation). +- CLASSIFICATION POLICY DECLARED (auditable): where a required change's true + mechanism is committed, dual-reviewed C3a/C3b/C3-1 residence design that the + four clause labels do not literally name (SameIncarnationCreate FIFO staging + per AD-59; conductor-constructed bodies at Create per C3b; no create-authority + advance outside the residence transaction), the change is logged under the + closest clause WITH an explicit mechanism note and flagged in the final + report for coordinator veto. Verbatim-assertion fixture repairs get no + clause line per the guard's own exemption, but are listed below. + +### Fixture repairs (assertions verbatim, no expectation-change lines) +1. RuntimePlacementPresentationSinkTests.Fixture.Materialize — normalize the + stale residence (legacy-immediate materialization commits the wire cell + out-of-band; the query performs lazy retirement) BEFORE tests capture + ownership snapshots; previously the retirement happened inside the sink's + own first residence query and shifted SetPositionOperationCount/ + AwaitingSetPositionPreparationCount 1->0 mid-TryApply. 14/14 green. +2. LiveEntityProjectionWithdrawalControllerTests.Fixture.Spawn — spawn had no + Physics block but non-null Position/Setup/Scale + InstanceSequence!=0; + wrapped with the shared WithConsistentPhysics (sanctioned for block-less + builders). 6/6 green. +3. LiveEntityPresentationControllerTests.Fixture.Spawn — builder ALREADY + carries a physics block; field-level fix only (Timestamps.Instance was + hardcoded 1 vs the instanceSequence parameter used by guid-reuse tests). + NO blind wrap; RawState untouched. 12/12 green. +4. RemotePhysicsUpdaterTests boundary Spawn(ushort instanceSequence, ...) — + same field-level Timestamps.Instance fix; RawState untouched. Both green. +5. LiveAppearanceAnimationTests.Capture_... — block-less cellless spawn with + InstanceSequence 1; WithConsistentPhysics wrap (file's other builder + already used it). Green. + +### Continuation 4 — production changes beyond the inherited diff (both +### flagged for coordinator review; each is one revertible hunk) +1. src/AcDream.App/Rendering/EquippedChildRenderController.cs (TryAttach, + before MaterializeLiveEntity): converts a retained residence-managed + child's sticky residence AwaitRuntimePlacement → LegacyImmediate at the + world→attached kind transition. WITHOUT this, equipping a world-created + (cut-over) item throws at LiveEntityRuntime.cs:655's residence-change + guard ("cannot change its materialization residence from + AwaitRuntimePlacement to LegacyImmediate") because the attach path passes + the default LegacyImmediate — while the flip's own materializer comment + (DatLiveEntityProjectionMaterializer.cs:712-722) states equipped children + must carry LegacyImmediate so a later drop-to-world stays legacy "by + construction". Found via ParentSupersession_CompletesAtExactAttachedReadyBoundary; + production-reachable (pickup → CreateParent → TryAttach with a retained + sidecar). This completes the flip's documented design, not new invention. +2. src/AcDream.App/World/LiveEntityRuntime.cs (RebucketLiveEntity, + residence-managed branch): while the initial-create residence is still + ACTIVE, the presentation-only move now refuses (returns false) — the + completion receipt is the entity's first world-visible moment (clauses + 1/3). The inherited flip had made the presentation-only move + unconditional, re-opening the mid-registration reentrancy hazard pinned + by RuntimePlacementPendingMaterialization_OwnsResourcesWithoutPublishingResidence + (a resource-registration observer could install a bucket for a suppressed + record before its placement committed). A STALE residence lazily retires + inside the same query, so post-residence legacy moves are unaffected; the + completion-receipt path calls RebucketLiveEntityPresentationOnly directly + and never crosses this gate. + +### Continuation 4 — additional fixture repairs (assertions verbatim) +6. LiveEntityLifecycleStressTests fixture — generation bind on its private + lifetime. 7. LiveSessionResetPlanTests.GraphicalResetHost — entity seeded + through the legacy direct RegisterEntity (session-less GameRuntime has + generation 0; the subject is reset/teardown retry, not the create flow). +8. CurrentGameRuntimeAdapterTests.GraphicalObserverFailure — session started + first (its siblings' existing pattern). 9. Hydration RecordingMaterializer + — mirrors the production materializer's self-projection branch (committed + cell + no active residence → presentation-only rebucket); fixed + PositionAfterPickup/PositionAfterInventoryOnlyCreate spatial-projection + truth. 10. PartialProjection_IsRetriedInsteadOfMistakenForCompletedHydration + — models the production per-frame pump (FirstEntry.DriveAll) before the + streaming recovery (the failed Create unwound before OnCreateCore's own + pump; an undriven residence leaves FullCellId 0 and streaming candidates + key off the committed cell). 11. LiveEntityRuntimeFixture.CreateDriven — + NEW driven variant (collision generation + engine landblock + production + RuntimeFirstEntryDriveController + ack-only subscription mirroring host + rules); applied to InitialChildCreate_PreservesParentEventQueued..., + PositionAfterPickup_RequiresTeleportHookEvenWithEqualTeleportStamp, and + PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder. + +### Checkpoint: FULL App suite GREEN — 4,028 passed / 0 failed / 3 skipped +### of 4,031 (Release). Next: Runtime + Headless re-verification, then the +### five contract integration tests. + +### Continuation 4 — five contract integration tests (all green, real host +### wiring: registration -> subscription -> RuntimeFirstEntryDriveController; +### no hand-called conductor sequences) +NEW tests/AcDream.App.Tests/World/RuntimeFirstEntryHostIntegrationTests.cs +(fixture: real RuntimeEntityObjectLifetime + generation + committed collision +generation + LiveEntityHydrationController.OnCreate route + production +RuntimePlacementPresentationSink behind the real +RuntimePlacementProjectionSubscription + real drive controller + local-player +publication chain; materializer is the production-mirroring double): +1. InitialCreate_ResidenceConductorReceipt_BindsWorldVisibilityExactlyOnce — + residence begins once, sidecar provably suppressed at materialize time, + conductor completes inside the Create transaction, exactly one visibility + edge, world snapshot at the committed pose, all ledgers drained. +2. DeferredParentCreate_StaysInvisibleUntilParentReplay — unaddressable-parent + child: no canonical/sidecar/presentation, queued under the parent GUID; + parent Create replays it (real registration delegate), next-frame pump + completes the parented conductor; child celless + presentation-suppressed, + only the parent world-visible. +3. LocalLogin_PresentationAttachFailure_RetriesWithoutRuntimeRollback — the + camera/shadow-analog App attach failure (first visibility binding throws) + AFTER the Runtime commit: published PlayerMovementController + (IsRuntimePublished) + canonical body + committed cell all survive, the + completion receipt stays pending, RetryPending() binds presentation with + the SAME controller instance. NOTE: the literal PlayerModeController + camera object is not constructed here (its ~20-dependency graph has no + focused harness); its presentation-only rollback is pinned by the updated + UpdateFrameOrchestrator source assertions + this receipt-level analog. +5. GraphicalAndDirectHosts_CommitIdenticalFirstEntryRuntimeFacts — the same + spawn through the full graphical wiring vs the no-window direct host + shape (RegisterEntityWithInitialResidence + pump + ack-only subscription) + commits byte-identical canonical first-entry facts (cell, versions, body + pose/state/InWorld, snapshot sequence, local id). +NEW in tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs: +4. MissingPreparedCollisionYieldsTypedRetryAndCompletesWhenAvailable — flaky + IPreparedCollisionSource (Missing -> Loaded) through the REAL + HeadlessSessionWorldProjection.ProjectSpawn pump: no exception escapes, + entity stays tracked/pending with no controller and cell 0, the session + tick's retry pump completes placement + publishes the controller. +Totals after the tests: Headless 78/78 (77+1); App integration class 4/4. + +### Continuation 4 — gate ladder +1. Complete test projects (Release): App 4,032 passed / 0 failed / 3 skipped + of 4,035 (4,031 + 4 new integration tests); Runtime 982/982; Headless + 78/78 (77 + 1 new). GREEN. +2. dotnet build AcDream.slnx -c Release --nologo: 0 errors, 18 warnings + (pre-existing test-project warnings; none in files this session touched). + GREEN. +3. Complete solution (-m:1, ACDREAM_PAK_PATH=C:\Users\erikn\Documents\ + Asheron's Call\acdream.pak): IN FLIGHT (background). +4. Connected gates: ACE confirmed listening on UDP 9000 (PID 22100), + C:\ACE\Server\ACE_Log.txt present, no AcDream.App/acclient processes + running. Will run after gate 3. + +### Continuation 4 — gate ladder RESULT (stopped at gate 4 per pinned order) +3. Complete solution (Release, -m:1, ACDREAM_PAK_PATH): GREEN — App 4,032/3 + skips (4,035), Bake 15, Cli 4, Content 124, Core.Net 762, Core 4,242/1 + skip (4,243), Headless 78, Runtime 982, UI.Abstractions 543 = 10,782 + passed / 0 failed / 4 skipped of 10,786. +4. CONNECTED tools/run-connected-world-lifecycle-gate.ps1: **FAIL — STOPPED + HERE.** Artifacts: logs/connected-world-gate-20260802-122749/ + (report.json Passed=false; capped/ + uncapped-reconnect/ each with + stdout.log, stderr.log, artifacts/). Both sessions connected, entered + world as 0x5000000A, ran the 54-command UI probe, requested AND received + graceful logout confirmation, then CRASHED identically with an unhandled + System.InvalidOperationException: "A sealed, retired, or discarded + Runtime movement controller cannot be mutated." + EXACT CHAIN (identical in both sessions): + PlayerMovementController.EnsureConfigurationMutable + (src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:859) + <- SetCharacterSkills(:1248) + <- RuntimeMovementSkillProjection.ApplyTo( + src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs:21) + <- LiveSessionRuntimeFactory.ApplyMovementStats( + src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:327; recompute + callback registered at :300 in CreateCharacterBindings) + <- LiveSessionEventRouter.RecomputePlayerQualities( + src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:406) + <- ClientObjectTable.Ingest(WeenieData) + <- ObjectTableWiring.ApplyEntitySpawn + <- RuntimeEntityObjectLifetime.ApplyAcceptedSpawn(:926) + <- LiveEntityHydrationController.OnCreateCore(:298) — an ordinary + inbound Create datagram, processed AFTER the graceful-logout + confirmation (stdout timeline: probe -> logout confirmed -> + post-logout stat-chain recomputes -> crash). + DIAGNOSIS (report-only, no fix attempted): the flip made the local + movement controller Runtime-published with a SEALED configuration + lifecycle (mutable only pre-publication; retired at teardown). The + character-bindings quality-recompute subscription + (LiveSessionRuntimeFactory.CreateCharacterBindings -> + ApplyMovementStats -> controller.SetCharacterSkills) still mutates the + controller directly on EVERY player-quality recompute; once the + controller is sealed (published) or retired (logout teardown), that + mutation throws. This is exactly the review-focus item "Sealed + Controller setter: audit every compile break's fix — each must route + through the publication lifecycle" — this site was missed because it is + a RUNTIME mutation (EnsureConfigurationMutable), not a compile break. + A fix must decide where server skill updates route post-flip (e.g. the + Runtime movement-state seam / construction options at first entry + + a Runtime-owned live-skill channel), which is a production design + decision outside this session's mechanical remit — per the pinned gate + order the session STOPS at this failure and reports. + Secondary observation from the same logs (likely the same defect class, + recorded for the fixer): after the unhandled exception the shutdown path + reported "status=AbandonedIncomplete, blocked=native window" with + Silk.NET "You cannot call `Reset` inside of the render loop!" — a + crash-path artifact, not an independent bug. + The nine-stop soak (tools/run-connected-r6-soak.ps1) was NOT run (gated + behind the lifecycle gate's pass). +- Nothing staged, no commits at any point; HEAD remains 78f1eb18; the 7 + protected dirty paths + AGENTS.md carry only their inherited state + (AGENTS.md the sole real pre-existing content diff; PlayerModeController + touched only by the inherited sanctioned flip surgery — this session + changed only the marker TEST for it, not the file). + +## C3c-F1 — movement-stat application through the Runtime seam +### Candidate-window investigation (resolved with evidence) +- The crash state was RuntimeOwnedDormant, not retired: the failing gate run's + stdout shows the login world-reveal never completed (collision=False at every + readiness line; event=cancel at logout with completed=False) — first-entry + activation stayed DeferredCell for the whole session, so + RuntimeLocalPlayerMovementState held the dormant controller when the + post-logout ingest recompute fired. EnsureConfigurationMutable throws for + dormant (PlayerMovementController.cs:848-861: only Standalone/ + CandidatePreparing/RuntimePublished/dormant+groundPhase return). +- CandidateSealed is UNREACHABLE through the seam: the sealed candidate is + never installed into the movement owner (CanPrepare requires + `_movement.Controller is null`, RuntimeLocalPlayerPhysicsPublicationState.cs:908; + IsCurrent requires CanCommitRuntimeOwnedController(epoch, null), :937-939), + and Prepare→Commit runs back-to-back inside one synchronous Advance step + (RuntimeLocalPlayerFirstEntryState.cs:395-431) with no callback dispatch + between (Prepare's construction is callback-free by design, publication + state :319-334). +- RuntimeOwnedDormant IS reachable across pump iterations: Commit installs + the dormant controller (publication state :436) and Evaluate/CommitActivation + DeferredCell yields AwaitingActivation with stage kept + (RuntimeLocalPlayerFirstEntryState.cs:493-497) — inbound quality events pump + between Advance calls. DECISION: apply-immediately-to-dormant (not + defer-at-commit) — the dormant instance IS the controller that goes live + (ActivateRuntimePublication at RuntimeSetPositionState.cs:2464 flips the + same object), the writes touch only PlayerWeenie fields + the mover-flag + latch (nothing the activation envelope validates), and the precedent is the + existing dormant channels RefreshDormantRuntimePhysicsState/Vector + (PlayerMovementController.cs:735-759) that land accepted server facts on + the dormant owner mid-window. Deferring would need an activation hook and + would leave the weenie stale for the activation ground-phase dispatch. +### Implementation +- PlayerMovementController: internal ApplyCharacterMovementStats(in snapshot) + — lifecycle switch (live/dormant apply, terminal typed drop) + private + ApplyCharacterMovementStatsCore (verbatim body of the deleted + RuntimeMovementSkillProjection.ApplyTo, field writes not gated setters) + + internal ReportExhaustionAtMovementBoundary (live-only exhaustion dispatch). +- RuntimeLocalPlayerMovementState: public ApplyCharacterMovementStats( + RuntimeMovementSkillState) + public ReportExhaustion() + public enum + RuntimeMovementStatsApplication {AppliedLive, AppliedDormant, + DroppedNoController, DroppedIncompleteSnapshot, DroppedDisplacedController}. + Disposed-owner tolerant (typed drop, not ObjectDisposedException) per J3.6. +- DELETED src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs + (zero remaining consumers). +- NEW src/AcDream.App/Net/LiveMovementStatsApplier.cs — owns the + StaminaExhaustionEdgeTracker + logging; observes the exhaustion edge for + both applied outcomes, dispatches only AppliedLive (dormant: no in-flight + movement; activation reads the current stamina gate). +- LiveSessionRuntimeFactory: constructs the applier; OnSkillsUpdated/ + OnMovementStatsUpdated route through it; ApplyMovementStats + + _staminaExhaustion deleted; ResetPlayerPresentation resets the applier. + App keeps zero direct configuration mutations on the stats path. +### Tests (all green at write time) +- Runtime (7 new in RuntimeLocalPlayerMovementStateTests): live byte-identity + vs the old direct path (InqRunRate/InqJumpVelocity/CanJump/JumpStaminaCost/ + OwnPvpFlags), dormant-window write lands on the instance that goes live, + terminal typed drop with unchanged observables + setter-still-throws, + sealed-candidate defensive row, absent/incomplete drops, disposed-owner + tolerance, ReportExhaustion lifecycle gating. 19/19 file total. +- App (3 new in LiveMovementStatsApplierTests): the REAL crash chain + (real WorldSession + LiveSessionEventRouter + ClientObjectTable.Ingest of + the player row + real applier callback) against a retired-installed + controller — no throw, typed displaced drop logged; dormant-window ingest + applies + values current at activation; absent/incomplete silent skips. +### Connected gate run 1 (post stats-fix): FAIL — second site of the same class +- logs/connected-world-gate-20260802-125907: BOTH sessions confirmed graceful + logout, then crashed on the SAME EnsureConfigurationMutable throw via the + OTHER App-side mutation my sweep had already classified residual-unsafe: + LiveEntityNetworkUpdateController.OnState:1006 → + PlayerMovementController.ApplyPhysicsState(:366) at the dormant controller + (post-logout inbound SetState; the login reveal never completes in this + gate profile so the first-entry controller stays dormant all session — + same as the original 122749 failure). The stats seam itself WORKED: the + stdout shows repeated "player: applied server movement stats run=10205..." + dormant applications with no ingest crash. +- Disposition per the sweep clause ("route each through the seam or report + why it is already lifecycle-safe" — this site cannot be reported safe): + routed through the same owner seam pattern. NOT a guard at the throw site: + the lifecycle decision moved into the owner. +### Second routing (inbound local-player SetState) +- PlayerMovementController.ApplyServerPhysicsState (internal, typed): + live → exact ApplyPhysicsState body; dormant → + DroppedDormantActivationOwned (the activation transaction re-reads the + canonical FinalPhysicsState itself via RefreshDormantRuntimePhysicsState + at both activation phases, and while the accepted SetState is queued + behind the initial residence the App push carries that same UNCHANGED + record value — RuntimeEntityObjectLifetime.TryApplyState:1351-1378 queues + without advancing the record — so the drop is value-preserving by + construction); terminal → DroppedDisplacedController. New enum + RuntimeServerPhysicsStateApplication beside the stats enum. +- LiveEntityNetworkUpdateController.cs:1005-1018 routes through the typed + entry (result discarded; comment records both gate crash chains). +- Tests: Runtime ApplyServerPhysicsState_DormantDropsForActivationAndLiveAppliesExactly + (990/990 Runtime); App source pin C3cF1ProductionWiringTests. + LocalPlayerInboundSetState_RoutesThroughTheTypedOwnerEntry (no direct + ApplyPhysicsState caller remains in the file). App 4,036/3 skips of 4,039; + Headless 78/78. +### Connected gate run 2 (130455) — FAIL, root cause classified INHERITED; STOPPED per directive +- Both sessions ran CRASH-FREE (0 unhandled exceptions; capped alive 7+ min + until my graceful WM_CLOSE, uncapped alive the full 420 s timeout) — the + F1 crash-class fixes hold live. 418 login entities + 10,384 total ingested + cleanly with 158 dormant stat applications. +- Harness failures: capped "client exited waiting for probe complete" (my + directed close), uncapped "timed out after 420 s waiting for probe + complete". +- Wedge mechanism (link-by-link): + 1. First-entry conductor Prepares+Commits the DORMANT controller within + the first frames (stats lines from stdout line 62). + 2. Activation stays DeferredCell; even after streaming collision readiness + completes (~40 s, reveal line 223 collision=True ready=True) the + activation/publication never commits — PlayerModeController logs ONE + "Runtime first-entry controller ... not committed yet" (line 224, + PlayerModeController.cs:247-250) and player mode never enters. + 3. Reveal (kind=Login) completes on readiness alone with + materialized=False (RuntimeWorldTransitState.Complete:685-720 requires + Materialized only for Portal kind), then per-frame readiness + re-acknowledgements spam event=rejected reason=readiness-after-terminal + (9,000+ lines). + 4. AcknowledgeWorldViewportVisible never fires (visible=False forever) → + probe line 5 "timed out waiting for normal world viewport" + (RetailUiAutomationScriptRunner.cs:308-313; route line 4-5 + tools/connected-world-lifecycle.route.txt) → probe never prints + complete → harness fails. +- NOT an F1 regression — three proofs: + 1. 122749 (zero F1 changes) fails with the IDENTICAL harness failure + string ("client exited waiting for probe complete", report.json), has + NO probe-complete/checkpoint/player-mode lines, and its own crash + (SetCharacterSkills throwing) proves the controller was never published + there either. + 2. 122749's login was WORSE pre-F1: the per-Create ingest-recompute throw + killed the process 26 s in (StartedUtc→FinishedUtc) with only 1 entity + seen vs 418/10,384 under F1 — the coordinator's "122749 entered world + normally" premise is contradicted by its own artifacts. + 3. The F1 write set cannot affect activation currency: the stats core + touches only PlayerWeenie fields + the OwnPvpFlags latch; the + activation envelope checks lifecycle/epochs/body identity only + (RuntimeLocalPlayerPhysicsPublicationState.cs:961-992, :747-775); the + DeferredCell decision is RuntimeSetPositionState cell-residency + machinery untouched by this slice. +- Residual root cause home: the C3c first-entry activation never resolves + its deferred destination cell in the graphical host (or its pending drive + entry stops being re-armed) — RuntimeLocalPlayerFirstEntryState.cs:433-501 + (PublicationCommitted → EvaluateActivation/CommitActivation DeferredCell + loop) against RuntimeSetPositionState's dormant-activation cell gate. + That is conductor/publication semantics OUTSIDE the F1 contract → + STOPPED, no fix attempted, per coordinator directive 3. +- Client terminated via graceful-close discipline (WM_CLOSE → logout + confirmed by ACE, clean [session] lines, no crash); harness concluded and + recorded FAIL; artifacts preserved at + logs/connected-world-gate-20260802-130455/. + +## C3c-F2 — the login DeferredCell activation wedge + +### Step 1 — artifact re-verification (the pinned contract's evidence chain is +### WRONG on run 122749; both prior classifications were partly unsound) +Read directly from the primary artifacts, not from either prior report: +- 122749 (flip WITHOUT F1), capped/stdout.log is 67 lines TOTAL. It shows + `[UI-PROBE] running 54 UI probe command(s)` (L52) — that line is the probe + ANNOUNCING its command count, not 54 commands completing. The reveal shows + ONLY `event=begin` (collision=False) and ONE `event=readiness` + (collision=False, ready=False, materialized=False, visible=False), then + `event=cancel ... visible=False`. The smoke plugin saw 1 entity total. + report.json StartedUtc -> FinishedUtc = 26 SECONDS for BOTH sessions. + => the contract's "world became visible / probe's `wait world-visible` + passed / 54-command probe ran" is falsified. The world was NEVER visible in + 122749; the process died ~9 s after entering world from the F1 crash, i.e. + BEFORE the ~40 s collision-readiness edge where the wedge manifests. +- Consequence: 122749 proves NOTHING either way about post-readiness + activation (it never got there). The F1 agent's "identical harness failure + string" proof is equally weak (identical string, different causes: crash vs + timeout). Both prior attributions are unsound; attribution has to come from + CODE, not from these two logs. +- 130455 (flip WITH F1) is the only run that reaches the readiness edge: + L223 readiness collision=True ready=True; L224 `not committed yet` (ONE + line); L225 `event=complete ... materialized=False`; then 5,577+ + `readiness-after-terminal`; probe L1193 `wait world-visible 30000` timeout. + +### Step 2 — the contract's primary suspect is STRUCTURALLY disproven +F1's sweep change (LiveEntityNetworkUpdateController.cs:1005-1018) passes +`record.FinalPhysicsState` into the typed owner entry. The activation cell +gate reads that SAME canonical value directly off the record — +RuntimeSetPositionState.cs:1747-1752 builds `canonicalRequest` with +`MoverPhysicsState = record.FinalPhysicsState`. The activation path never +reads the controller's copy. A drop on the CONTROLLER therefore cannot +deprive the activation of anything, "value-preserving" or not. The primary +suspect cannot produce a DeferredCell wedge. Contract constraint 3's +"retained-to-activation admission" is not the fix; the real defect is below +and is entirely inside C3c-flip code that F1 never touched. + +### Step 3 — VERIFIED MECHANISM, link by link +L1. Login: the first-entry conductor prepares the authored mover and + Prepare+Commits the publication in one synchronous step + (RuntimeLocalPlayerFirstEntryState.cs:395-431). The controller is now + RuntimeOwnedDormant (RuntimeLocalPlayerPhysicsPublicationState.cs:433-436) + — confirmed live by the 158 `player: applied server movement stats` + dormant lines in 130455. +L2. Stage PublicationCommitted -> EvaluateActivation -> engine SetPosition + (RuntimeSetPositionState.cs:1770). The destination landblock's collision + is still being published (the reveal reports collision=False for ~40 s), + so the result is DEFERRED. +L3. CommitActivation -> TryApplyDormantLocalActivationCommit PARKS the + operation (RuntimeSetPositionState.cs:2066-2107): Stage=AwaitingCell, + WakeableLostCell=true, CollisionGenerationReady=false, and + `CollisionGeneration = prepared.DeferredCollisionGeneration`, computed at + :1939-1940 as `_physics.ExpectedCollisionGeneration(cellId)` = the + IN-FLIGHT admission's generation G (RuntimePhysicsState.cs:2934-2939). + Bucket key = (cell, prefix, G). +L4. ~40 s later the landblock's collision generation commits. + RuntimePhysicsState.cs:2503 calls + `SetPosition.CommitCollisionGeneration(lb, G, ready:true)`, which finds + bucket (cell, prefix, G), verifies `IsSpawnCellReady`, and sets + `operation.CollisionGenerationReady = true` + (RuntimeSetPositionState.cs:3886). The push-side `RetryDeferred` is + deliberately a no-op for this operation — it returns immediately when + `operation.DormantLocalActivation` is set (:4044-4045) by design: the + local-player lease must re-enter through the sealed evaluation/commit + path. So the ONLY door left is the PULL-side rearm on the conductor's + next Advance. +L5. Immediately after :2503, `AdvanceCommittedActivation` REMOVES the + admission from `_collisionAdmissions` (RuntimePhysicsState.cs:2552-2558) + while leaving `_collisionGenerations[lb] == G` (set at + BeginCollisionAdmission, :2066). From this instant on, + `ExpectedCollisionGeneration(cellId)` no longer returns G — with no + admission it returns `_collisionGenerations[lb] + 1` = G+1 + (RuntimePhysicsState.cs:2940-2944). +L6. THE DEFECT. The next Advance reaches + `TryRearmDeferredDormantLocalActivation` + (RuntimeSetPositionState.cs:1851-1882), whose gate includes + `operation.CollisionGeneration != _physics.ExpectedCollisionGeneration( + operation.ExactCellId)` (:1870-1871) -> `G != G+1` -> rearm refused, + permanently. `ExpectedCollisionGeneration` means "the generation that + will make me ready" at PARK time and "the next, not-yet-begun generation" + at WAKE time; the rearm compares against the wrong one. Nothing else ever + clears WakeableLostCell, so the operation is wedged for the session. +L7. EvaluateActivation's fallback then reports DeferredCell forever + (publication state :469-476 -> IsDormantLocalActivationAwaitingCell true), + the conductor yields AwaitingActivation and keeps its pending entry + (first-entry state :493-497), the controller never activates, and + `IsRuntimePublished` stays false -> PlayerModeController.cs:243-251 logs + "not committed yet". +L8. SECOND LINK (flip-introduced, independent). `PlayerModeAutoEntry.TryEnter` + sets `_armed = false` BEFORE invoking EnterPlayerMode + (PlayerModeAutoEntry.cs:227-228) — a one-shot. Its + `IsPlayerControllerReady` precondition in the PRODUCTION context is the + constant `true` (PlayerModeAutoEntry.cs:86). That was harmless pre-flip + because TryEnter CONSTRUCTED the controller and could not fail on it + (deleted block, `git diff src/AcDream.App/Input/PlayerModeController.cs`); + post-flip TryEnter returns false when the conductor has not committed. So + the single shot is burned on the exact frame readiness flips, and + `LivePlayerModeAutoEntryContext.EnterPlayerMode` calls + `_worldReveal.Complete()` UNCONDITIONALLY (PlayerModeAutoEntry.cs:97-101) + — sealing the reveal (materialized=False) and producing the 5,577 + readiness-after-terminal rejections. The flip's own comment ("auto-entry + retries on a later frame", PlayerModeController.cs:242) is false as + written. Ordering note: UpdateFrameOrchestrator.cs:201-207 runs streaming + -> live frame (DriveAll) -> auto-entry, so with L6 fixed the same frame + would usually succeed — but "usually" is a race, and the log proves a + failed attempt burns the shot and seals the reveal. + +### Why the existing rearm test never caught L6 +RuntimeLocalPlayerPhysicsPublicationStateTests +.DeferredCommitWaitsThenRearmsSameLeaseAfterExactGenerationWake (:337-383) +wakes the operation by calling `SetPosition.BeginCollisionGeneration` / +`CommitCollisionGeneration` DIRECTLY, bypassing RuntimePhysicsState's +admission ledger. `_collisionAdmissions` and `_collisionGenerations` therefore +stay EMPTY, so `ExpectedCollisionGeneration` returns 1 at both park and wake +and the identity check accidentally holds. Production always goes through +BeginCollisionAdmission -> CommitCollisionGeneration, which is exactly the +path that breaks it. + +### The fix (2 hunks) +F2-1 (root cause, Runtime): the rearm's generation identity check compares +against the LIVE collision generation authority +(`_physics.CollisionGenerationAuthority`, RuntimePhysicsState.cs:2953-2962 = +`_collisionGenerations[lb]`), not `ExpectedCollisionGeneration`. Post-commit +that is exactly G, so the parked lease rearms. Every stale case still +refuses: a superseding admission or a cancel bumps `_collisionGenerations` +away from G. Park-side semantics (:1939) untouched — "park against the +generation that will make me ready" is the established convention shared with +the remote ParkDeferred path (:3974). No latch is loosened: WakeableLostCell, +CollisionGenerationReady and IsSpawnCellReady remain mandatory. +F2-2 (second link, App): `LivePlayerModeAutoEntryContext +.IsPlayerControllerReady` stops lying — it reports the exact precondition +PlayerModeController.TryEnter enforces (Runtime-published controller + +committed EntityPhysicsHost). The one-shot latch, the reveal latch, and the +readiness-after-terminal rejection are all UNCHANGED; the trigger simply +cannot burn its shot before the conductor has committed, so +`_worldReveal.Complete()` runs only on a real entry. PlayerModeController.cs +is NOT touched by this fix. + +### Step 4 — LIVE PROBE CORRECTION (temporary attributed probe, since stripped) +The Step-3 analysis was right about the parts but wrong about which link fires +first. A temporary change-only probe on the rearm gate terms and the drive +controller's local status (env-gated, stripped before the gate) was run against +ACE. Evidence, run `logs/c3c-f2-probe.out.log` (F2-1 only, no admissibility +term): +``` +L61 [c3cf2-rearm] ... ready=False ... gen=1 auth=0 expected=1 spawnReady=True +L220 [c3cf2-rearm] ... ready=False ... gen=1 auth=1 expected=1 spawnReady=True +L221 [c3cf2-rearm] ... ready=True ... gen=1 auth=1 expected=1 spawnReady=True +L222 [c3cf2-drive] local status=RejectedAuthority step=0 pending=59 +``` +- L61: the lease parks at generation 1 with NO admission and NO committed + generation yet (auth=0), i.e. `ExpectedCollisionGeneration`'s 1UL default. +- L221: the collision-generation commit marks it ready — and `expected` is + STILL 1, proving the admission is still registered at that instant: the + commit reenters the host's first-entry pump between + RuntimePhysicsState.cs:2503 and the retirement at :2552-2558. +- L222: the rearm succeeds inside that window, the immediately following + evaluation fails `TrySealCollisionEvaluationAuthority` on the still- + registered admission, and because the lease is no longer AwaitingCell, + EvaluateActivation answers RejectedAuthority — TERMINAL. The conductor + discards and the drive entry is dropped for the session. +=> LINK 3 (the reentrant-window rearm) is the DOMINANT live blocker, and it +fires BEFORE the L6 generation mismatch can. L6 is still real and still +load-bearing — see the next run. + +Second live run with both terms (`logs/c3c-f2-probe2.out.log`): +``` +L223 [c3cf2-rearm] ... ready=True gen=1 auth=1 expected=1 -> refused (window) +L224 [c3cf2-rearm] ... ready=True gen=1 auth=1 expected=2 -> REARMED +L225 [c3cf2-drive] local status=Completed step=0 pending=2 +L230 live: auto-entered player mode for 0x5000000A +L231 [world-reveal] event=complete ... +L234 [world-reveal] event=world-visible ... visible=True +``` +L224 is the direct proof that F2-1 is load-bearing: at the frame the rearm +actually happens the admission is gone, so `expected` is 2 and only the +committed authority still equals the parked generation 1. Zero +readiness-after-terminal lines in the whole run. + +### Final fix (3 hunks, all required) +F2-1 RuntimeSetPositionState.TryRearmDeferredDormantLocalActivation — compare +the parked generation against `CollisionGenerationAuthority` (the generation +the collision world HOLDS) instead of `ExpectedCollisionGeneration` (which +means "the next, not-yet-begun generation" once the admission retires). +F2-3 same method — refuse the rearm while the destination prefix is not +evaluable, using the seal's own predicate, now factored as +`RuntimePhysicsState.IsCollisionEvaluationPrefixAdmissible` and consumed by +BOTH the seal and the rearm so they cannot drift. This is the same shape the +remote wake path already had via `TryGetBlockingQuiescence` (:4069-4095). +F2-2 LivePlayerModeAutoEntryContext.IsPlayerControllerReady — report the +Runtime first-entry commit instead of the constant `true`, so the one-shot +guard cannot burn its single attempt (and unconditionally complete the world +reveal) before the conductor has published the controller. + +### Tests +- NEW RuntimeLocalPlayerPhysicsPublicationStateTests + .DeferredCommitRearmsAfterProductionAdmissionCommitsItsGeneration — pins + F2-1; fails pre-fix. +- NEW RuntimeLocalPlayerPhysicsPublicationStateTests + .DeferredCommitStaysParkedWhileTheCommittingAdmissionIsStillRegistered — + pins F2-3; pre-fix it fails with the exact live symptom + (`Expected: DeferredCell / Actual: RejectedAuthority`). +- NEW tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests + .ProductionAutoEntryRequiresTheRuntimePublishedController — pins F2-2 + (source pin; the production context's ~15-dependency graph has no focused + harness, matching the C3c-F1 precedent). +- CONVERTED to the production wake path (assertions verbatim; only the wake + DRIVER changed, from the raw SetPosition seam to the collision admission + ledger, because the raw seam leaves the ledger empty — a state production + can never reach, and precisely why these tests missed the wedge): + RuntimeLocalPlayerPhysicsPublicationStateTests + .DeferredCommitWaitsThenRearmsSameLeaseAfterExactGenerationWake, + .DeferredAuthoredActivationSuspendsRowsAndExactWakeRestoresThem, + RuntimeLocalPlayerFirstEntryStateTests + .AwaitingActivationRetriesWhileCellUnresolvedThenResumesAfterGenerationWake, + .DeleteAndSameGuidReincarnationAutomaticallyFreesThePublicationSlotForTheFreshIncarnation. + All four fail pre-fix once converted. + +### Gates +Runtime 992/992; App 4,037/0/3 skips; Headless 78/78; Release solution build +0 errors / 18 pre-existing test-project warnings; complete solution (-m:1, +ACDREAM_PAK_PATH) 10,797 passed / 0 failed / 4 skipped of 10,801; +`git diff --check` clean. + +CONNECTED GATE: logs/connected-world-gate-20260802-135444 — RESULT=FAIL, but +NOT on the wedge, which is gone in both sessions: +- capped: login reveal reached world-visible, the probe captured checkpoint + `capped_login` AND screenshot `capped_login.png`, then teleported + (`old lb=(169,180) new lb=(9,4)`), and generation 2 (kind=Portal) reached + materialized=True, visible=True, completed=True. 17,043 entities ingested. +- uncapped-reconnect: login at cell 0x09040008 reached visible=True, + completed=True. +- Zero `readiness-after-terminal` lines, zero "not committed yet", zero + F1-class controller-mutation crashes in either session. + +### BLOCKER (pre-existing, out of C3c-F2 scope) — map-corner landblock +Both sessions then died identically: +`System.ArgumentOutOfRangeException (Parameter 'landblockId')` at +RuntimeSetPositionState.BeginCollisionPrefixQuiescence +(src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs:783) +<- RuntimePhysicsState.BeginCollisionPrefixQuiescence(:1993) +<- RuntimePhysicsState.CommitCollisionGeneration(:2432) +<- LandblockPhysicsPublisher.AdvanceCompleteOne + (src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:626) +<- LandblockPresentationPipeline.Advance <- StreamingController.Tick. +Mechanism: the teleport destination is landblock (9,4). The far streaming +radius is 12 and StreamingRegion only SKIPS out-of-range indices +(`nx < 0 || nx > 0xFF`, StreamingRegion.cs:80/117/155/225) — it does not skip +(0,0) — so the window includes landblock id `(0<<24)|(0<<16)|0xFFFF` = +0x0000FFFF. `CanonicalLandblock` keeps that as 0x0000FFFF, and +BeginCollisionPrefixQuiescence computes `prefix = landblockId & 0xFFFF0000` += 0x00000000 and throws its `prefix == 0u` guard (:781-783). Dereth's +south-west corner landblock therefore cannot be collision-published, and any +position within the far radius of it crashes the client. +This code is untouched by the C3c flip, by C3c-F1, and by C3c-F2 (`git diff +src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` contains no change to +BeginCollisionPrefixQuiescence); it was simply unreachable while login itself +was wedged — 130455 never left Holtburg and never teleported. +NOT fixed here, and deliberately not a one-line guard removal: prefix 0 is +also overloaded as an "absent prefix" sentinel in the same class — e.g. +ParkDeferred derives `operation.CollisionQuiescenceHeld = collisionPrefixOverride +!= 0u` (:3975-3978), so a genuine landblock-(0,0) quiescence override would be +read as "no override". A correct fix needs an explicit has-prefix flag (or a +nullable prefix), which is a physics-ownership design change outside this +contract. Recommend a dedicated slice. + +## C3c-F3 — corner-landblock prefix-0 sentinel conversion + +### Chain audit (complete, every prefix-sentinel site classified) +CONVERTED (the has-prefix representation is nullable uint for the override +pair + OperationId-based token presence + explicit landblockId==0 absent-id +input guards): +1. RuntimeSetPositionState.cs RuntimeCollisionPrefixQuiescenceToken.IsValid + (:112) — dropped `LandblockPrefix != 0u`; presence now discriminated by + OperationId != 0 (monotonic from 1) + CollisionGeneration != 0 (from 1). + This was load-bearing beyond the crash site: a real prefix-0 token read + as invalid wedged TryGetCurrentQuiescence (:839/:887/:905 callers), + permission currency (:886), and TryGetBlockingQuiescence's `excluded` + term (:3617 — RetryDeferred's restoringQuiescence would have blocked + itself). +2. RuntimeSetPositionState.BeginCollisionPrefixQuiescence (:782) — the + crash-site `prefix == 0u` throw replaced by `landblockId == 0u` (absent + id), prefix computed unconditionally. +3. RuntimeSetPositionState.ParkDeferred (:3977-78, :4013-19) — override + params converted `ulong collisionGenerationOverride = 0UL, uint + collisionPrefixOverride = 0u` -> `ulong?/uint? = null`; + CollisionQuiescenceHeld = collisionPrefixOverride.HasValue. Both + quiescence-token call sites (:2842, :2893) pass values unchanged + (implicit lift); byte-identical for nonzero prefixes. +4. RuntimeSetPositionState.ParkCollisionResidents (:3414-17) — `?? 0UL` / + `?? 0u` collapse removed; `quiescence?.Token.X` now flows null/value. +5. RuntimePhysicsState.BeginCollisionPrefixQuiescence (:1991) — dead + `canonical == 0u` (CanonicalLandblock ORs 0xFFFF, never 0) replaced by a + LIVE `landblockId == 0u` guard. +6. RuntimePhysicsState.AdvanceCollisionRetirementMutation (:2626) — same + dead-guard replacement. +7. RuntimePhysicsState.BeginCollisionAdmission (:2037) — NEW landblockId==0 + guard at the admission entrance: an absent id canonicalizes to + 0x0000FFFF (the REAL corner landblock), and the old accidental + commit-time protection (the prefix throw) is gone. +8. ShadowObjectRegistry.DeriveOutdoorSeed (:651, Core) — `lbPrefix == 0u -> + no seed` replaced by `landblockId == 0u`; corner-block baked statics now + derive real seeds 0x0000000N (previously silently dropped from the + shadow world). Same sentinel class, exercised by the corner collision + publication chain. + +KEPT with justification (no collision with prefix 0): +- Generation-0 "unbound" sentinel (RuntimeSetPositionState :3727/:3741-45/ + :3811/:3842/:5135/:5180/:5255): generations allocate from 1 + (checked(current+1), Begin throws on 0) — 0 is unreachable as a real + generation. +- ExactCellId==0 "absent cell" (IndexDeferred/IndexUnboundDeferred/ + UnindexDeferred): cell-part 0x0000 is not a valid cell; corner cells are + 0x00000001+. +- RuntimePhysicsState dead post-canonicalization zero checks + (ExpectedCollisionGeneration :2932, IsCollisionEvaluationPrefixAdmissible + :2963, CollisionGenerationAuthority :2977, TrySealCollisionEvaluationAuthority + Add :3030): CanonicalLandblock never returns 0; harmless dead defensive + terms, prefix-agnostic. (Noted follow-up: CanonicalLandblock(0) aliases + cellId 0 -> 0x0000FFFF in these helpers; unreachable with real + operation cells today, flagged rather than redesigned.) +- Core PhysicsEngine bare-id compat (:1355/:1362 SampleTerrainWalkableInCell, + :2093 HasCellSurface): `requestedPrefix == 0` there means "caller passed a + bare pre-#106 test-fixture id", a different semantic; corner cells resolve + correctly through the world-position filter. Follow-up cleanup candidate, + out of this contract's chain. +- Convention pinned by test: raw input 0x00000000 remains "absent" + everywhere; landblock (0,0) is addressed by canonical 0x0000FFFF (or any + cell inside it) — matching what production streaming always passes. + +### Tests (7 new, all fail pre-fix / pass post-fix; pre-fix run of the +### production-chain test reproduced the EXACT 135444 crash signature: +### ArgumentOutOfRangeException 'landblockId' at RuntimeSetPositionState.cs:783 +### <- RuntimePhysicsState.cs:1993 <- :2432) +New partial tests/AcDream.Runtime.Tests/Physics/ +RuntimeCollisionPrefixQuiescenceTests.CornerLandblock.cs: +- CornerLandblockCollisionGenerationCommitsThroughTheProductionAdmissionChain + (corner 0x0000FFFF + neighbor 0x0001FFFF, empty engine, full + admission->prepare->stage->seal->commit, ownership converged) +- CornerResidentParksAndRestoresAcrossAnActivationReplacement (SetPosition + commit into corner cell; activation replacement parks the resident under + the prefix-0 quiescence override, wakes, restores) +- CornerPrefixQuiescenceHoldsAndReleasesExactlyLikeANonzeroPrefix (contract + test 2: identical held-placement script vs PrefixP, step-for-step log + parity incl. QuiescenceHeld hold/acquire/cancel/restore) +- CornerLandblockDemotesAndWithdrawsThroughRetirementMutations +- AbsentLandblockIdStillCannotBeginQuiescence (id-0 keeps throwing at + quiescence AND at the new admission-entrance guard; corner token IsValid) +tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs: +- Register_CornerLandblock_DerivesRealOutdoorSeed +- Register_AbsentLandblockId_StillKeepsWhenEmpty (passed pre-fix too — + pins the preserved keep-when-empty guard) +Two in-flight test corrections during TDD: seeded corner Place asserts +CommittedHostAcknowledgementPending (the ordinary bound-events commit +status — over-strict first draft); parity run addresses the corner by +canonical 0x0000FFFF, not raw 0 (which is the absent sentinel by design). + +### Gate ladder +1. Focused corner tests: 5/5 Runtime + 39/39 ShadowObjectRegistry suite. + Complete projects: Runtime 997/997 (992+5), App 4,037/3 skips of 4,040, + Headless 78/78. +2. Release solution build: 0 errors. Complete solution (-m:1, PAK): running. +2 (cont). Complete solution (-m:1, ACDREAM_PAK_PATH): 10,804 passed / 0 + failed / 4 skipped (App 4,037/3, Bake 15, Cli 4, Content 124, Core.Net + 762, Core 4,244/1, Headless 78, Runtime 997, UI 543). +3. Connected gate: run logs/connected-world-gate-20260802-142539 launched + against live ACE (UDP 9000 confirmed listening, no stale client); result + pending. +4. git diff --check: clean (line-ending metadata warnings only, matching the + known worktree pattern). Nothing staged. +3 (result). Connected gate PASS: logs/connected-world-gate-20260802-142539/ + report.json Passed=true, Failures=[], both sessions exit 0, one warning + ("capped: 25 expected world-edge landblock miss(es)" — the expected + class). The 135444 corner crash is gone: the capped session completed the + full route (capped_login, facility_hub, aerlinthe_first, rynthid, + holtburg_after_dungeon, aerlinthe_revisit checkpoints + screenshots + under capped/artifacts/), uncapped-reconnect completed + uncapped_reconnect. Coordinator confirmed and directed the soak. +5. R6 soak launched (run-connected-r6-soak.ps1, same ACE, detached PID + 14036); result pending. +5 (result). R6 soak logs/connected-r6-soak-20260802-143157: RESULT=FAIL per + the PRIMARY artifact (report.json Passed=false, 37 failures) — the + coordinator's "concluded successfully" summary was based on the markers + log (route-complete + graceful close are true) but the pass criterion is + report.json. Failure class: streamingWork convergence + (deferredCompletions/deferredAdoptedCpuBytes/pendingPublications=1/ + farBacklog nonzero) at 8 of 9 canonical checkpoints (aerlinthe clean), + plus Caul plateau loadedLandblocks/totalLandblocks 283->189 and mesh + cache growth 547->588 / +40.5 MB. No crash, no exception, graceful + logout confirmed; 9/9 checkpoints + screenshots captured. + ATTRIBUTION ANALYSIS (verified, not guessed): + - Last passing soak (logs/connected-r6-soak-20260727-004942.report.json, + Passed=true, 0 failures) enforced the SAME streamingWork expect-zero + criterion and met it — but predates the ENTIRE uncommitted C3c + flip+F1+F2+F3 stack, so it separates {branch} from {baseline}, not F3 + from the flip. + - F3's blast radius was provably NOT exercised in the failing run: + grep 0x0000FFFF over the soak out.log = 0 hits (the corner landblock + never entered any route window), zero exceptions (the new absent-id + guards never fired), and every F3 conversion is byte-identical for + nonzero prefixes (pinned by the parity test). The failing criterion is + streaming/publication convergence — a C3c-flip/F1/F2-era surface. + - The lifecycle gate 142539 (WITH F3) passed cleanly the same day. + STOPPED per contract — no retry, no further code work, nothing staged. + +## C3c-F4 — soak streaming-convergence regression: DIAGNOSED, STOPPED (no fix landed) + +**Verdict: NOT a wedge and NOT in the C3c flip/F1/F2/F3 diff.** It is a +throughput regression in the committed collision-generation atomic-replacement +mechanism. + +### Named mechanism (link by link) +1. `StreamingController.DrainAndApply` (src/AcDream.App/Streaming/StreamingController.cs:1662-1690) + advances the completion-queue head and `break`s when it does not complete — + at most ONE landblock publication per frame. +2. `LandblockPresentationPipeline.Advance` stage `publication-index-physics` + (src/AcDream.App/Streaming/LandblockPresentationPipeline.cs:612-627) charges + `EntityOperations: PreparationCursor < Entities.Count ? 1 : 0`. A FAR build is + `Array.Empty()` (PublishAsFar, :419-447), so every step is FREE + and only the 2 ms elapsed-time ceiling bounds it. +3. `LandblockPhysicsPublisher.AdvancePreparationOne` + (src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:297-309) gates on + `RuntimePhysicsState.AdvanceCollisionGenerationPreparation`. +4. That calls `PreparedLandblockCollisionGeneration.AdvanceStagingClone` + (src/AcDream.Runtime/Physics/RuntimePhysicsState.cs:531-560) → + `PhysicsEngine.CollisionStagingBuilder.Advance` + (src/AcDream.Core/Physics/PhysicsEngine.cs:842-925): ONE leaf per step of an + off-side draft of the COMPLETE collision world minus the target prefix + (landblock slots, CellStruct, FlatCellStruct, FlatEnvCell, Buildings, + EnvCells, Terrain, OutdoorCells, shadow-owner slots). +5. `PhysicsEngine.CommitLandblockReplacement` (:304-321) does + `stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld)` — the + atomic unit is the WHOLE world, which is why the whole world must be cloned. + +### Measured (attributed probes, lifecycle-gate route, since stripped) +- median 19,736 / p90 32,135 / max 38,021 clone leaves per landblock + publication; median 3.64 ms CPU each; 1,584 preparations = 8.53 s CPU in one + 4-minute capped session. +- Far-queue drain measured at ~10 landblocks/s; `[queue-stale]` showed the head + seq advancing 234→488→548→603→…→1360 and far count 411→351→296→242→190 — + the queue drains, it never catches up. `pendingPublications=1` is the one + head in flight, not a stuck item. +- `[fifo-wedge]` never fired: `pendingProjections=0`. The placement projection + FIFO, the sink's C3c residence gate, and the conductors are NOT involved. + +### Attribution +`git log -S CollisionStagingBuilder` → introduced by `6b28ff99` +"fix(physics): make collision activation starvation-free" (2026-07-31), on top +of `be94bc9b` (atomic activation, 2026-07-31) / `9b0f59bd` (2026-08-01). The +last PASSING soak is `a9a822f2` (2026-07-27) — before all three. `git diff` +touches no file on this path. + +### Why no fix landed (contract STOP rule) +Tried the one semantics-preserving lever: batch 256 leaves per metered +preparation step. Measured 1.8x (gate far backlog 334→187 / 264→130, loaded +landblocks 291→438 / 261→395) — real but NOT convergence. It also fails +`RuntimePhysicsStateTests.DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep` +(tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:983, +`Assert.InRange(step.WorkUnits, 0, 1)`) — one-leaf-per-step is an ASSERTED +invariant of the slice that introduced the clone. Reverted; tree is byte-identical +to flip+F1+F2+F3. + +Convergence requires the clone to become O(changed) instead of O(resident world) +— structural sharing in `CollisionWorldState`, or a per-landblock (not +whole-world) atomic replacement unit. That is a semantics change to the C2-era +mechanism and belongs to its own slice. + +### Secondary symptoms — same mechanism, not separate defects +`loadedLandblocks` 283→189 and `visibleLandblocks` 30 vs the baseline's 180 are +the far ring never converging (baseline held 625 loaded at every checkpoint). +Mesh cache 547→588 / +40.5 MB is different far-ring subsets resident at the two +visits; retest for a true leak only after convergence is restored. + +## C3c-F5 — local-player first-entry contact seeding + +### Research (grep-named-first, complete before design) +- Retail local-player seeding point: `SmartBox::HandleCreateObject` 0x00454C80 + runs `SmartBox::init_player` 0x00455010 then `CPhysicsObj::enter_world` + (call site 0x00455095; body 0x00516170) for the LOCAL player — the same + enter_world the non-player branch uses at 0x004550EC. enter_world builds + SetPositionStruct flags 0x11, calls CPhysicsObj::SetPosition, sets + `transient_state |= 0x80` (ACTIVE) and HandleEnterWorld — NO contact + seeding anywhere in it (pseudo-C 284198-284249). Contact arrives from the + first gravity frame (digest #270 section; find_placement_pos validates the + spot but records no touch). Local player and remote spawn CONFIRMED to + share the retail mechanism. +- Legacy local path (deleted by the flip): BuildControllerAndCamera ran + Resolve(100f drop) + ResolvePlacement then PreparePositionForCommit -> + SetPositionCore, which FORCE-seeded `Contact | OnWalkable | Active` + ("Treat as grounded after a server-side position snap", + PlayerMovementController.cs:1830-1834) — an unconditional non-retail seed + (Contact-without-plane, the state the landing family calls + unrepresentable). The flip deleted the call chain without an equivalent. +- New path today: conductor -> publication -> dormant activation -> + PhysicsEngine.SetPosition (faithful port, result.InContact=false for a + clean placement) -> TryApplyDormantLocalActivationCommit commits + contact=false -> FinalizeActivation activates. Body starts airborne; + outbound CanSendPositionEvent (= InContact && OnWalkable, + PlayerMovementController.cs:1517) stays false -> MTS contact byte 0 + (LocalPlayerOutboundController.cs:203) -> ACE says 'while in the air'. +- DO-NOT-RETRY compliance: settle passes isOnGround:false + real body (no + caller-bool seeding); no ContactPlaneValid gating; no forced transients — + contact only from the sweep's real touch; airborne spawn stays airborne. + +### Design (pinned-contract shape) +- Move RemoteSpawnPlacementSettler (App) -> Core.Physics + `SpawnPlacementSettler` (public, like PhysicsObjUpdate; Core internals are + NOT visible to App). Remote caller + tests updated; semantics byte-identical. +- Seed at RuntimeLocalPlayerPhysicsPublicationState.FinalizeActivation, + after TryApplyDormantLocalActivationFinalCommit + shadow dispatch + + IsCommittedActivationSuffixCurrent, before placement dispatch (no + reentrant-sink hazard; stale-authority path skips the settle). Inputs: + activation Body/Record key/ActivationPreparation radius+height, + IsPlayer|EdgeSlide|OwnPvpFlags, Movement.HitGround/Motion.LeaveGround — + the same callback pair the per-tick landing path uses. +- Propagation: body transients (a) are THE controller grounded state (b) + (controller reads _body directly) and THE outbound bit (c) + (CanSendPositionEvent -> contactByte). No second copy exists. + +### Implementation (seams touched) +1. src/AcDream.Core/Physics/SpawnPlacementSettler.cs — NEW (moved from + src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs, deleted; public + like PhysicsObjUpdate because Core internals are not visible to App). + TrySettle body byte-identical to the #270 shipped version; only the + class name/namespace/doc changed. +2. src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:193 — the + one legacy remote caller now calls + AcDream.Core.Physics.SpawnPlacementSettler.TrySettle (unchanged args). +3. src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs + — FinalizeActivation now calls new private + SettleFirstEntryGroundContact(activation) AFTER + TryApplyDormantLocalActivationFinalCommit + shadow dispatch + + IsCommittedActivationSuffixCurrent, BEFORE placement dispatch (no + reentrant-sink window; stale-authority early return skips the settle). + Inputs: activation.Body position/cell, ActivationPreparation + radius/height, IsPlayer|EdgeSlide|OwnPvpFlags, + Controller.LocalEntityId, Movement.HitGround/Motion.LeaveGround (the + per-tick landing pair). try/catch matches the existing post-commit + ground-edge dispatch containment (_activationDispatchFailureCount). +4. Tests: tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs + (moved from App.Tests, bodies unchanged, layer rule 6); + Issue270ProductionWiringTests source pin updated to the new name; + RuntimeLocalPlayerPhysicsPublicationStateTests +2 + (CommitActivationOnFlatGroundSeedsRetailFirstGravityFrameContact, + CommitActivationOverVoidLeavesFirstEntryGenuinelyAirborne) + fixture + moverSphereOriginZ param (default 0 = pre-existing shape); + RuntimeFirstEntryHostIntegrationTests +2 + (LocalLogin_FlatGround_ReportsGroundedOutboundContactBit, + LocalLogin_AirborneSpawn_StaysGenuinelyAirborne) + HostFixture + terrainHeight/moverSphereOriginZ params. + +### Gate ladder +1. Focused: publication-state 92/92 (90+2), first-entry conductor 15/15, + settler 3/3 (Core), App first-entry integration + issue-270 wiring 8/8. + Complete projects (Release): Runtime 999/0, App 4,036/3 skips, + Headless 78/0, Core 4,247/1 skip. + NOTE (pre-existing, not this slice): + LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysics... fails in + DEBUG only — the test feeds an intentional near payload to a FarLoad and + LandblockStreamer.cs:505 Debug.Assert fires; Release (the gate config) + compiles it out and it passes. Reproduced 3x isolated in Debug, passes + in Release; untouched by this slice's diff. +2. Release solution build 0 warn/0 err; complete solution (-m:1, + ACDREAM_PAK_PATH): 10,808 passed / 0 failed / 4 skips + (App 4,036/3, Bake 15, Cli 4, Content 124, Core.Net 762, Core 4,247/1, + Headless 78, Runtime 999, UI 543). +3. Connected lifecycle gate: launched against live ACE (process 22100, + UDP 9000); result pending. +3 (result). Connected lifecycle gate PASS: + logs/connected-world-gate-20260802-164432/report.json Passed=true, + Failures=[], both sessions ExitCode=0, one warning ("capped: 25 expected + world-edge landblock miss(es)" — the exact 142539/161138 class). All 6 + capped checkpoints + uncapped_reconnect captured with screenshots. + "while in the air" grep: 0 in capped/stdout.log, 0 in + uncapped-reconnect/stdout.log (0x042C also 0/0). Note: the two prior + PASS runs (142539/161138) also contained 0 occurrences — the scripted + route never surfaced the rejection string; the behavioral proof of the + fix is the outbound-bit integration tests + the settle assertions, and + the gate proves no regression. +4. git diff --check exit 0 (CRLF metadata warnings only, the known worktree + pattern); nothing staged; no probes added by this slice. + +### Register note +No divergence-register row existed for the #270 compressed settle (it is +classified as timing compression — it produces exactly the state retail's +first gravity frame produces); extending it symmetrically to the local +player follows the same classification. The commit that lands C3c should +also delete/refresh AD-42 (its cited legacy GameWindow/PlayerModeController +resolve path no longer exists in the flipped tree) — flagged for the +closeout, not acted on here (report-only bookkeeping, no doc edits in this +slice's scope). + +### Purple-haze note (observation only) +The haze script is the Hidden/UnHide materialization path +(EntityEffectController.PlayTypedFromHiddenTransition — retail set_hidden +0x00514C60); nothing in it keys off contact/airborne state, so the F5 +contact gap does NOT plausibly drive the re-fire. A re-fired UnHide implies +the local player's presentation saw a hidden/visibility edge while standing +— consistent with F4's streaming/publication convergence regression +re-bucketing the player's surroundings, which remains the plausible driver. + +## C3c-R1 +Fix round for review round 1 (contract c3c-r1-fixes.md). Every finding +verified at source before editing; dispositions below. + +### Verification-at-source results (pre-edit) +- R1 CONFIRMED: CommitPreparedPosition (PlayerMovementController.cs:1784) + had ZERO production callers post-flip (grep); PreparePositionForCommit + (called at RuntimeLocalPlayerPhysicsPublicationState.cs:219) uses + publishSharedState:false and the PositionManager binds at :318 (after the + position seed), so no login path armed the leash. The final commit + (RuntimeSetPositionState.cs:2494-2516) is where the accepted position is + final, the shared cell is published (:2508), and the controller activates + (:2516). +- R2 CONFIRMED: LiveEntityRuntime.cs:806-828 keyed the presentation-only + branch off the sticky enum; post-residence entities skipped CommitRebucket + (:882-892) + the prepare_to_enter_world clock edges (:897-924). All six + cited unflipped-route callers verified (grep RebucketLiveEntity). +- R3 CONFIRMED: RuntimeLiveEntitySessionController.OnSpawned opened the + residence unconditionally; HeadlessSessionHost builds drive+projection only + under `_contentLease is { } content` (:539-565); content-less is + validated-legal (HeadlessConfigurationLoader.ValidateContent :88-98 + returns on null). worldProjection==null is exactly "no drive exists" + (single production constructor call site). +- F2 CONFIRMED: SpawnPlacementSettler.cs:61 commits settle.Position; + settle.CellId never read. +- F4 CONFIRMED: F3's landblockId==0 guards (RuntimePhysicsState + .BeginCollisionAdmission :2037-area) throw through + HeadlessCollisionGenerationTransaction.Begin (:62) reachable from + CenterOn; the two cited sites passed the raw wire LandblockId unguarded. +- F5/F6/F7/F8/F9 confirmed as cited (drive `_pending` outside every ledger; + both route Disposes cleared a SHARED drive unconditionally; far-remote + DeferredCell park has no wake outside the 3x3 neighborhood; the three + stale comments; the per-pump TryGetWorldEntity+GetSetupCylinder). + +### F3 — STOPPED, pinned design conflicts with source (file:line evidence) +The pinned probe ("nested production OnCreate, not hand-called +AdvanceCreateAuthority") cannot produce create-authority drift in the +items-6/8 tests' post-residence window: +1. RuntimeEntityObjectLifetime.cs:660-665 — the ExistingGeneration branch + calls Entities.AdvanceCreateAuthority ONLY when !beginInitialResidence; + the graphical route always registers with residence + (LiveEntityRuntime.cs:544-548), so a post-residence same-generation + Create is description-only. +2. The FIFO-adoption alternative is closed: ConsumeExecuted removes the + completed residence entry at Released + (RuntimeInitialCreateResidenceState.cs:1246-1251), and the fixtures + complete+release during the initial OnCreate, so TryGetTransaction + (:729-748) misses and no continuation can be staged post-release. +3. The executor drain (RuntimeInitialCreateContinuationExecutor.cs:2424-2429) + is the ONLY production site that advances create authority for an + existing incarnation - exactly what the hand-call models. +4. EMPIRICAL: temporarily restoring the nested-OnCreate probe in + FailedCompletedSupersession_RemainsPendingUntilExactRetry made BOTH rows + fail "Assert.Throws() Failure: No exception was thrown" (no drift, no + CreateSupersessionRecovery). Experiment reverted; tree byte-identical + for that file except nothing. +Restoring the probe requires either accepting dead drift machinery or a +test-scenario redesign (drift staged during the ACTIVE-residence window and +drained mid-recovery by a reentrant pump) - a design call for the +coordinator, not a probe swap. NOT implemented; reported. + +### R4(c) progress-log correction (retail minor M1) +The C3c-F5 section above says the legacy local force-seed path was +"deleted by the flip". CORRECTION: the force-seed at +PlayerMovementController.cs SetPositionCore ("Treat as grounded after a +server-side position snap", Contact|OnWalkable|Active) still RUNS during +publication-candidate preparation (PreparePositionForCommit -> +SetPositionCore) and is then OVERWRITTEN by the faithful activation commit +(which commits the SetPosition result's contact=false) and the settle. What +the flip deleted was the CALLER CHAIN (BuildControllerAndCamera's +Resolve/ResolvePlacement + CommitPreparedPosition), not the seed statement. +Register row AD-61 records the "overwritten, not deleted" truth. + +### Implemented (all others) +- R1: PlayerMovementController.ArmConstraintLeashAtCommittedPlacement + (internal, published-guarded) + RuntimeLocalPlayerPhysicsPublicationState + .ArmFirstEntryConstraintLeash called in FinalizeActivation after the + final commit + shadow dispatch, inside the IsCommittedActivationSuffixCurrent + gate, BEFORE the settle; exactly-once via `_activation = null` preceding + the suffix. Ordering justified in the new doc comment with file:line. +- R2: the public RebucketLiveEntity now suppresses ONLY while + HasActiveInitialCreateResidence (exact-token activity view); post-residence + falls through to the FULL legacy branch (CommitRebucket + clock edges). + RebucketLiveEntityPresentationOnly is now called only from + TryApplyInitialCreateCompletionPresentation. +- R3: content-less direct host (worldProjection null) registers via + RegisterEntity + direct accepted-frame commit (exact pre-flip shape), + with the C4/C5 revisit note. +- R4: register AD-61 filed (local-player settle compression, overwritten + force-seed, settle-CellId caveat); AD-42 refreshed (repointed at + RemoteTeleportController.ResolvePlacement + headless portal resync; + login split retired); section count 46->47. +- F1: LiveEntityRuntime.ConvertMaterializationResidenceToLegacyImmediate + (throws while residence active); EquippedChildRenderController uses it. +- F4: `{ LandblockId: not 0u }` guards at both cited CenterOn sites. +- F5: RuntimeEntityObjectOwnershipSnapshot.FirstEntryDrivePendingCount + (IsConverged-gated) + RegisterFirstEntryDriveOwnership; the drive + registers its pending-count provider at construction. +- F6: RuntimeFirstEntryDriveController.AttachRoute/DetachRoute one-route + latch (Clear deleted); GraphicalSessionEventRoute + HeadlessSessionEventRoute + attach/detach with `this`. +- F7: RuntimeAuthoritativePositionRouteClassifier.ToCellessCreateRoute + (exact Parented/PickedUp branch shape, preserving authority/operation/ + collision-batch); RuntimeInitialCreateResidenceState.TryConvertToCellessRoute + (active+unplaced only: ForgetExactPlacement + lease rewrite + + PublishCancellation + retirement fan-out for conductor progress reset, + entry retained); RuntimeEntityObjectLifetime + .TryConvertInitialResidenceToCellessRoute facade; + IHeadlessCollisionNeighborhood.IsWithinServiceWindow (3x3 around the + requested center; no-center = within) consumed by + HeadlessSessionWorldProjection.ProjectSpawn for far remotes before the + pump. +- F8: both conductor "nothing calls Advance in production" headers + corrected; PlayerModeController's two false "auto-entry retries" claims + replaced with the actual disarm-before-invoke semantics (verified against + PlayerModeAutoEntry.TryEnter :232-248 - _armed=false precedes the + invoke, and a throw propagates before the context's reveal Complete()). +- F9: the graphical activation-preparation provider caches the resolved + setup cylinder per incarnation (keyed by LocalEntityId; unresolved + results not cached; shadow disposition stays live - it is validated + against the exact registry at activation commit and may change between + pumps). + +### New tests +- Runtime: CommitActivationArmsTheLoginConstraintLeashAtTheCommittedPlacement, + CommitActivationNeverRearmsTheLeashOnAStaleRetry (publication suite); + ContentLessDirectSink_KeepsPreFlipLegacyRegistration, + FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner + (session-controller suite; DirectSinkOwnsCanonicalCreateUpdateDelete... + updated to pass a world projection so it keeps exercising the residence + route per R3's semantics). +- App: PostResidenceRebucket_TakesTheFullLegacyPathIncludingTheClockEdge + (active-suppression + F5 ledger visibility + retired->legacy CommitRebucket + + the pending-reentry clock rebase), + ResidenceConversionToLegacyImmediate_RefusesWhileTheResidenceIsActive. +- Headless: FarRemoteCreateCompletesCelllessWithoutPinningItsResidence + (+ Spawn(guid, cellId) helper param; FixtureCollisionNeighborhood + implements IsWithinServiceWindow). +- One in-flight test correction during TDD: the leash test's anchor + assertions initially assumed the committed placement kept the raw spawn + Z=3; the faithful placement transaction already floor-snaps (2.705), so + the anchor asserts the committed floor-snapped band + committed cell. + +### Gate ladder (this round) +1. Runtime 1,003/1,003; App 4,038/3 skips of 4,041; Headless 79/79. +2. Release solution build: 0 errors. Complete solution (-m:1, + ACDREAM_PAK_PATH): 10,815 passed / 0 failed / 4 skipped of 10,819 + (App 4,038/3, Bake 15, Cli 4, Content 124, Core.Net 762, Core 4,247/1, + Headless 79, Runtime 1,003, UI 543). +3. Connected gate run 1: logs/connected-world-gate-20260802-174811 — + Passed=false, ATTRIBUTED TO USER INTERFERENCE per the coordinator + (the user manually drove the client, including a teleport, during the + run). Fingerprint: every failure at the capped_login checkpoint only + (transitOwnership.activeTeleportCount=1 at the stable checkpoint, + activeRevealCount=1, pendingDestinationReadinessCount=1, + hostProjectionCount=1, reveal/viewport/composites/collision not ready, + 216 staged mesh uploads + 44 composite warmups mid-stream to the manual + teleport destination). No crash, no exception; only the expected + world-edge warning class. Coordinator sanctioned exactly ONE clean + re-run (external interference, not a blind retry); graceful-close + discipline held (no stale client process, ACE endpoint intact). +4. git diff --check: exit 0 (CRLF metadata warnings only, the known + worktree pattern); nothing staged. +3 (result). Sanctioned clean re-run PASS: + logs/connected-world-gate-20260802-175401/report.json Passed=true, + Failures=[], only the expected world-edge warning — the established + passing signature (142539/161138/164432 class). 174811 interference + attribution confirmed. Round complete; nothing staged. + +### F3 addendum (coordinator resolution accepted, implemented) +Hand-calls KEPT as honest documented models: enriched comments at all six +item-6/8 sites (LiveEntityHydrationControllerTests x5 sites incl. the +shared item-6/CompletedSupersessionReadyFailure text, +LiveEntityCreateSupersessionRecoveryTests x1) citing +ApplyWeenieDescriptionAction as the sole production site, +RuntimeEntityObjectLifetime :660-665 !beginInitialResidence gate, and +ConsumeExecuted. NEW source pin +tests/AcDream.App.Tests/World/C3cR1F3DriftModelSourcePinTests +.HandCalledDriftProbe_StillModelsTheExecutorDrainAdvance (single +_entities.AdvanceCreateAuthority in the executor, inside +ApplyWeenieDescriptionAction; registration advance still residence-gated). +Focused files 82/82; Runtime 1,003/1,003; App 4,039/3 skips of 4,042; +git diff --check exit 0. No staging/commits. diff --git a/docs/research/2026-08-02-collision-throughput-handoff/user-observations-feel-test.md b/docs/research/2026-08-02-collision-throughput-handoff/user-observations-feel-test.md new file mode 100644 index 00000000..642c6f49 --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/user-observations-feel-test.md @@ -0,0 +1,30 @@ +# User feel-test observations — O-slice tree (2026-08-02 ~20:10, uncommitted) + +Axioms; they override the gate numbers. Log: launch-feeltest-oclone.log. + +1. MONSTERS STILL POP IN while running past — the O-slice did NOT fix the + user-visible symptom despite the soak's publication convergence. +2. MONSTERS SPAWNED MID-AIR far ahead at a newly-entered area. +3. STATICS ("stabs") PLACED INCORRECTLY — visibly wrong static placement. +4. User: "This is not how retail worked. I could see monsters way in + front of me." +5. DOOR APPROACH REGRESSED: using a door no longer walks the character + to it first. NOTE: likely a COMMITTED C3c regression, not O-slice — + prime suspect is PlayerModeController's conditional MoveTo bind + (`if (controller.MoveTo is { } moveTo)` — the flip only binds + approach callbacks IF the Runtime-owned MoveToManager already exists; + the legacy path CREATED it via factory at attach). If Runtime's + MakeMoveToManager runs after player-mode attach (or never for this + flow), MoveToComplete/approach never wires. Triage first in the C3c + fix slice; check whether the 175401 gate probe ever exercised a + door/use-approach (suspect: no coverage). + +SMOKING GUN (log): 243x "streaming: origin-recenter preparation will +resume: System.InvalidOperationException: Landblock already has a +full retirement receipt." — continuous catch-retry loop during origin +recenter. Both reviewers redirected with this; the implementer's +"exposed pre-existing" retirement classification is under re-judgment. +The catch-and-resume wrapper is itself suspect as a pre-existing +symptom-swallower (no-silent-catch rule). + +STATUS: O-slice commit ON HOLD until every observation is explained. diff --git a/docs/research/2026-08-02-collision-throughput-handoff/user-observations-smoke-test.md b/docs/research/2026-08-02-collision-throughput-handoff/user-observations-smoke-test.md new file mode 100644 index 00000000..dc6f6148 --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/user-observations-smoke-test.md @@ -0,0 +1,29 @@ +# User in-game observations — 2026-08-02 (~15:40, during the F4 diagnostic run) + +Axioms per the retail-oracle rule. User will do a full test session once +the current work passes; these are the pre-session signals. + +1. AIRBORNE-WHILE-STANDING (severe, flip-suspect): repeated + "[System] You can't do that while in the air!" + + "You can't do that. (error 0x042C)" x4 + one "WeenieError 0x001D" when + trying to cast while standing still. Suspect: the conductor placement + path lacks the legacy spawn path's #270 settle sweep (contact from the + compressed first gravity frame) -> outbound contact state says + airborne. Routed to F4 as a lead (unified-hypothesis check); if F4's + stuck item is not the player, this becomes its own slice (F5) BEFORE + the C3c commit — casting is core gameplay and blocks the smoke test. +2. MATERIALIZATION HAZE RE-FIRING while standing still (flip-suspect): + purple haze re-triggers around the character. Plausibly the visible + face of the soak's pendingPublications=1 stuck item if that item is + the local player. Routed to F4. +3. NO SLIDE ALONG IMPASSABLE SLOPES: walking into too-steep terrain does + not glide laterally. Likely pre-existing open issue #269 (Campaign P + slope-slide residual). Verify pre-existence during the review/closeout; + do not fold into C3c unless evidence says the flip changed it. +4. /ls DOES NOT WORK: unclear which command surface (chat slash command?). + Triage at the session; low priority. + +Review-focus implication: retail reviewer must verify the flip preserves +the legacy spawn path's contact seeding (#270) semantics; adversarial +reviewer must verify the placement publication for the local player +actually completes and is reaped. diff --git a/src/AcDream.Core/Physics/CollisionWorldState.cs b/src/AcDream.Core/Physics/CollisionWorldState.cs index bc92d882..3e46a97f 100644 --- a/src/AcDream.Core/Physics/CollisionWorldState.cs +++ b/src/AcDream.Core/Physics/CollisionWorldState.cs @@ -3,6 +3,81 @@ using AcDream.Core.World.Cells; namespace AcDream.Core.Physics; +/// +/// Per-landblock installed-key ledger for one collision-world map. Slot lists +/// mirror the shadow registry's prefix-owner-slot idiom: removal tombstones a +/// slot (key 0) so an in-flight metered cursor retains its captured list +/// reference and observes only tombstones, and an emptied container is +/// reclaimed so a future install gets a fresh compact list. Maintained by the +/// typed install/remove helpers; consumed by +/// the landblock-replacement seal so capturing one prefix's keys never scans +/// the whole resident world (O1 of the 2026-08-02 collision +/// publication-throughput fix). +/// +internal sealed class PrefixKeyIndex +{ + private readonly Dictionary> _slots = new(); + private readonly Dictionary> _indices = new(); + private readonly Dictionary> _freeSlots = new(); + + internal void Add(uint key) + { + uint prefix = key & 0xFFFF0000u; + if (!_slots.TryGetValue(prefix, out List? slots)) + { + slots = new List(); + _slots[prefix] = slots; + _indices[prefix] = new Dictionary(); + _freeSlots[prefix] = new Stack(); + } + Dictionary indices = _indices[prefix]; + if (indices.ContainsKey(key)) + return; + if (_freeSlots[prefix].TryPop(out int freeIndex)) + { + slots[freeIndex] = key; + indices[key] = freeIndex; + return; + } + indices[key] = slots.Count; + slots.Add(key); + } + + internal void Remove(uint key) + { + uint prefix = key & 0xFFFF0000u; + if (!_indices.TryGetValue(prefix, out Dictionary? indices) + || !indices.Remove(key, out int slotIndex)) + { + return; + } + _slots[prefix][slotIndex] = 0u; + _freeSlots[prefix].Push(slotIndex); + if (indices.Count != 0) + return; + // An in-flight seal cursor retains its captured List reference and + // observes only tombstones. A future install gets a fresh container. + _slots.Remove(prefix); + _indices.Remove(prefix); + _freeSlots.Remove(prefix); + } + + /// + /// The live slot list for one landblock prefix, or null when no key is + /// installed. Callers capture the reference plus Count once and + /// iterate by index, skipping tombstone slots (key 0). + /// + internal List? SlotsForPrefix(uint prefix) => + _slots.TryGetValue(prefix & 0xFFFF0000u, out List? slots) + ? slots + : null; + + internal int InstalledKeyCountForPrefix(uint prefix) => + _indices.TryGetValue(prefix & 0xFFFF0000u, out var indices) + ? indices.Count + : 0; +} + /// /// One exclusive-by-ownership collision-world root. A preparation mutates only /// its private root; activation transfers the complete root through one volatile @@ -38,6 +113,128 @@ internal sealed class CollisionWorldState internal List ShadowOwnerSlots { get; } = new(); internal Dictionary ShadowOwnerIndices { get; } = new(); internal Stack ShadowOwnerFreeSlots { get; } = new(); + + // ── O1 per-prefix installed-key ledgers ──────────────────────────────── + // Every mutation of the five landblock-scoped world maps goes through the + // typed helpers below so these ledgers stay exact. The seal's landblock- + // replacement builders enumerate one prefix's keys instead of scanning the + // whole resident map, and the retirement/removal paths retire one prefix + // in O(prefix keys). + internal PrefixKeyIndex CellStructKeys { get; } = new(); + internal PrefixKeyIndex FlatCellStructKeys { get; } = new(); + internal PrefixKeyIndex FlatEnvCellKeys { get; } = new(); + internal PrefixKeyIndex BuildingKeys { get; } = new(); + internal PrefixKeyIndex EnvCellKeys { get; } = new(); + + internal void SetCellStruct(uint id, CellPhysics value) + { + CellStruct[id] = value; + CellStructKeys.Add(id); + } + + internal bool TryAddCellStruct(uint id, CellPhysics value) + { + if (!CellStruct.TryAdd(id, value)) + return false; + CellStructKeys.Add(id); + return true; + } + + internal bool RemoveCellStruct(uint id) + { + if (!CellStruct.TryRemove(id, out _)) + return false; + CellStructKeys.Remove(id); + return true; + } + + internal void SetFlatCellStruct(uint id, FlatCellStructureCollisionAsset value) + { + FlatCellStruct[id] = value; + FlatCellStructKeys.Add(id); + } + + internal bool TryAddFlatCellStruct(uint id, FlatCellStructureCollisionAsset value) + { + if (!FlatCellStruct.TryAdd(id, value)) + return false; + FlatCellStructKeys.Add(id); + return true; + } + + internal bool RemoveFlatCellStruct(uint id) + { + if (!FlatCellStruct.TryRemove(id, out _)) + return false; + FlatCellStructKeys.Remove(id); + return true; + } + + internal void SetFlatEnvCell(uint id, FlatEnvCellTopology value) + { + FlatEnvCell[id] = value; + FlatEnvCellKeys.Add(id); + } + + internal bool TryAddFlatEnvCell(uint id, FlatEnvCellTopology value) + { + if (!FlatEnvCell.TryAdd(id, value)) + return false; + FlatEnvCellKeys.Add(id); + return true; + } + + internal bool RemoveFlatEnvCell(uint id) + { + if (!FlatEnvCell.TryRemove(id, out _)) + return false; + FlatEnvCellKeys.Remove(id); + return true; + } + + internal void SetBuilding(uint id, BuildingPhysics value) + { + Buildings[id] = value; + BuildingKeys.Add(id); + } + + internal bool TryAddBuilding(uint id, BuildingPhysics value) + { + if (!Buildings.TryAdd(id, value)) + return false; + BuildingKeys.Add(id); + return true; + } + + internal bool RemoveBuilding(uint id) + { + if (!Buildings.TryRemove(id, out _)) + return false; + BuildingKeys.Remove(id); + return true; + } + + internal void SetEnvCell(uint id, EnvCell value) + { + EnvCells[id] = value; + EnvCellKeys.Add(id); + } + + internal bool TryAddEnvCell(uint id, EnvCell value) + { + if (!EnvCells.TryAdd(id, value)) + return false; + EnvCellKeys.Add(id); + return true; + } + + internal bool RemoveEnvCell(uint id) + { + if (!EnvCells.TryRemove(id, out _)) + return false; + EnvCellKeys.Remove(id); + return true; + } } /// @@ -83,5 +280,18 @@ internal sealed class CollisionWorldStateSlot return transferred; } + /// + /// O2 (2026-08-02): terminally revokes a consumed staging root. The + /// per-landblock delta commit installs the staged content into the active + /// root instead of swapping roots, so the staging root no longer becomes + /// the active root — but a committed preparation must still lose access to + /// its private world exactly as the old transfer revoked it. + /// + internal void Revoke() + { + _revoked = true; + _current = null; + } + internal CollisionWorldState Capture() => Current; } diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index ad2a2b9d..9e58e7e1 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -483,9 +483,9 @@ public sealed class PhysicsDataCache preparedTopology = null; } if (preparedStructure is not null) - _flatCellStruct.TryAdd(envCellId, preparedStructure); + _collisionWorld.Current.TryAddFlatCellStruct(envCellId, preparedStructure); if (preparedTopology is not null) - _flatEnvCell.TryAdd(envCellId, preparedTopology); + _collisionWorld.Current.TryAddFlatEnvCell(envCellId, preparedTopology); // UCG Stage 1: register only a loadable authored cell. if (!CellGraph.Contains(envCellId)) @@ -558,7 +558,7 @@ public sealed class PhysicsDataCache // for every ordinary (non-house-barrier) cell. RestrictionObj = envCell.RestrictionObj, }; - _cellStruct[envCellId] = cellPhysics; + _collisionWorld.Current.SetCellStruct(envCellId, cellPhysics); if (PhysicsDiagnostics.ProbeDumpCellsEnabled && PhysicsDiagnostics.ProbeDumpCellIds.Contains(envCellId)) @@ -696,8 +696,8 @@ public sealed class PhysicsDataCache if (preparedStructure.ContainmentBsp.RootIndex < 0) return; - _flatCellStruct.TryAdd(envCellId, preparedStructure); - _flatEnvCell.TryAdd(envCellId, preparedTopology); + _collisionWorld.Current.TryAddFlatCellStruct(envCellId, preparedStructure); + _collisionWorld.Current.TryAddFlatEnvCell(envCellId, preparedTopology); if (!CellGraph.Contains(envCellId)) { @@ -724,7 +724,7 @@ public sealed class PhysicsDataCache portal.Flags)); } - _cellStruct.TryAdd(envCellId, new CellPhysics + _collisionWorld.Current.TryAddCellStruct(envCellId, new CellPhysics { SourceId = envCellId, WorldTransform = worldTransform, @@ -914,7 +914,7 @@ public sealed class PhysicsDataCache /// dat-driven . /// public void RegisterCellStructForTest(uint envCellId, CellPhysics physics) - => _cellStruct[envCellId] = physics; + => _collisionWorld.Current.SetCellStruct(envCellId, physics); /// /// Indoor walking Phase 2 (2026-05-19). Cache the building portal list @@ -926,7 +926,7 @@ public sealed class PhysicsDataCache { if (_buildings.ContainsKey(landcellId)) return; Matrix4x4.Invert(worldTransform, out var inverse); - _buildings[landcellId] = new BuildingPhysics + _collisionWorld.Current.SetBuilding(landcellId, new BuildingPhysics { WorldTransform = worldTransform, InverseWorldTransform = inverse, @@ -935,7 +935,7 @@ public sealed class PhysicsDataCache // (0x00534030) — and one building per origin landcell mirrors // CLandBlock::init_buildings (0x0052fd80). ModelId = modelId, - }; + }); } /// @@ -954,10 +954,33 @@ public sealed class PhysicsDataCache /// public void RemoveBuildingsForLandblock(uint landblockId) { - uint prefix = landblockId & 0xFFFF0000u; - foreach (var key in _buildings.Keys) - if ((key & 0xFFFF0000u) == prefix) - _buildings.TryRemove(key, out _); + CollisionWorldState world = _collisionWorld.Current; + RemovePrefixKeys( + world.BuildingKeys, + landblockId & 0xFFFF0000u, + world.RemoveBuilding); + } + + /// + /// Retires one landblock prefix's installed keys through the O1 ledger: + /// O(prefix keys), never a whole-map scan. Removal tombstones the captured + /// slot list, so index iteration over the captured reference stays exact. + /// + private static void RemovePrefixKeys( + PrefixKeyIndex ledger, + uint prefix, + Func remove) + { + List? slots = ledger.SlotsForPrefix(prefix); + if (slots is null) + return; + int limit = slots.Count; + for (int index = 0; index < limit; index++) + { + uint key = slots[index]; + if (key != 0u) + remove(key); + } } /// @@ -973,15 +996,13 @@ public sealed class PhysicsDataCache public void RemoveCellsForLandblock(uint landblockId) { uint prefix = landblockId & 0xFFFF0000u; - foreach (var key in _cellStruct.Keys) - if ((key & 0xFFFF0000u) == prefix) - _cellStruct.TryRemove(key, out _); - foreach (var key in _flatCellStruct.Keys) - if ((key & 0xFFFF0000u) == prefix) - _flatCellStruct.TryRemove(key, out _); - foreach (var key in _flatEnvCell.Keys) - if ((key & 0xFFFF0000u) == prefix) - _flatEnvCell.TryRemove(key, out _); + CollisionWorldState world = _collisionWorld.Current; + RemovePrefixKeys(world.CellStructKeys, prefix, world.RemoveCellStruct); + RemovePrefixKeys( + world.FlatCellStructKeys, + prefix, + world.RemoveFlatCellStruct); + RemovePrefixKeys(world.FlatEnvCellKeys, prefix, world.RemoveFlatEnvCell); } public BuildingPhysics? GetBuilding(uint landcellId) @@ -990,7 +1011,8 @@ public sealed class PhysicsDataCache public IReadOnlyCollection BuildingIds => (IReadOnlyCollection)_buildings.Keys; /// Test helper, mirrors . - public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; + public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => + _collisionWorld.Current.SetBuilding(landcellId, b); internal sealed class LandblockReplacementBuilder : IDisposable { @@ -1017,10 +1039,9 @@ public sealed class PhysicsDataCache private readonly List _removeFlatEnvCells = new(); private readonly List _removeBuildings = new(); private readonly UcgCellGraph.LandblockReplacementBuilder _cellGraph; - private IEnumerator>? _cellEnumerator; - private IEnumerator>? _flatCellEnumerator; - private IEnumerator>? _flatEnvEnumerator; - private IEnumerator>? _buildingEnumerator; + private List? _keySlots; + private int _keySlotLimit; + private bool _keySlotsCaptured; private int _phase; private int _cursor; @@ -1078,93 +1099,139 @@ public sealed class PhysicsDataCache _phase++; return false; case 2: - _cellEnumerator ??= _staging._cellStruct.GetEnumerator(); - if (CapturePrefixOne(_cellEnumerator, _prefix, _cells, _cellIds)) + { + // O1: enumerate the staging root's installed target-prefix + // keys instead of scanning the whole staging map, one key + // per advance. + if (TryTakeNextPrefixKey( + StagingWorld.CellStructKeys, + out uint id)) { + CaptureInstall(_staging._cellStruct, id, _cells, _cellIds); WorkUnits++; return false; } - _cellEnumerator.Dispose(); - _cellEnumerator = null; _phase++; return false; + } case 3: - _cellEnumerator ??= _active._cellStruct.GetEnumerator(); - if (CaptureRemovalOne(_cellEnumerator, _prefix, _cellIds, _removeCells)) + { + // O1: enumerate the active root's installed target-prefix + // keys for removal capture. This also removes the previous + // cross-frame live enumerator over the active map. + if (TryTakeNextPrefixKey( + ActiveWorld.CellStructKeys, + out uint id)) { + CaptureRemoval(_active._cellStruct, id, _cellIds, _removeCells); WorkUnits++; return false; } - _cellEnumerator.Dispose(); - _cellEnumerator = null; _phase++; return false; + } case 4: - _flatCellEnumerator ??= _staging._flatCellStruct.GetEnumerator(); - if (CapturePrefixOne(_flatCellEnumerator, _prefix, _flatCells, _flatCellIds)) + { + if (TryTakeNextPrefixKey( + StagingWorld.FlatCellStructKeys, + out uint id)) { + CaptureInstall( + _staging._flatCellStruct, + id, + _flatCells, + _flatCellIds); WorkUnits++; return false; } - _flatCellEnumerator.Dispose(); - _flatCellEnumerator = null; _phase++; return false; + } case 5: - _flatCellEnumerator ??= _active._flatCellStruct.GetEnumerator(); - if (CaptureRemovalOne(_flatCellEnumerator, _prefix, _flatCellIds, _removeFlatCells)) + { + if (TryTakeNextPrefixKey( + ActiveWorld.FlatCellStructKeys, + out uint id)) { + CaptureRemoval( + _active._flatCellStruct, + id, + _flatCellIds, + _removeFlatCells); WorkUnits++; return false; } - _flatCellEnumerator.Dispose(); - _flatCellEnumerator = null; _phase++; return false; + } case 6: - _flatEnvEnumerator ??= _staging._flatEnvCell.GetEnumerator(); - if (CapturePrefixOne(_flatEnvEnumerator, _prefix, _flatEnvCells, _flatEnvCellIds)) + { + if (TryTakeNextPrefixKey( + StagingWorld.FlatEnvCellKeys, + out uint id)) { + CaptureInstall( + _staging._flatEnvCell, + id, + _flatEnvCells, + _flatEnvCellIds); WorkUnits++; return false; } - _flatEnvEnumerator.Dispose(); - _flatEnvEnumerator = null; _phase++; return false; + } case 7: - _flatEnvEnumerator ??= _active._flatEnvCell.GetEnumerator(); - if (CaptureRemovalOne(_flatEnvEnumerator, _prefix, _flatEnvCellIds, _removeFlatEnvCells)) + { + if (TryTakeNextPrefixKey( + ActiveWorld.FlatEnvCellKeys, + out uint id)) { + CaptureRemoval( + _active._flatEnvCell, + id, + _flatEnvCellIds, + _removeFlatEnvCells); WorkUnits++; return false; } - _flatEnvEnumerator.Dispose(); - _flatEnvEnumerator = null; _phase++; return false; + } case 8: - _buildingEnumerator ??= _staging._buildings.GetEnumerator(); - if (CapturePrefixOne(_buildingEnumerator, _prefix, _buildings, _buildingIds)) + { + if (TryTakeNextPrefixKey( + StagingWorld.BuildingKeys, + out uint id)) { + CaptureInstall( + _staging._buildings, + id, + _buildings, + _buildingIds); WorkUnits++; return false; } - _buildingEnumerator.Dispose(); - _buildingEnumerator = null; _phase++; return false; + } case 9: - _buildingEnumerator ??= _active._buildings.GetEnumerator(); - if (CaptureRemovalOne(_buildingEnumerator, _prefix, _buildingIds, _removeBuildings)) + { + if (TryTakeNextPrefixKey( + ActiveWorld.BuildingKeys, + out uint id)) { + CaptureRemoval( + _active._buildings, + id, + _buildingIds, + _removeBuildings); WorkUnits++; return false; } - _buildingEnumerator.Dispose(); - _buildingEnumerator = null; _phase++; return false; + } case 10: WorkUnits++; if (!_cellGraph.Advance()) @@ -1210,43 +1277,70 @@ public sealed class PhysicsDataCache destination.TryAdd(id, value); } - private static bool CapturePrefixOne( - IEnumerator> enumerator, - uint prefix, + private CollisionWorldState StagingWorld => + _staging._collisionWorld.Current; + + private CollisionWorldState ActiveWorld => + _active._collisionWorld.Current; + + /// + /// O1 metered prefix-key cursor. The first call of a phase captures + /// the ledger's live slot-list reference and count; later calls + /// iterate by index, skipping tombstones (key 0). Removal only ever + /// tombstones a slot, so a captured reference stays exact across + /// frames without holding a map enumerator. + /// + private bool TryTakeNextPrefixKey(PrefixKeyIndex ledger, out uint key) + { + if (!_keySlotsCaptured) + { + _keySlots = ledger.SlotsForPrefix(_prefix); + _keySlotLimit = _keySlots?.Count ?? 0; + _keySlotsCaptured = true; + _cursor = 0; + } + while (_cursor < _keySlotLimit) + { + uint candidate = _keySlots![_cursor++]; + if (candidate != 0u) + { + key = candidate; + return true; + } + } + key = 0u; + _keySlots = null; + _keySlotsCaptured = false; + return false; + } + + private static void CaptureInstall( + ConcurrentDictionary source, + uint id, List> destination, HashSet ids) { - if (!enumerator.MoveNext()) - return false; - KeyValuePair pair = enumerator.Current; - if ((pair.Key & 0xFFFF0000u) == prefix) + if (source.TryGetValue(id, out T? value)) { - destination.Add(pair); - ids.Add(pair.Key); + destination.Add(new KeyValuePair(id, value)); + ids.Add(id); } - return true; } - private static bool CaptureRemovalOne( - IEnumerator> enumerator, - uint prefix, + private static void CaptureRemoval( + ConcurrentDictionary source, + uint id, HashSet retained, List destination) { - if (!enumerator.MoveNext()) - return false; - uint id = enumerator.Current.Key; - if ((id & 0xFFFF0000u) == prefix && !retained.Contains(id)) + if (!retained.Contains(id) && source.ContainsKey(id)) destination.Add(id); - return true; } public void Dispose() { - _cellEnumerator?.Dispose(); - _flatCellEnumerator?.Dispose(); - _flatEnvEnumerator?.Dispose(); - _buildingEnumerator?.Dispose(); + _keySlots = null; + _keySlotsCaptured = false; _cellGraph.Dispose(); } } diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 69194998..9fc0c335 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -249,20 +249,22 @@ public sealed class PhysicsEngine float WorldOffsetY); /// - /// Creates an off-side collision world from the last complete generation. - /// Streaming modifies this copy only; the active engine and its borrowed - /// cache/registry identities remain stable until Runtime commits. + /// Creates the empty off-side staging root for one landblock collision + /// generation. O3 (2026-08-02): admission no longer materializes a clone + /// of the resident world — the staging root holds ONLY the target + /// landblock's authored content and the commit installs it into the + /// active root as a per-landblock delta whose owner refloods run against + /// the live world (retail CObjCell::init_objects 0x0052B420 → + /// CPhysicsObj::recalc_cross_cells 0x00515A30). /// internal CollisionStagingBuilder CreateCollisionStagingBuilder( uint targetLandblockId) { + _ = targetLandblockId; PhysicsDataCache activeCache = DataCache ?? throw new InvalidOperationException( "Active collision engine has no data cache."); - return new CollisionStagingBuilder( - this, - activeCache, - targetLandblockId & 0xFFFF0000u); + return new CollisionStagingBuilder(this, activeCache); } internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( @@ -301,6 +303,20 @@ public sealed class PhysicsEngine expectedRetainedOwners)); } + /// + /// Publishes one sealed landblock replacement into the ACTIVE collision + /// root as a per-landblock delta drained in this one synchronous + /// update-thread call — the O2 (2026-08-02) restoration of be94bc9b's + /// O(changed) commit, replacing the whole-root + /// CollisionWorldStateSlot.TransferTo swap. Retail hydrates one + /// cell synchronously and refloods the objects associated with it + /// (CObjCell::init_objects 0x0052B420 → + /// CPhysicsObj::recalc_cross_cells 0x00515A30); the streaming + /// analogue is one landblock delta applied atomically with respect to + /// every reader (the runtime is single-threaded and the caller holds the + /// prefix quiescence permission). Owner rows install from the sealed + /// staging registry, which the seal keeps exactly current. + /// internal void CommitLandblockReplacement( PreparedPhysicsEngineLandblock replacement) { @@ -310,14 +326,59 @@ public sealed class PhysicsEngine PhysicsDataCache stagingCache = replacement.Staging.DataCache ?? throw new InvalidOperationException( "Staging collision engine has no data cache."); - uint activeCurrentCellId = activeCache.CellGraph.CurrCell?.Id ?? 0u; - stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld); - if ((activeCurrentCellId & 0xFFFF0000u) - == (replacement.LandblockId & 0xFFFF0000u)) + ShadowObjectRegistry stagingShadows = replacement.Staging.ShadowObjects; + CollisionWorldState active = activeCache.CollisionWorld.Current; + + // Rows in retired target cells belong exclusively to owners in the + // sealed owner list (every owner with target-prefix rows is captured + // there), so dropping the retired cells' row lists first can never + // discard a row the owner installs below. + PreparedPhysicsDataCacheLandblock data = replacement.DataCache; + for (int index = 0; index < data.CellIdsToRemove.Count; index++) + active.ShadowCells.Remove(data.CellIdsToRemove[index]); + IReadOnlyList envCellRemovals = + data.CellGraph.EnvCellIdsToRemove; + for (int index = 0; index < envCellRemovals.Count; index++) + active.ShadowCells.Remove(envCellRemovals[index]); + + using (LandblockReplacementApplyCursor cursor = + CreateLandblockReplacementApplyCursor(replacement)) { - activeCache.CellGraph.CurrCell = - activeCache.CellGraph.GetVisible(activeCurrentCellId); + while (true) + { + LandblockReplacementApplyStep step = cursor.Advance(); + if (step.HasOwner) + { + // O3 (2026-08-02): retail's per-cell hydration suffix — + // adopt each staged owner and recalculate its cross-cells + // against the live post-delta world, retire the outgoing + // generation's authored statics, and re-run the flood for + // retained live owners touching the replaced landblock + // (CObjCell::init_objects 0x0052B420 → + // CPhysicsObj::recalc_cross_cells 0x00515A30). + ShadowObjects.ApplyCommittedOwnerReplacement( + stagingShadows, + step.OwnerId, + replacement.LandblockId); + } + if (step.Completed) + break; + } } + + // Retail init_objects refloods every object associated with the + // hydrated cell at hydration time. Owners that became associated with + // the target after the sealed capture (a mover entering the prefix + // mid-publication) are in the live prefix-owner slots but not the + // sealed list; recalculate their cross-cells here too. + ShadowObjects.RefloodPrefixOwnersAfterReplacement( + replacement.LandblockId, + replacement.Shadows.OwnerIds); + + // The staging root no longer becomes the active root, but a committed + // preparation must still lose its private world exactly as TransferTo + // revoked it. + stagingCache.CollisionWorld.Revoke(); } internal LandblockReplacementApplyCursor @@ -331,239 +392,12 @@ public sealed class PhysicsEngine bool HasOwner, uint OwnerId); - internal LandblockRetirementCursor CreateLandblockRetirementCursor( - PhysicsEngine authoritative, - uint landblockId, - bool withdraw) => new( - this, - authoritative, - landblockId, - withdraw); - /// - /// Applies one demotion/withdrawal to an off-side root without a whole- - /// world synchronous scan. Every advance inspects or mutates at most one - /// stable owner slot, dictionary leaf, or authored outdoor cell. - /// - internal sealed class LandblockRetirementCursor : IDisposable - { - private readonly PhysicsEngine _destinationEngine; - private readonly PhysicsDataCache _destinationCache; - private readonly CollisionWorldState _destination; - private readonly CollisionWorldState _authoritative; - private readonly uint _canonical; - private readonly uint _prefix; - private readonly bool _withdraw; - private readonly List _ownerSlots; - private readonly int _ownerSlotLimit; - private readonly LandblockPhysics? _demotedLandblock; - private readonly CellGraphTerrain? _demotedTerrain; - private IEnumerator>? _cells; - private IEnumerator>? - _flatCells; - private IEnumerator>? _flatEnvCells; - private IEnumerator>? _buildings; - private IEnumerator>? _envCells; - private int _ownerIndex; - private int _outdoorIndex; - private int _phase; - - internal LandblockRetirementCursor( - PhysicsEngine destination, - PhysicsEngine authoritative, - uint landblockId, - bool withdraw) - { - _destinationEngine = destination; - _destinationCache = destination.DataCache - ?? throw new InvalidOperationException( - "Collision engine has no data cache."); - _destination = destination._collisionWorld.Capture(); - _authoritative = authoritative._collisionWorld.Capture(); - _canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; - _prefix = landblockId & 0xFFFF0000u; - _withdraw = withdraw; - _ownerSlots = _destination.ShadowOwnerSlots; - _ownerSlotLimit = _ownerSlots.Count; - if (!withdraw) - { - _authoritative.Landblocks.TryGetValue( - _canonical, - out _demotedLandblock); - _authoritative.Terrain.TryGetValue( - _prefix, - out _demotedTerrain); - } - } - - internal uint LandblockId => _canonical; - - internal LandblockRetirementStep Advance() - { - while (true) - { - switch (_phase) - { - case 0: - if (_ownerIndex < _ownerSlotLimit) - { - uint ownerId = _ownerSlots[_ownerIndex++]; - if (ownerId != 0u) - { - _destinationEngine.ShadowObjects - .RetireOwnerFromLandblock( - ownerId, - _canonical); - } - return Worked(); - } - _phase++; - continue; - case 1: - _cells ??= _destination.CellStruct.GetEnumerator(); - if (RemoveOneInPrefix(_cells, _destination.CellStruct)) - return Worked(); - DisposeEnumerator(ref _cells); - _phase++; - continue; - case 2: - _flatCells ??= _destination.FlatCellStruct.GetEnumerator(); - if (RemoveOneInPrefix( - _flatCells, - _destination.FlatCellStruct)) - return Worked(); - DisposeEnumerator(ref _flatCells); - _phase++; - continue; - case 3: - _flatEnvCells ??= _destination.FlatEnvCell.GetEnumerator(); - if (RemoveOneInPrefix( - _flatEnvCells, - _destination.FlatEnvCell)) - return Worked(); - DisposeEnumerator(ref _flatEnvCells); - _phase++; - continue; - case 4: - _buildings ??= _destination.Buildings.GetEnumerator(); - if (RemoveOneInPrefix( - _buildings, - _destination.Buildings)) - return Worked(); - DisposeEnumerator(ref _buildings); - _phase++; - continue; - case 5: - _envCells ??= _destination.EnvCells.GetEnumerator(); - if (RemoveOneInPrefix(_envCells, _destination.EnvCells)) - return Worked(); - DisposeEnumerator(ref _envCells); - _phase++; - continue; - case 6: - if (_outdoorIndex < 0x40) - { - uint id = _prefix | (uint)++_outdoorIndex; - _destination.ShadowCells.Remove(id); - if (_withdraw) - { - _destination.OutdoorCells.TryRemove(id, out _); - } - else if (_authoritative.OutdoorCells.TryGetValue( - id, - out ObjCell? outdoor)) - { - _destination.OutdoorCells[id] = outdoor; - } - return Worked(); - } - _phase++; - continue; - case 7: - if (_withdraw) - { - _destinationEngine._landblocks.Remove(_canonical); - _destinationEngine.RemoveLandblockSlot(_canonical); - _destination.Terrain.TryRemove(_prefix, out _); - } - else - { - if (_demotedLandblock is not null) - { - _destinationEngine._landblocks[_canonical] = - _demotedLandblock; - _destinationEngine.EnsureLandblockSlot(_canonical); - } - if (_demotedTerrain is not null) - _destination.Terrain[_prefix] = _demotedTerrain; - } - _phase++; - return Worked(); - case 8: - uint currentCellId = - _destinationCache.CellGraph.CurrCell?.Id ?? 0u; - if ((currentCellId & 0xFFFF0000u) == _prefix - && (_withdraw - || (currentCellId & 0xFFFFu) >= 0x0100u)) - { - _destinationCache.CellGraph.CurrCell = null; - } - _phase++; - return new LandblockRetirementStep( - Completed: true, - Worked: false); - default: - return new LandblockRetirementStep( - Completed: true, - Worked: false); - } - } - } - - private LandblockRetirementStep Worked() => new( - Completed: false, - Worked: true); - - private bool RemoveOneInPrefix( - IEnumerator> source, - IDictionary destination) - { - if (!source.MoveNext()) - return false; - uint id = source.Current.Key; - if ((id & 0xFFFF0000u) == _prefix) - { - destination.Remove(id); - _destination.ShadowCells.Remove(id); - } - return true; - } - - private static void DisposeEnumerator( - ref IEnumerator>? enumerator) - { - enumerator?.Dispose(); - enumerator = null; - } - - public void Dispose() - { - DisposeEnumerator(ref _cells); - DisposeEnumerator(ref _flatCells); - DisposeEnumerator(ref _flatEnvCells); - DisposeEnumerator(ref _buildings); - DisposeEnumerator(ref _envCells); - } - } - - internal readonly record struct LandblockRetirementStep( - bool Completed, - bool Worked); - - /// - /// Applies one already-committed landblock delta to a later off-side root. - /// Each advance mutates at most one dictionary leaf, one synthesized - /// outdoor cell, or one logical shadow owner. + /// Applies one sealed landblock delta to the active collision root. Each + /// advance mutates at most one dictionary leaf, one synthesized outdoor + /// cell, or yields one logical shadow owner to the committing caller. + /// drains it in one synchronous + /// update-thread call. /// internal sealed class LandblockReplacementApplyCursor : IDisposable { @@ -597,48 +431,88 @@ public sealed class PhysicsEngine switch (_phase) { case 0: - if (RemoveOne(_destination.CellStruct, data.CellIdsToRemove)) + if (_index < data.CellIdsToRemove.Count) + { + _destination.RemoveCellStruct( + data.CellIdsToRemove[_index++]); return Worked(); + } NextPhase(); continue; case 1: - if (InstallOne(_destination.CellStruct, data.Cells)) + if (_index < data.Cells.Count) + { + KeyValuePair pair = + data.Cells[_index++]; + _destination.SetCellStruct(pair.Key, pair.Value); return Worked(); + } NextPhase(); continue; case 2: - if (RemoveOne(_destination.FlatCellStruct, data.FlatCellIdsToRemove)) + if (_index < data.FlatCellIdsToRemove.Count) + { + _destination.RemoveFlatCellStruct( + data.FlatCellIdsToRemove[_index++]); return Worked(); + } NextPhase(); continue; case 3: - if (InstallOne(_destination.FlatCellStruct, data.FlatCells)) + if (_index < data.FlatCells.Count) + { + KeyValuePair + pair = data.FlatCells[_index++]; + _destination.SetFlatCellStruct(pair.Key, pair.Value); return Worked(); + } NextPhase(); continue; case 4: - if (RemoveOne(_destination.FlatEnvCell, data.FlatEnvCellIdsToRemove)) + if (_index < data.FlatEnvCellIdsToRemove.Count) + { + _destination.RemoveFlatEnvCell( + data.FlatEnvCellIdsToRemove[_index++]); return Worked(); + } NextPhase(); continue; case 5: - if (InstallOne(_destination.FlatEnvCell, data.FlatEnvCells)) + if (_index < data.FlatEnvCells.Count) + { + KeyValuePair pair = + data.FlatEnvCells[_index++]; + _destination.SetFlatEnvCell(pair.Key, pair.Value); return Worked(); + } NextPhase(); continue; case 6: - if (RemoveOne(_destination.Buildings, data.BuildingIdsToRemove)) + if (_index < data.BuildingIdsToRemove.Count) + { + _destination.RemoveBuilding( + data.BuildingIdsToRemove[_index++]); return Worked(); + } NextPhase(); continue; case 7: - if (InstallOne(_destination.Buildings, data.Buildings)) + if (_index < data.Buildings.Count) + { + KeyValuePair pair = + data.Buildings[_index++]; + _destination.SetBuilding(pair.Key, pair.Value); return Worked(); + } NextPhase(); continue; case 8: - if (RemoveOne(_destination.EnvCells, graph.EnvCellIdsToRemove)) + if (_index < graph.EnvCellIdsToRemove.Count) + { + _destination.RemoveEnvCell( + graph.EnvCellIdsToRemove[_index++]); return Worked(); + } NextPhase(); continue; case 9: @@ -689,8 +563,13 @@ public sealed class PhysicsEngine NextPhase(); continue; case 10: - if (InstallOne(_destination.EnvCells, graph.EnvCells)) + if (_index < graph.EnvCells.Count) + { + KeyValuePair pair = + graph.EnvCells[_index++]; + _destination.SetEnvCell(pair.Key, pair.Value); return Worked(); + } NextPhase(); continue; case 11: @@ -750,67 +629,30 @@ public sealed class PhysicsEngine _index = 0; } - private bool RemoveOne( - IDictionary destination, - IReadOnlyList ids) - { - if (_index >= ids.Count) - return false; - destination.Remove(ids[_index++]); - return true; - } - - private bool InstallOne( - IDictionary destination, - IReadOnlyList> entries) - { - if (_index >= entries.Count) - return false; - KeyValuePair pair = entries[_index++]; - destination[pair.Key] = pair.Value; - return true; - } - public void Dispose() { } } /// - /// Retained one-leaf-at-a-time materializer for an off-side collision - /// generation. Construction captures the current root reference only; no - /// resident dictionary is copied until . Runtime's - /// owner journal reconciles mutations that occur while this cursor walks. + /// O3 (2026-08-02): the empty off-side staging root for one landblock + /// collision generation. The pre-O3 builder materialized a whole-world + /// clone one leaf per host step; that clone existed only so the old + /// whole-root activation swap and the staged retained-owner refloods had + /// a complete world to stand on. With the per-landblock delta commit and + /// commit-time refloods against the live world (retail + /// CObjCell::init_objects 0x0052B420 → + /// CPhysicsObj::recalc_cross_cells 0x00515A30), admission is O(1) + /// and the staging root holds only the target landblock's authored + /// content. Immutable GfxObj/Setup catalogs still read through to the + /// active cache via the staging cache's read fallback. /// internal sealed class CollisionStagingBuilder : IDisposable { - private readonly PhysicsEngine _active; - private readonly CollisionWorldState _source; - private readonly CollisionWorldState _destination; - private readonly ShadowObjectRegistry _sourceShadows; - private readonly uint _targetPrefix; - private readonly HashSet _suppressedPrefixes = new(); - private readonly int _landblockSlotLimit; - private readonly int _ownerSlotLimit; - private IEnumerator>? _cells; - private IEnumerator>? _flatCells; - private IEnumerator>? _flatEnvCells; - private IEnumerator>? _buildings; - private IEnumerator>? _envCells; - private IEnumerator>? _terrain; - private IEnumerator>? _outdoorCells; - private int _landblockIndex; - private int _ownerIndex; - private int _phase; - internal CollisionStagingBuilder( PhysicsEngine active, - PhysicsDataCache activeCache, - uint targetPrefix) + PhysicsDataCache activeCache) { - _active = active; - _targetPrefix = targetPrefix; - _source = active._collisionWorld.Capture(); var stagingSlot = new CollisionWorldStateSlot(); StagingCache = activeCache.CreateEmptyCollisionStaging(stagingSlot); StagingEngine = new PhysicsEngine @@ -818,170 +660,13 @@ public sealed class PhysicsEngine DataCache = StagingCache, Objects = active.Objects, }; - _destination = stagingSlot.Capture(); - _sourceShadows = new ShadowObjectRegistry( - new CollisionWorldStateSlot(_source)); - _landblockSlotLimit = _source.LandblockSlots.Count; - _ownerSlotLimit = _source.ShadowOwnerSlots.Count; } internal PhysicsDataCache StagingCache { get; } internal PhysicsEngine StagingEngine { get; } - internal int WorkUnits { get; private set; } - internal bool Completed => _phase == 9; - - /// - /// Prevent a landblock retired after this cursor captured its source - /// root from being copied back into the draft by a later phase. - /// Already-copied leaves are retired by the caller before cloning - /// resumes; this tombstone covers every leaf not visited yet. - /// - internal void SuppressLandblock(uint landblockId) => - _suppressedPrefixes.Add(landblockId & 0xFFFF0000u); - - internal bool Advance() - { - switch (_phase) - { - case 0: - if (_landblockIndex < _landblockSlotLimit) - { - uint id = _source.LandblockSlots[_landblockIndex++]; - if (id != 0u - && (id & 0xFFFF0000u) != _targetPrefix - && !_suppressedPrefixes.Contains( - id & 0xFFFF0000u) - && _source.Landblocks.TryGetValue( - id, - out LandblockPhysics? landblock)) - { - StagingEngine.InstallLandblockClone(id, landblock); - } - WorkUnits++; - return false; - } - _phase++; - return false; - case 1: - _cells ??= _source.CellStruct.GetEnumerator(); - if (CopyOneOutsideTarget(_cells, _destination.CellStruct)) - return CountOne(); - DisposeEnumerator(ref _cells); - _phase++; - return false; - case 2: - _flatCells ??= _source.FlatCellStruct.GetEnumerator(); - if (CopyOneOutsideTarget(_flatCells, _destination.FlatCellStruct)) - return CountOne(); - DisposeEnumerator(ref _flatCells); - _phase++; - return false; - case 3: - _flatEnvCells ??= _source.FlatEnvCell.GetEnumerator(); - if (CopyOneOutsideTarget(_flatEnvCells, _destination.FlatEnvCell)) - return CountOne(); - DisposeEnumerator(ref _flatEnvCells); - _phase++; - return false; - case 4: - _buildings ??= _source.Buildings.GetEnumerator(); - if (CopyOneOutsideTarget(_buildings, _destination.Buildings)) - return CountOne(); - DisposeEnumerator(ref _buildings); - _phase++; - return false; - case 5: - _envCells ??= _source.EnvCells.GetEnumerator(); - if (CopyOneOutsideTarget(_envCells, _destination.EnvCells)) - return CountOne(); - DisposeEnumerator(ref _envCells); - _phase++; - return false; - case 6: - _terrain ??= _source.Terrain.GetEnumerator(); - if (CopyOneOutsideTarget(_terrain, _destination.Terrain)) - return CountOne(); - DisposeEnumerator(ref _terrain); - _phase++; - return false; - case 7: - _outdoorCells ??= _source.OutdoorCells.GetEnumerator(); - if (CopyOneOutsideTarget(_outdoorCells, _destination.OutdoorCells)) - return CountOne(); - DisposeEnumerator(ref _outdoorCells); - _phase++; - return false; - case 8: - if (_ownerIndex < _ownerSlotLimit) - { - uint ownerId = _source.ShadowOwnerSlots[_ownerIndex++]; - if (ownerId != 0u - && !_sourceShadows.IsStaticOwnerRootedIn( - ownerId, - _targetPrefix) - && !IsSuppressedStaticOwner(ownerId) - && !StagingEngine.ShadowObjects.HasLogicalOwner( - ownerId)) - { - StagingEngine.ShadowObjects.MirrorOwnerFrom( - _sourceShadows, - ownerId); - } - WorkUnits++; - return false; - } - uint currentCellId = _active.DataCache?.CellGraph.CurrCell?.Id ?? 0u; - StagingCache.CellGraph.CurrCell = - StagingCache.CellGraph.GetVisible(currentCellId); - _phase++; - return true; - default: - return true; - } - } - - private bool CountOne() - { - WorkUnits++; - return false; - } - - private bool CopyOneOutsideTarget( - IEnumerator> source, - IDictionary destination) - { - if (!source.MoveNext()) - return false; - KeyValuePair pair = source.Current; - uint prefix = pair.Key & 0xFFFF0000u; - if (prefix != _targetPrefix - && !_suppressedPrefixes.Contains(prefix)) - destination[pair.Key] = pair.Value; - return true; - } - - private bool IsSuppressedStaticOwner(uint ownerId) - => _sourceShadows.TryGetStaticOwnerRootPrefix( - ownerId, - out uint prefix) - && _suppressedPrefixes.Contains(prefix); - - private static void DisposeEnumerator( - ref IEnumerator>? enumerator) - { - enumerator?.Dispose(); - enumerator = null; - } public void Dispose() { - DisposeEnumerator(ref _cells); - DisposeEnumerator(ref _flatCells); - DisposeEnumerator(ref _flatEnvCells); - DisposeEnumerator(ref _buildings); - DisposeEnumerator(ref _envCells); - DisposeEnumerator(ref _terrain); - DisposeEnumerator(ref _outdoorCells); } } diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index e367b2c9..52a2006a 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -1983,6 +1983,78 @@ public sealed class ShadowObjectRegistry internal uint GetOwnerSlot(int index) => _ownerSlots[index]; + /// + /// O3 (2026-08-02): commit-time owner application for one landblock + /// delta, replacing the deleted staged whole-world reflood context. + /// Retail hydrates a cell and refloods the objects associated with it — + /// CObjCell::init_objects (0x0052B420) → + /// CPhysicsObj::recalc_cross_cells (0x00515A30); the per-landblock + /// streaming analogue is: adopt each staged owner's registration/shape + /// payload and recalculate its cross-cells against the live post-delta + /// world, retire the outgoing generation's authored statics that were not + /// re-authored, and re-run the flood for every retained live owner + /// touching the replaced landblock. + /// + internal void ApplyCommittedOwnerReplacement( + ShadowObjectRegistry stagingSource, + uint ownerId, + uint landblockId) + { + ArgumentNullException.ThrowIfNull(stagingSource); + if (stagingSource.HasLogicalOwner(ownerId)) + { + // Staged owner (authored target static, or an owner registered + // directly into the generation): adopt its payload, then + // recalc_cross_cells against the live world — the staged flood + // only saw the target-only staging root, so a seam footprint + // completes here. + MirrorOwnerFrom(stagingSource, ownerId); + RefloodOwnerForLandblock(ownerId, landblockId); + return; + } + if (IsStaticOwnerRootedIn(ownerId, landblockId)) + { + // Outgoing generation's authored static, not re-authored by the + // replacement: it ends with its landblock (same lifetime rule as + // RetireOwnerFromLandblock's static branch). + DeregisterCore(ownerId, publishMutation: false); + RemoveOwnerPrefixMembership(ownerId); + _ownerVersions.Remove(ownerId); + AdvanceMutationRevision(); + return; + } + // Retained live owner touching the replaced landblock: recalculate its + // cross-cells against the new topology. Suspended or since-removed + // owners no-op inside the reflood. + RefloodOwnerForLandblock(ownerId, landblockId); + } + + /// + /// O3 (2026-08-02): retail CObjCell::init_objects refloods every + /// object associated with the hydrated cell at hydration time. Owners + /// that became associated with the replaced landblock after the sealed + /// capture (a mover entering the prefix mid-publication) are present in + /// the live prefix-owner slots but absent from the sealed owner list; + /// recalculate their cross-cells against the just-installed topology. + /// + internal void RefloodPrefixOwnersAfterReplacement( + uint landblockId, + IReadOnlyList sealedOwnerIds) + { + uint prefix = landblockId & 0xFFFF0000u; + if (!_prefixOwnerSlots.TryGetValue(prefix, out List? slots)) + return; + var applied = new HashSet(sealedOwnerIds); + int limit = slots.Count; + for (int index = 0; index < limit; index++) + { + uint ownerId = slots[index]; + if (ownerId == 0u || !applied.Add(ownerId)) + continue; + RefloodOwnerForLandblock(ownerId, landblockId); + } + } + /// /// Refreshes one staging owner from the exact active payload, then floods /// it against the staging generation's complete cell graph. The returned @@ -2321,14 +2393,16 @@ public sealed class ShadowObjectRegistry if (_stagingSlotIndex < _stagingSlotLimit) { uint ownerId = _stagingSlots![_stagingSlotIndex++]; - if (_staging._entityReg.TryGetValue( - ownerId, - out RegistrationRecord? registration) - && registration.IsStatic - && (registration.SeedCellId & 0xFFFF0000u) == _prefix) - { + // O2 (2026-08-02): every staged logical owner in the + // target's prefix slots — authored statics AND owners + // registered directly into the staging generation — + // must reach the active world through the delta + // commit's owner installs; the old whole-root transfer + // carried them implicitly. Mirrored owners identical + // to their active state reinstall in place, so the + // wider filter stays exact and prefix-scoped. + if (_staging._entityReg.ContainsKey(ownerId)) AddOwner(ownerId); - } WorkUnits++; return false; } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 7b4f05b7..cfdbb70a 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -46,7 +46,8 @@ public sealed class CellGraph public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId); - public void Add(EnvCell cell) => _envCells.TryAdd(cell.Id, cell); + public void Add(EnvCell cell) => + _collisionWorld.Current.TryAddEnvCell(cell.Id, cell); /// Any id in the cell's landblock; masked to (id & 0xFFFF0000). public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin) @@ -96,8 +97,27 @@ public sealed class CellGraph _terrain.TryRemove(lb, out _); for (uint low = 1u; low <= 0x40u; low++) _outdoorCells.TryRemove(lb | low, out _); - foreach (var id in new List(_envCells.Keys)) - if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); + RemoveEnvCellPrefixKeys(lb); + } + + /// + /// O1: retire one prefix's EnvCells through the installed-key ledger — + /// O(prefix keys), never a whole-map scan. Removal tombstones the + /// captured slot list, so index iteration stays exact. + /// + private void RemoveEnvCellPrefixKeys(uint prefix) + { + CollisionWorldState world = _collisionWorld.Current; + List? slots = world.EnvCellKeys.SlotsForPrefix(prefix); + if (slots is null) + return; + int limit = slots.Count; + for (int index = 0; index < limit; index++) + { + uint id = slots[index]; + if (id != 0u) + world.RemoveEnvCell(id); + } } /// @@ -113,8 +133,7 @@ public sealed class CellGraph { CurrCell = null; } - foreach (var id in new List(_envCells.Keys)) - if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); + RemoveEnvCellPrefixKeys(lb); } /// The universal id->cell resolver (retail CObjCell::GetVisible). @@ -170,7 +189,10 @@ public sealed class CellGraph private readonly List> _envCells = new(); private readonly HashSet _stagingIds = new(); private readonly List _removeIds = new(); - private IEnumerator>? _enumerator; + private List? _keySlots; + private int _keySlotLimit; + private bool _keySlotsCaptured; + private int _cursor; private int _phase; internal LandblockReplacementBuilder( @@ -181,44 +203,48 @@ public sealed class CellGraph _active = active; _staging = staging; _prefix = landblockId & 0xFFFF0000u; - _enumerator = staging._envCells.GetEnumerator(); } internal bool Advance() { if (_phase == 0) { - if (_enumerator!.MoveNext()) + // O1: enumerate the staging root's installed target-prefix + // EnvCell keys via the ledger instead of scanning the map. + if (TryTakeNextPrefixKey( + _staging._collisionWorld.Current.EnvCellKeys, + out uint stagingId)) { - KeyValuePair pair = _enumerator.Current; - if ((pair.Key & 0xFFFF0000u) == _prefix - && (pair.Key & 0xFFFFu) >= 0x0100u) + if ((stagingId & 0xFFFFu) >= 0x0100u + && _staging._envCells.TryGetValue( + stagingId, + out EnvCell? cell)) { - _envCells.Add(pair); - _stagingIds.Add(pair.Key); + _envCells.Add( + new KeyValuePair(stagingId, cell)); + _stagingIds.Add(stagingId); } return false; } - _enumerator.Dispose(); - _enumerator = _active._envCells.GetEnumerator(); _phase = 1; return false; } if (_phase == 1) { - if (_enumerator!.MoveNext()) + // O1: active-side removal capture through the ledger — no + // cross-frame live enumerator over the active map. + if (TryTakeNextPrefixKey( + _active._collisionWorld.Current.EnvCellKeys, + out uint activeId)) { - uint id = _enumerator.Current.Key; - if ((id & 0xFFFF0000u) == _prefix - && (id & 0xFFFFu) >= 0x0100u - && !_stagingIds.Contains(id)) + if ((activeId & 0xFFFFu) >= 0x0100u + && !_stagingIds.Contains(activeId) + && _active._envCells.ContainsKey(activeId)) { - _removeIds.Add(id); + _removeIds.Add(activeId); } return false; } - _enumerator.Dispose(); - _enumerator = null; bool hasTerrain = _staging._terrain.TryGetValue( _prefix, out var terrain); @@ -233,12 +259,36 @@ public sealed class CellGraph return true; } + private bool TryTakeNextPrefixKey(PrefixKeyIndex ledger, out uint key) + { + if (!_keySlotsCaptured) + { + _keySlots = ledger.SlotsForPrefix(_prefix); + _keySlotLimit = _keySlots?.Count ?? 0; + _keySlotsCaptured = true; + _cursor = 0; + } + while (_cursor < _keySlotLimit) + { + uint candidate = _keySlots![_cursor++]; + if (candidate != 0u) + { + key = candidate; + return true; + } + } + key = 0u; + _keySlots = null; + _keySlotsCaptured = false; + return false; + } + internal PreparedCellGraphLandblock? Prepared { get; private set; } public void Dispose() { - _enumerator?.Dispose(); - _enumerator = null; + _keySlots = null; + _keySlotsCaptured = false; } } } diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index c6fbc561..b5ee2bf8 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -176,186 +176,38 @@ public readonly record struct RuntimeCollisionGenerationCommitted( ulong Generation, bool Ready); -/// -/// One process-local, versioned owner-mutation stream shared by every -/// collision draft. A live mutation is appended once; drafts consume only the -/// newest still-relevant record for each owner at their own metered cursor. -/// -internal sealed class CollisionOwnerMutationJournal -{ - private readonly List _entries = new(capacity: 256); - private readonly Dictionary _entryIndexByOwner = new(); - private long _nextSequence = 1; - private long _compactionThreshold; - private int _compactionIndex; - - internal long NextSequence => _nextSequence; - internal int Count => _entries.Count; - internal int ActiveCount => _entryIndexByOwner.Count; - internal bool HasPendingCompaction => _compactionThreshold != 0L; - - internal MutationRecord Record( - uint ownerId, - ulong ownerVersion, - long latestPreparationStartSequence) - { - long sequence = _nextSequence; - _nextSequence = checked(_nextSequence + 1L); - if (_entryIndexByOwner.TryGetValue(ownerId, out int index)) - { - if (_entries[index].Sequence >= latestPreparationStartSequence) - { - _entries[index] = new Entry(sequence, ownerId, ownerVersion); - return new MutationRecord(sequence, index); - } - // At least one newer draft captured the live root after this slot. - // Leave a tombstone for older cursors and append one new coalescing - // slot that every draft created since then can observe. - _entries[index] = default; - } - _entryIndexByOwner[ownerId] = _entries.Count; - _entries.Add(new Entry(sequence, ownerId, ownerVersion)); - return new MutationRecord( - sequence, - _entries.Count - 1); - } - - internal Entry Get(int index) => _entries[index]; - - internal bool TryGet( - uint ownerId, - out Entry entry, - out int slotIndex) - { - if (_entryIndexByOwner.TryGetValue(ownerId, out slotIndex)) - { - entry = _entries[slotIndex]; - return true; - } - entry = default; - slotIndex = -1; - return false; - } - - internal void RequestCompactionBefore(long sequence) - { - if (sequence <= _compactionThreshold) - return; - _compactionThreshold = sequence; - _compactionIndex = 0; - } - - internal bool AdvanceCompaction() - { - if (!HasPendingCompaction) - return false; - if (_compactionIndex < _entries.Count) - { - int index = _compactionIndex++; - Entry entry = _entries[index]; - if (entry.OwnerId != 0u - && entry.Sequence < _compactionThreshold - && _entryIndexByOwner.TryGetValue( - entry.OwnerId, - out int currentIndex) - && currentIndex == index) - { - _entryIndexByOwner.Remove(entry.OwnerId); - _entries[index] = default; - } - return true; - } - if (_entries.Count != 0 && _entries[^1].OwnerId == 0u) - { - _entries.RemoveAt(_entries.Count - 1); - return true; - } - _compactionThreshold = 0L; - _compactionIndex = 0; - return false; - } - - internal void Clear() - { - _entries.Clear(); - _entryIndexByOwner.Clear(); - _compactionThreshold = 0L; - _compactionIndex = 0; - } - - internal readonly record struct Entry( - long Sequence, - uint OwnerId, - ulong OwnerVersion); - - internal readonly record struct MutationRecord( - long Sequence, - int SlotIndex); -} - /// /// One off-side collision generation. It owns a private cache, cell graph, -/// engine, and shadow registry materialized incrementally from an O(1) root -/// snapshot. Hosts may populate it incrementally, but only Runtime can activate -/// it. +/// engine, and shadow registry holding ONLY the target landblock's authored +/// content — O3 (2026-08-02): admission no longer materializes a clone of the +/// resident world. The commit installs the sealed per-landblock delta into +/// the active root and recalculates owner cross-cells against the live world +/// (retail CObjCell::init_objects 0x0052B420 → +/// CPhysicsObj::recalc_cross_cells 0x00515A30), so no owner-mutation +/// journal, peer rebase, or draft retirement machinery exists. Hosts may +/// populate the generation incrementally, but only Runtime can activate it. /// internal sealed class PreparedLandblockCollisionGeneration : IDisposable { internal const int MaxConcurrentCollisionPreparations = 256; private readonly RuntimePhysicsState _owner; private readonly RuntimeCollisionAdmission _admission; - private readonly Dictionary _retainedOwnerVersions = new(); private readonly List _retainedOwnerIds = new(); private readonly HashSet _retainedOwnerSet = new(); - private readonly HashSet _armedOwners = new(); - private readonly HashSet _pendingOwners = new(); - private readonly Queue _pendingOwnerQueue = new(); - private readonly PhysicsEngine.PreparedPhysicsEngineLandblock?[] - _pendingCommittedRebases = - new PhysicsEngine.PreparedPhysicsEngineLandblock?[ - MaxConcurrentCollisionPreparations]; - private int _pendingCommittedRebaseHead; - private int _pendingCommittedRebaseCount; - private PhysicsEngine.LandblockReplacementApplyCursor? - _activeCommittedRebase; - private readonly Dictionary _retiredLandblocks = new(); - private readonly HashSet _pendingRetirementSet = new(); - private readonly Queue _pendingRetirements = new(); - private PhysicsEngine.LandblockRetirementCursor? _activeRetirement; - private readonly HashSet _pendingCloneOwnerMutations = new(); - private readonly Queue _pendingCloneOwnerMutationQueue = new(); - private readonly HashSet _pendingRoutedOwnerMutations = new(); - private readonly Queue _pendingRoutedOwnerMutationQueue = new(); - private PhysicsEngine.CollisionStagingBuilder? _stagingBuilder; private ShadowObjectRegistry.RetainedRefloodOwnerScan? _retainedOwnerScan; private PhysicsEngine.LandblockReplacementBuilder? _sealBuilder; private PhysicsEngine.PreparedPhysicsEngineLandblock? _sealedReplacement; - private readonly CollisionOwnerMutationJournal _ownerMutationJournal; - private readonly long _ownerMutationStartSequence; - private readonly Dictionary _observedOwnerMutationSequences = new(); - private readonly HashSet _subscribedOwners = new(); - private readonly HashSet _exactSubscribedOwners = new(); - private long _ownerMutationScanEpoch; - private int _ownerMutationScanIndex; - private bool _sealedExactWriteThrough; private bool _disposed; internal PreparedLandblockCollisionGeneration( RuntimePhysicsState owner, RuntimeCollisionAdmission admission, PhysicsEngine.CollisionStagingBuilder stagingBuilder, - CollisionOwnerMutationJournal ownerMutationJournal, long sequence) { _owner = owner; _admission = admission; - _stagingBuilder = stagingBuilder - ?? throw new ArgumentNullException(nameof(stagingBuilder)); - _ownerMutationJournal = ownerMutationJournal - ?? throw new ArgumentNullException(nameof(ownerMutationJournal)); - _ownerMutationStartSequence = ownerMutationJournal.NextSequence; - _ownerMutationScanEpoch = ownerMutationJournal.NextSequence; - _ownerMutationScanIndex = ownerMutationJournal.Count; + ArgumentNullException.ThrowIfNull(stagingBuilder); DataCache = stagingBuilder.StagingCache; Engine = stagingBuilder.StagingEngine; Sequence = sequence; @@ -364,358 +216,25 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal PhysicsDataCache DataCache { get; } internal PhysicsEngine Engine { get; } internal long Sequence { get; } - internal long OwnerMutationStartSequence => _ownerMutationStartSequence; internal uint[] GfxObjectIds { get; private set; } = Array.Empty(); internal uint[] SetupIds { get; private set; } = Array.Empty(); - internal IReadOnlyDictionary RetainedOwnerVersions => - _retainedOwnerVersions; internal bool IsDisposed => _disposed; internal bool RetainedOwnerCaptureComplete { get; private set; } - internal bool StagingCloneComplete { get; private set; } internal bool IsSealed => _sealedReplacement is not null; - internal bool IsReadyForActivation => - _sealedReplacement is not null - && _pendingOwners.Count == 0 - && _pendingRoutedOwnerMutations.Count == 0; - - internal bool HasPendingCommittedRebase => - _activeCommittedRebase is not null - || _pendingCommittedRebaseCount != 0; - - internal bool HasPendingRetirement => - _activeRetirement is not null - || _pendingRetirements.Count != 0; - - internal bool IsOwnerMutationReconciliationCurrent => - _ownerMutationScanEpoch == _ownerMutationJournal.NextSequence - && _ownerMutationScanIndex >= _ownerMutationJournal.Count; - - internal RuntimeCollisionJournalStep AdvanceOwnerMutationReconciliation() - { - EnsureUsable(); - if (!StagingCloneComplete) - { - throw new InvalidOperationException( - "Collision owner mutations cannot reconcile before staging materialization."); - } - long epoch = _ownerMutationJournal.NextSequence; - // Already-visited slots are subscribed and receive either exact - // target write-through or a coalesced metered replay. - // Continue from the current cursor when the journal epoch advances; - // restarting at zero would let one continuously-moving unrelated - // owner starve every later slot. - _ownerMutationScanEpoch = epoch; - if (_ownerMutationScanIndex < _ownerMutationJournal.Count) - { - CollisionOwnerMutationJournal.Entry entry = - _ownerMutationJournal.Get(_ownerMutationScanIndex++); - if (entry.OwnerId == 0u) - { - return new RuntimeCollisionJournalStep( - Completed: _ownerMutationScanIndex - >= _ownerMutationJournal.Count, - Worked: true); - } - - // Every visited slot gets a cheap notification subscription so a - // later same-prefix mutation can enqueue one metered replay. Only - // target-relevant owners are promoted to exact write-through. - SubscribeOwner(entry.OwnerId, exact: false); - - if (entry.Sequence < _ownerMutationStartSequence - || (_observedOwnerMutationSequences.TryGetValue( - entry.OwnerId, - out long observed) - && observed >= entry.Sequence)) - { - return new RuntimeCollisionJournalStep( - Completed: _ownerMutationScanIndex - >= _ownerMutationJournal.Count, - Worked: true); - } - _observedOwnerMutationSequences[entry.OwnerId] = entry.Sequence; - if (ObserveOwnerMutation(entry.OwnerId)) - SubscribeOwner(entry.OwnerId, exact: true); - return new RuntimeCollisionJournalStep( - Completed: _ownerMutationScanIndex - >= _ownerMutationJournal.Count, - Worked: true); - } - return new RuntimeCollisionJournalStep( - Completed: true, - Worked: false); - } - - internal void ObserveSubscribedOwnerMutation( - uint ownerId, - long sequence, - long epochBefore, - int countBefore) - { - if (_disposed) - return; - bool wasCurrent = _ownerMutationScanEpoch == epochBefore - && _ownerMutationScanIndex >= countBefore; - _observedOwnerMutationSequences[ownerId] = sequence; - if (!_sealedExactWriteThrough - && !_exactSubscribedOwners.Contains(ownerId)) - { - if (_pendingRoutedOwnerMutations.Add(ownerId)) - _pendingRoutedOwnerMutationQueue.Enqueue(ownerId); - return; - } - _ = ObserveOwnerMutation(ownerId); - if (wasCurrent) - { - _ownerMutationScanEpoch = _ownerMutationJournal.NextSequence; - _ownerMutationScanIndex = _ownerMutationJournal.Count; - } - } - - internal void ObserveRoutedOwnerMembershipMutation(uint ownerId) - { - if (_disposed || _exactSubscribedOwners.Contains(ownerId)) - return; - if (_sealedExactWriteThrough) - { - _ = ObserveOwnerMutation(ownerId); - SubscribeOwner(ownerId, exact: true); - return; - } - if (_pendingRoutedOwnerMutations.Add(ownerId)) - _pendingRoutedOwnerMutationQueue.Enqueue(ownerId); - } - - internal RuntimeCollisionJournalStep AdvanceRoutedOwnerMutation() - { - EnsureUsable(); - while (_pendingRoutedOwnerMutationQueue.Count != 0) - { - uint ownerId = _pendingRoutedOwnerMutationQueue.Dequeue(); - if (!_pendingRoutedOwnerMutations.Remove(ownerId)) - continue; - if (ObserveOwnerMutation(ownerId)) - SubscribeOwner(ownerId, exact: true); - if (_ownerMutationJournal.TryGet( - ownerId, - out CollisionOwnerMutationJournal.Entry entry, - out int slotIndex)) - { - _observedOwnerMutationSequences[ownerId] = entry.Sequence; - if (slotIndex == _ownerMutationScanIndex) - { - _ownerMutationScanIndex++; - _ownerMutationScanEpoch = - _ownerMutationJournal.NextSequence; - } - else if (_ownerMutationScanIndex - >= _ownerMutationJournal.Count) - { - // This slot was already behind the cursor. The routed - // prefix transition is the only previously-unsubscribed - // mutation that can make it target-relevant, so observing - // its latest coalesced entry closes the current epoch. - _ownerMutationScanEpoch = - _ownerMutationJournal.NextSequence; - } - } - return new RuntimeCollisionJournalStep( - Completed: _pendingRoutedOwnerMutations.Count == 0, - Worked: true); - } - return new RuntimeCollisionJournalStep( - Completed: true, - Worked: false); - } + internal bool IsReadyForActivation => _sealedReplacement is not null; + /// + /// O3: admission captures the O(1) empty staging root at construction; + /// there is no resident-world materialization to advance. + /// internal RuntimeCollisionPreparationStep AdvanceStagingClone() { EnsureUsable(); - if (!StagingCloneComplete) - { - int before = _stagingBuilder!.WorkUnits; - if (!_stagingBuilder.Advance()) - { - return new RuntimeCollisionPreparationStep( - Completed: false, - WorkUnits: _stagingBuilder.WorkUnits - before); - } - _stagingBuilder.Dispose(); - _stagingBuilder = null; - StagingCloneComplete = true; - } - - while (_pendingCloneOwnerMutationQueue.Count != 0) - { - uint ownerId = _pendingCloneOwnerMutationQueue.Dequeue(); - if (!_pendingCloneOwnerMutations.Remove(ownerId)) - continue; - ObserveOwnerMutation(ownerId); - return new RuntimeCollisionPreparationStep( - Completed: false, - WorkUnits: 1); - } return new RuntimeCollisionPreparationStep( Completed: true, WorkUnits: 0); } - internal void EnqueueCommittedRebase( - PhysicsEngine.PreparedPhysicsEngineLandblock replacement) - { - EnsureUsable(); - if (_pendingCommittedRebaseCount - == _pendingCommittedRebases.Length) - { - throw new InvalidOperationException( - "Collision preparation rebase capacity was exceeded."); - } - int tail = (_pendingCommittedRebaseHead - + _pendingCommittedRebaseCount) - % _pendingCommittedRebases.Length; - _pendingCommittedRebases[tail] = replacement; - _pendingCommittedRebaseCount++; - } - - internal void RecordDemotion(uint landblockId) - { - EnsureUsable(); - uint canonical = CanonicalLandblock(landblockId); - InvalidateCommittedRebases(canonical); - _stagingBuilder?.SuppressLandblock(canonical); - EnqueueRetirement(canonical, withdraw: false); - } - - internal void RecordWithdrawal(uint landblockId) - { - EnsureUsable(); - uint canonical = CanonicalLandblock(landblockId); - InvalidateCommittedRebases(canonical); - _stagingBuilder?.SuppressLandblock(canonical); - EnqueueRetirement(canonical, withdraw: true); - } - - internal PhysicsEngine.LandblockRetirementStep AdvanceRetirement() - { - EnsureUsable(); - if (_activeRetirement is null) - { - uint canonical = DequeueRetirement(); - _activeRetirement = Engine.CreateLandblockRetirementCursor( - _owner.Engine, - canonical, - _retiredLandblocks[canonical]); - } - PhysicsEngine.LandblockRetirementStep step = - _activeRetirement.Advance(); - if (step.Completed) - { - _activeRetirement.Dispose(); - _activeRetirement = null; - } - return step; - } - - internal PhysicsEngine.LandblockReplacementApplyStep - AdvanceCommittedRebase() - { - EnsureUsable(); - if (_activeCommittedRebase is null) - { - PhysicsEngine.PreparedPhysicsEngineLandblock replacement = - DequeueCommittedRebase(); - if (_retiredLandblocks.ContainsKey(replacement.LandblockId)) - { - return new PhysicsEngine.LandblockReplacementApplyStep( - Completed: false, - Worked: false, - HasOwner: false, - OwnerId: 0u); - } - _activeCommittedRebase = - Engine.CreateLandblockReplacementApplyCursor(replacement); - } - PhysicsEngine.LandblockReplacementApplyStep step = - _activeCommittedRebase.Advance(); - if (step.HasOwner) - ForceOwnerReflood(step.OwnerId); - if (step.Completed) - { - _activeCommittedRebase.Dispose(); - _activeCommittedRebase = null; - } - return step; - } - - private PhysicsEngine.PreparedPhysicsEngineLandblock - DequeueCommittedRebase() - { - PhysicsEngine.PreparedPhysicsEngineLandblock replacement = - _pendingCommittedRebases[_pendingCommittedRebaseHead] - ?? throw new InvalidOperationException( - "Collision rebase queue contained an empty slot."); - _pendingCommittedRebases[_pendingCommittedRebaseHead] = null; - _pendingCommittedRebaseHead = (_pendingCommittedRebaseHead + 1) - % _pendingCommittedRebases.Length; - _pendingCommittedRebaseCount--; - return replacement; - } - - private void ClearCommittedRebases() - { - while (_pendingCommittedRebaseCount != 0) - _ = DequeueCommittedRebase(); - _pendingCommittedRebaseHead = 0; - } - - private void InvalidateCommittedRebases(uint landblockId) - { - uint canonical = CanonicalLandblock(landblockId); - if (_activeCommittedRebase?.LandblockId == canonical) - { - _activeCommittedRebase.Dispose(); - _activeCommittedRebase = null; - } - - // Queued entries are left in their fixed ring and skipped one per - // later seal step. This keeps retirement admission O(1). - } - - private void EnqueueRetirement(uint canonical, bool withdraw) - { - bool changed = !_retiredLandblocks.TryGetValue( - canonical, - out bool previousWithdraw) - || (withdraw && !previousWithdraw); - _retiredLandblocks[canonical] = previousWithdraw || withdraw; - if (!changed) - return; - if (_activeRetirement?.LandblockId == canonical) - { - _activeRetirement.Dispose(); - _activeRetirement = null; - } - if (!_pendingRetirementSet.Add(canonical)) - return; - _pendingRetirements.Enqueue(canonical); - } - - private uint DequeueRetirement() - { - uint canonical = _pendingRetirements.Dequeue(); - _pendingRetirementSet.Remove(canonical); - return canonical; - } - - private void ClearRetirements() - { - _pendingRetirements.Clear(); - _activeRetirement?.Dispose(); - _activeRetirement = null; - _retiredLandblocks.Clear(); - _pendingRetirementSet.Clear(); - } - internal bool Matches( RuntimePhysicsState owner, RuntimeCollisionAdmission admission) => @@ -725,92 +244,22 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void SetAssetClosure(uint[] gfxObjectIds, uint[] setupIds) { EnsureUsable(); - GfxObjectIds = gfxObjectIds ?? throw new ArgumentNullException(nameof(gfxObjectIds)); + GfxObjectIds = gfxObjectIds + ?? throw new ArgumentNullException(nameof(gfxObjectIds)); SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds)); } + /// + /// Arms one retained owner into the sealed owner list. The owner's + /// cross-cells are recalculated at commit against the live post-delta + /// world (retail recalc_cross_cells), so no staged mirror or version + /// bookkeeping exists. + /// internal void RefreshRetainedOwner(uint ownerId) { EnsureUsable(); EnsureRetainedOwner(ownerId); - _ = Engine.ShadowObjects.RefreshRetainedOwnerFrom( - _owner.Engine.ShadowObjects, - ownerId, - _admission.LandblockId, - out ulong version); - _retainedOwnerVersions[ownerId] = version; - _pendingOwners.Remove(ownerId); - _armedOwners.Add(ownerId); _sealBuilder?.RefreshRetainedOwner(ownerId); - SubscribeOwner(ownerId); - } - - internal bool ObserveOwnerMutation(uint ownerId) - { - if (_disposed) - return false; - if (!StagingCloneComplete) - { - if (_pendingCloneOwnerMutations.Add(ownerId)) - _pendingCloneOwnerMutationQueue.Enqueue(ownerId); - return false; - } - if (_owner.Engine.ShadowObjects.IsStaticOwnerRootedIn( - ownerId, - _admission.LandblockId) - || Engine.ShadowObjects.IsStaticOwnerRootedIn( - ownerId, - _admission.LandblockId)) - { - // The staged build is authoritative for target-root statics. Never - // mirror the outgoing generation back over an omitted/replaced - // authored owner merely because its old live state changed. - return false; - } - bool relevantBefore = Engine.ShadowObjects.OwnerTouchesLandblock( - ownerId, - _admission.LandblockId); - Engine.ShadowObjects.MirrorOwnerFrom( - _owner.Engine.ShadowObjects, - ownerId); - bool relevant = _armedOwners.Contains(ownerId) - || relevantBefore - || _owner.Engine.ShadowObjects.OwnerTouchesLandblock( - ownerId, - _admission.LandblockId); - if (!relevant) - return false; - - EnsureRetainedOwner(ownerId); - RefreshRetainedOwner(ownerId); - return true; - } - - private void ForceOwnerReflood(uint ownerId) - { - if (_disposed) - return; - if (!StagingCloneComplete) - { - if (_pendingCloneOwnerMutations.Add(ownerId)) - _pendingCloneOwnerMutationQueue.Enqueue(ownerId); - return; - } - if (_owner.Engine.ShadowObjects.IsStaticOwnerRootedIn( - ownerId, - _admission.LandblockId) - || Engine.ShadowObjects.IsStaticOwnerRootedIn( - ownerId, - _admission.LandblockId)) - { - return; - } - - Engine.ShadowObjects.MirrorOwnerFrom( - _owner.Engine.ShadowObjects, - ownerId); - EnsureRetainedOwner(ownerId); - RefreshRetainedOwner(ownerId); } internal RuntimeCollisionOwnerCaptureStep AdvanceRetainedOwnerCapture() @@ -860,20 +309,10 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void ResetRetainedOwnerCapture() { EnsureUsable(); - ClearOwnerSubscriptions(); _retainedOwnerScan?.Dispose(); _retainedOwnerScan = null; _retainedOwnerIds.Clear(); _retainedOwnerSet.Clear(); - _retainedOwnerVersions.Clear(); - _armedOwners.Clear(); - _pendingOwners.Clear(); - _pendingOwnerQueue.Clear(); - _pendingCloneOwnerMutations.Clear(); - _pendingCloneOwnerMutationQueue.Clear(); - _pendingRoutedOwnerMutations.Clear(); - _pendingRoutedOwnerMutationQueue.Clear(); - _sealedExactWriteThrough = false; RetainedOwnerCaptureComplete = false; _sealedReplacement = null; _sealBuilder?.Dispose(); @@ -890,29 +329,6 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable Restarted: true, WorkUnits: 0); } - while (_pendingOwnerQueue.Count != 0) - { - uint ownerId = _pendingOwnerQueue.Dequeue(); - if (!_pendingOwners.Remove(ownerId)) - continue; - RefreshRetainedOwner(ownerId); - if (_sealedReplacement is not null) - { - return new RuntimeCollisionSealStep( - Completed: _pendingOwners.Count == 0, - Restarted: false, - WorkUnits: 1); - } - return new RuntimeCollisionSealStep( - Completed: false, - Restarted: false, - WorkUnits: 1); - } - if (_retainedOwnerVersions.Count != _retainedOwnerIds.Count) - { - throw new InvalidOperationException( - "Every retained collision owner must refresh before sealing."); - } _sealBuilder ??= _owner.Engine.CreateLandblockReplacementBuilder( Engine, @@ -939,11 +355,6 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable workUnits); } _sealedReplacement = _sealBuilder.Prepared; - // From this point through the same-call activation, every already- - // observed owner mutation writes through exactly. The finite dirty - // queue accumulated during topology construction can now drain even - // when several unrelated owners keep moving every update tick. - _sealedExactWriteThrough = true; return new RuntimeCollisionSealStep( Completed: true, Restarted: false, @@ -961,27 +372,10 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void MarkCommitted() { EnsureUsable(); - _sealedExactWriteThrough = false; _sealBuilder = null; _sealedReplacement = null; _retainedOwnerIds.Clear(); _retainedOwnerSet.Clear(); - _retainedOwnerVersions.Clear(); - _armedOwners.Clear(); - _pendingOwners.Clear(); - _pendingOwnerQueue.Clear(); - ClearCommittedRebases(); - ClearRetirements(); - _activeCommittedRebase?.Dispose(); - _activeCommittedRebase = null; - _pendingCloneOwnerMutations.Clear(); - _pendingCloneOwnerMutationQueue.Clear(); - _pendingRoutedOwnerMutations.Clear(); - _pendingRoutedOwnerMutationQueue.Clear(); - _observedOwnerMutationSequences.Clear(); - ClearOwnerSubscriptions(); - _stagingBuilder?.Dispose(); - _stagingBuilder = null; _disposed = true; } @@ -994,26 +388,9 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable _retainedOwnerScan = null; _retainedOwnerIds.Clear(); _retainedOwnerSet.Clear(); - _retainedOwnerVersions.Clear(); - _armedOwners.Clear(); - _pendingOwners.Clear(); - _pendingOwnerQueue.Clear(); - ClearCommittedRebases(); - ClearRetirements(); - _activeCommittedRebase?.Dispose(); - _activeCommittedRebase = null; - _pendingCloneOwnerMutations.Clear(); - _pendingCloneOwnerMutationQueue.Clear(); - _pendingRoutedOwnerMutations.Clear(); - _pendingRoutedOwnerMutationQueue.Clear(); - _observedOwnerMutationSequences.Clear(); - ClearOwnerSubscriptions(); - _stagingBuilder?.Dispose(); - _stagingBuilder = null; _sealBuilder?.Dispose(); _sealBuilder = null; _sealedReplacement = null; - _sealedExactWriteThrough = false; _disposed = true; } @@ -1023,25 +400,6 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable _retainedOwnerIds.Add(ownerId); } - private void SubscribeOwner(uint ownerId, bool exact = true) - { - if (exact) - _exactSubscribedOwners.Add(ownerId); - if (_subscribedOwners.Add(ownerId)) - _owner.SubscribeCollisionOwner(ownerId, this); - } - - private void ClearOwnerSubscriptions() - { - foreach (uint ownerId in _subscribedOwners) - _owner.UnsubscribeCollisionOwner(ownerId, this); - _subscribedOwners.Clear(); - _exactSubscribedOwners.Clear(); - } - - private static uint CanonicalLandblock(uint value) => - (value & 0xFFFF0000u) | 0xFFFFu; - private void EnsureUsable() { if (_disposed) @@ -1059,10 +417,6 @@ internal readonly record struct RuntimeCollisionPreparationStep( bool Completed, int WorkUnits); -internal readonly record struct RuntimeCollisionJournalStep( - bool Completed, - bool Worked); - internal readonly record struct RuntimeCollisionSealStep( bool Completed, bool Restarted, @@ -1091,13 +445,8 @@ public sealed class RuntimePhysicsState : IDisposable _preparedCollisionGenerations = new(); private readonly Dictionary _collisionPrefixMutations = new(); - private readonly CollisionOwnerMutationJournal _collisionOwnerJournal = new(); - private readonly Dictionary> - _collisionOwnerSubscribers = new(); private int _collisionMutationThreadId; - private bool _suppressCollisionOwnerJournal; private long _nextCollisionPreparationSequence; - private long _latestCollisionPreparationStartSequence; private ulong _collisionWorldAuthority = 1UL; private readonly List> _collisionGenerationCommittedObservers = new(); @@ -1133,9 +482,6 @@ public sealed class RuntimePhysicsState : IDisposable { DataCache = DataCache, }; - Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; - Engine.ShadowObjects.OwnerPrefixMembershipChanged += - OnCollisionOwnerPrefixMembershipChanged; CollisionReports = new RuntimeCollisionReportingState( Entities, Engine.ShadowObjects); @@ -1155,9 +501,6 @@ public sealed class RuntimePhysicsState : IDisposable DataCache = engine.DataCache ?? PhysicsDataCache.CreateProduction(engine.CollisionWorld); Engine.DataCache = DataCache; - Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; - Engine.ShadowObjects.OwnerPrefixMembershipChanged += - OnCollisionOwnerPrefixMembershipChanged; CollisionReports = new RuntimeCollisionReportingState( Entities, Engine.ShadowObjects); @@ -1172,8 +515,6 @@ public sealed class RuntimePhysicsState : IDisposable public int SpatialRootCount => _spatialRoots.Count; public int SpatialRemoteCount => _spatialRemotes.Count; public int SpatialProjectileCount => _spatialProjectiles.Count; - internal int CollisionOwnerJournalEntryCountForDiagnostics => - _collisionOwnerJournal.ActiveCount; internal double UtcNowSeconds => (_timeProvider.GetUtcNow() - DateTimeOffset.UnixEpoch) .TotalSeconds; @@ -1974,7 +1315,6 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions.Clear(); SetPosition.ResetSession(); CollisionReports.ResetSession(); - TrimCollisionOwnerJournal(); AdvanceCollisionWorldAuthority(); Volatile.Write(ref _collisionMutationThreadId, 0); } @@ -2105,12 +1445,8 @@ public sealed class RuntimePhysicsState : IDisposable this, admission, stagingBuilder, - _collisionOwnerJournal, checked(++_nextCollisionPreparationSequence)); _preparedCollisionGenerations[admission.LandblockId] = prepared; - _latestCollisionPreparationStartSequence = Math.Max( - _latestCollisionPreparationStartSequence, - prepared.OwnerMutationStartSequence); return prepared; } @@ -2205,7 +1541,6 @@ public sealed class RuntimePhysicsState : IDisposable admission.Generation + 1UL); AdvanceCollisionWorldAuthority(); } - TrimCollisionOwnerJournal(); return true; } @@ -2302,76 +1637,12 @@ public sealed class RuntimePhysicsState : IDisposable throw new InvalidOperationException( "Collision generation cannot seal before its assets are prepared."); } - if (!prepared.StagingCloneComplete) - { - RuntimeCollisionPreparationStep preparation = - prepared.AdvanceStagingClone(); - return new RuntimeCollisionSealStep( - Completed: false, - Restarted: false, - WorkUnits: preparation.WorkUnits); - } - RuntimeCollisionJournalStep routed = - prepared.AdvanceRoutedOwnerMutation(); - if (routed.Worked) - { - return new RuntimeCollisionSealStep( - Completed: routed.Completed - && prepared.IsOwnerMutationReconciliationCurrent - && prepared.IsReadyForActivation, - Restarted: false, - WorkUnits: 1); - } - if (prepared.HasPendingRetirement) - { - PhysicsEngine.LandblockRetirementStep retirement = - prepared.AdvanceRetirement(); - return new RuntimeCollisionSealStep( - Completed: false, - Restarted: false, - WorkUnits: retirement.Worked ? 1 : 0); - } - if (prepared.HasPendingCommittedRebase) - { - PhysicsEngine.LandblockReplacementApplyStep rebase = - prepared.AdvanceCommittedRebase(); - return new RuntimeCollisionSealStep( - Completed: false, - Restarted: false, - WorkUnits: rebase.Worked ? 1 : 0); - } - RuntimeCollisionSealStep seal = prepared.AdvanceSeal(); - if (!seal.Completed) - return seal; - if (seal.WorkUnits != 0 - && !prepared.IsOwnerMutationReconciliationCurrent) - { - return new RuntimeCollisionSealStep( - Completed: false, - Restarted: false, - WorkUnits: seal.WorkUnits); - } - RuntimeCollisionJournalStep journal = - prepared.AdvanceOwnerMutationReconciliation(); - if (journal.Worked) - { - return new RuntimeCollisionSealStep( - Completed: journal.Completed, - Restarted: false, - WorkUnits: 1); - } - if (journal.Completed - && _collisionOwnerJournal.AdvanceCompaction()) - { - return new RuntimeCollisionSealStep( - Completed: true, - Restarted: false, - WorkUnits: 1); - } - return new RuntimeCollisionSealStep( - Completed: journal.Completed, - Restarted: false, - WorkUnits: seal.WorkUnits); + // O3 (2026-08-02): no staged clone, journal, retirement, or peer + // rebase remains — the seal is exactly the target-scoped replacement + // builder. Owner cross-cells recalculate at commit against the live + // world (retail recalc_cross_cells), so post-seal owner movement + // needs no reconciliation here. + return prepared.AdvanceSeal(); } internal RuntimeCollisionGenerationCommit CommitCollisionGeneration( @@ -2413,11 +1684,8 @@ public sealed class RuntimePhysicsState : IDisposable "Collision generation has already completed."); } - if (!prepared.IsOwnerMutationReconciliationCurrent - || !prepared.IsReadyForActivation - || HasOlderPreparedGeneration(prepared) - || prepared.HasPendingCommittedRebase - || prepared.HasPendingRetirement) + if (!prepared.IsReadyForActivation + || HasOlderPreparedGeneration(prepared)) { if (!prepared.IsSealed) { @@ -2473,38 +1741,20 @@ public sealed class RuntimePhysicsState : IDisposable } mutation.Permission = permission; - // Parking a live owner writes to the canonical shadow journal. The - // prepared replacement must be resealed against that exact journal - // tail before permission can be consumed. + // Parking a live owner can mutate shadow rows; the prepared + // replacement's owner list stays valid regardless because the commit + // recalculates each owner's cross-cells against the live world. if (!IsCollisionPrefixMutationPermissionCurrent(permission) - || !prepared.IsOwnerMutationReconciliationCurrent || !prepared.IsReadyForActivation - || HasOlderPreparedGeneration(prepared) - || prepared.HasPendingCommittedRebase - || prepared.HasPendingRetirement) + || HasOlderPreparedGeneration(prepared)) { return PendingActivation(mutation); } PhysicsEngine.PreparedPhysicsEngineLandblock replacement = prepared.TakeSealedReplacement(); - bool suppressOwnerJournal = _suppressCollisionOwnerJournal; - _suppressCollisionOwnerJournal = true; - try - { - Engine.CommitLandblockReplacement(replacement); - AdvanceCollisionWorldAuthority(); - } - finally - { - _suppressCollisionOwnerJournal = suppressOwnerJournal; - } - foreach ((_, PreparedLandblockCollisionGeneration later) in - _preparedCollisionGenerations) - { - if (later.Sequence > prepared.Sequence) - later.EnqueueCommittedRebase(replacement); - } + Engine.CommitLandblockReplacement(replacement); + AdvanceCollisionWorldAuthority(); _preparedCollisionGenerations.Remove(admission.LandblockId); prepared.MarkCommitted(); mutation.EngineMutationCommitted = true; @@ -2568,7 +1818,6 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions.Remove(mutation.LandblockId); } _collisionPrefixMutations.Remove(mutation.LandblockId); - TrimCollisionOwnerJournal(); PublishCollisionGenerationCommitted( new RuntimeCollisionGenerationCommitted( acknowledgement.LandblockId, @@ -2703,28 +1952,15 @@ public sealed class RuntimePhysicsState : IDisposable mutation.Permission = permission; CommitCollisionInvalidation(mutation); - bool suppressOwnerJournal = _suppressCollisionOwnerJournal; - _suppressCollisionOwnerJournal = true; - try - { - if (kind is RuntimeCollisionPrefixMutationKind.Demotion) - Engine.DemoteLandblockToTerrain(canonical); - else - Engine.RemoveLandblock(canonical); - AdvanceCollisionWorldAuthority(); - } - finally - { - _suppressCollisionOwnerJournal = suppressOwnerJournal; - } - foreach ((_, PreparedLandblockCollisionGeneration prepared) in - _preparedCollisionGenerations) - { - if (kind is RuntimeCollisionPrefixMutationKind.Demotion) - prepared.RecordDemotion(canonical); - else - prepared.RecordWithdrawal(canonical); - } + // O3 (2026-08-02): drafts hold only their own target's content, + // so retiring another landblock needs no per-draft fan-out — a + // draft's commit refloods its owners against the live world and + // observes this retirement there. + if (kind is RuntimeCollisionPrefixMutationKind.Demotion) + Engine.DemoteLandblockToTerrain(canonical); + else + Engine.RemoveLandblock(canonical); + AdvanceCollisionWorldAuthority(); mutation.EngineMutationCommitted = true; mutation.Ready = kind is RuntimeCollisionPrefixMutationKind.Demotion && Engine.IsLandblockTerrainResident(canonical); @@ -2742,10 +1978,7 @@ public sealed class RuntimePhysicsState : IDisposable mutation.TargetGeneration, mutation.Ready); if (completed) - { _collisionPrefixMutations.Remove(canonical); - TrimCollisionOwnerJournal(); - } return new RuntimeCollisionMutationResult( new RuntimeCollisionAcknowledgement( canonical, @@ -2759,10 +1992,6 @@ public sealed class RuntimePhysicsState : IDisposable { if (_disposed) return; - _suppressCollisionOwnerJournal = true; - Engine.ShadowObjects.OwnerMutated -= OnCollisionOwnerMutated; - Engine.ShadowObjects.OwnerPrefixMembershipChanged -= - OnCollisionOwnerPrefixMembershipChanged; foreach ((_, PreparedLandblockCollisionGeneration prepared) in _preparedCollisionGenerations) { @@ -2771,8 +2000,6 @@ public sealed class RuntimePhysicsState : IDisposable _preparedCollisionGenerations.Clear(); SetPosition.Dispose(); CollisionReports.Dispose(); - _collisionOwnerJournal.Clear(); - _collisionOwnerSubscribers.Clear(); Engine.Clear(); _spatialRemotes.Clear(); _spatialProjectiles.Clear(); @@ -3237,113 +2464,6 @@ public sealed class RuntimePhysicsState : IDisposable && body.InWorld; } - private void OnCollisionOwnerMutated(uint ownerId, ulong version) - { - _ = version; - if (_suppressCollisionOwnerJournal || _disposed) - return; - if (_preparedCollisionGenerations.Count == 0) - return; - long epochBefore = _collisionOwnerJournal.NextSequence; - int countBefore = _collisionOwnerJournal.Count; - CollisionOwnerMutationJournal.MutationRecord mutation = - _collisionOwnerJournal.Record( - ownerId, - version, - _latestCollisionPreparationStartSequence); - if (!_collisionOwnerSubscribers.TryGetValue( - ownerId, - out List? subscribers)) - { - return; - } - // Subscription mutation is update-thread confined. Iterate by index so - // no delegate-array or enumerator allocation enters the hot path. - for (int index = 0; index < subscribers.Count; index++) - { - subscribers[index].ObserveSubscribedOwnerMutation( - ownerId, - mutation.Sequence, - epochBefore, - countBefore); - } - } - - private void OnCollisionOwnerPrefixMembershipChanged( - uint ownerId, - uint landblockPrefix) - { - if (_suppressCollisionOwnerJournal || _disposed) - return; - uint canonical = (landblockPrefix & 0xFFFF0000u) | 0xFFFFu; - if (_preparedCollisionGenerations.TryGetValue( - canonical, - out PreparedLandblockCollisionGeneration? prepared)) - { - prepared.ObserveRoutedOwnerMembershipMutation(ownerId); - } - } - - internal void SubscribeCollisionOwner( - uint ownerId, - PreparedLandblockCollisionGeneration prepared) - { - if (!_collisionOwnerSubscribers.TryGetValue( - ownerId, - out List? subscribers)) - { - subscribers = new List(); - _collisionOwnerSubscribers[ownerId] = subscribers; - } - if (!subscribers.Contains(prepared)) - subscribers.Add(prepared); - } - - internal void UnsubscribeCollisionOwner( - uint ownerId, - PreparedLandblockCollisionGeneration prepared) - { - if (!_collisionOwnerSubscribers.TryGetValue( - ownerId, - out List? subscribers)) - { - return; - } - for (int index = 0; index < subscribers.Count; index++) - { - if (!ReferenceEquals(subscribers[index], prepared)) - continue; - subscribers.RemoveAt(index); - break; - } - if (subscribers.Count == 0) - _collisionOwnerSubscribers.Remove(ownerId); - } - - private void TrimCollisionOwnerJournal() - { - if (_preparedCollisionGenerations.Count == 0) - { - _collisionOwnerJournal.Clear(); - _latestCollisionPreparationStartSequence = 0L; - return; - } - long minimumStart = long.MaxValue; - long latestStart = 0L; - foreach ((_, PreparedLandblockCollisionGeneration prepared) in - _preparedCollisionGenerations) - { - minimumStart = Math.Min( - minimumStart, - prepared.OwnerMutationStartSequence); - latestStart = Math.Max( - latestStart, - prepared.OwnerMutationStartSequence); - } - _latestCollisionPreparationStartSequence = latestStart; - _collisionOwnerJournal.RequestCompactionBefore(minimumStart); - } - private bool HasOlderPreparedGeneration( PreparedLandblockCollisionGeneration candidate) { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index 78349ead..eba53cc6 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -576,7 +576,12 @@ public sealed class RuntimePhysicsStateTests GC.GetAllocatedBytesForCurrentThread() - before; Assert.True(commit.Committed); - Assert.Equal(0L, allocated); + // O2 (2026-08-02): the commit is a per-landblock delta apply, so it + // allocates O(target payload) — dictionary nodes for installed leaves, + // synthesized outdoor cells, and per-owner row installs — never + // O(resident world). CollisionPreparationCostIsIndependentOfResident- + // WorldSize pins the world-size independence exactly. + Assert.InRange(allocated, 0L, 4L * 1024L * 1024L); Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); Assert.Equal(ownerCount, physics.Engine.ShadowObjects.TotalRegistered); } @@ -907,7 +912,10 @@ public sealed class RuntimePhysicsStateTests GC.GetAllocatedBytesForCurrentThread() - before; Assert.True(commit.Committed); - Assert.Equal(0L, allocated); + // O2 (2026-08-02): delta-apply commit allocates O(target payload) + // (installed leaves + synthesized outdoor cells + one owner install), + // never O(resident world). + Assert.InRange(allocated, 0L, 1L * 1024L * 1024L); Assert.Equal(1, notifications); Assert.Same(cacheFacade, physics.DataCache); Assert.Same(graphFacade, physics.DataCache.CellGraph); @@ -918,14 +926,25 @@ public sealed class RuntimePhysicsStateTests } [Fact] - public void DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep() + public void CollisionSealWorkIsIndependentOfResidentWorldSize() + { + // O1 (2026-08-02 collision publication-throughput fix): the seal's + // landblock-replacement builders enumerate the per-prefix installed-key + // ledger, so total seal work depends only on the target payload, never + // on how many landblocks are resident. + Assert.Equal( + MeasureSealWorkUnits(residentLandblocks: 32), + MeasureSealWorkUnits(residentLandblocks: 256)); + } + + private static int MeasureSealWorkUnits(int residentLandblocks) { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; - const int residentLandblocks = 32; for (int index = 0; index < residentLandblocks; index++) { - uint prefix = (uint)(0x10 + index) << 24 | 0x010000u; + uint prefix = (uint)(0x10 + (index % 128)) << 24 + | (uint)(0x01 + (index / 128)) << 16; uint landblockId = prefix | 0xFFFFu; RuntimeLandblockCollisionAssets assets = CollisionAssets( landblockId, @@ -954,9 +973,101 @@ public sealed class RuntimePhysicsStateTests isStatic: false); } - const uint target = 0x4001FFFFu; + const uint target = 0x0901FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 21f)); + AddSyntheticCell(prepared.DataCache, 0x09010100u); + prepared.DataCache.RegisterBuildingForTest( + 0x09010001u, + SyntheticBuilding(Matrix4x4.Identity)); + + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + + int workUnits = 0; + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal(admission, prepared); + Assert.False(seal.Restarted); + Assert.InRange(seal.WorkUnits, 0, 1); + workUnits += seal.WorkUnits; + } + while (!seal.Completed); + physics.CancelCollisionGeneration(admission, prepared); + return workUnits; + } + + [Fact] + public void CollisionPreparationCostIsIndependentOfResidentWorldSize() + { + // O3 (2026-08-02): the whole-world staging clone is gone. Admission + // captures an O(1) empty root, preparation completes without walking + // the resident world, and the seal enumerates only the target + // prefix's installed keys — so the complete admission → preparation → + // seal → commit sequence performs identical work at 32 and 256 + // resident landblocks. This is the strictly stronger replacement for + // the deleted one-leaf-per-step clone invariant: it pins the property + // (bounded, world-size-independent work), not the mechanism. + (int prepared32, int seal32) = + MeasurePreparationAndSealWork(residentLandblocks: 32); + (int prepared256, int seal256) = + MeasurePreparationAndSealWork(residentLandblocks: 256); + Assert.Equal(prepared32, prepared256); + Assert.Equal(seal32, seal256); + } + + private static (int PreparationAdvances, int SealWorkUnits) + MeasurePreparationAndSealWork(int residentLandblocks) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + for (int index = 0; index < residentLandblocks; index++) + { + uint prefix = (uint)(0x10 + (index % 128)) << 24 + | (uint)(0x01 + (index / 128)) << 16; + uint landblockId = prefix | 0xFFFFu; + RuntimeLandblockCollisionAssets assets = CollisionAssets( + landblockId, + terrainHeight: index); + physics.Engine.AddLandblock( + assets.LandblockId, + assets.Terrain, + assets.CellSurfaces, + assets.PortalPlanes, + assets.WorldOffsetX, + assets.WorldOffsetY); + AddSyntheticCell(physics.DataCache, prefix | 0x0100u); + physics.DataCache.RegisterBuildingForTest( + prefix | 1u, + SyntheticBuilding(Matrix4x4.Identity)); + physics.Engine.ShadowObjects.Register( + (uint)(20_000 + index), + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + landblockId, + seedCellId: prefix | 1u, + isStatic: false); + } + + const uint target = 0x0901FFFFu; RuntimeCollisionAdmission warmAdmission = - physics.BeginCollisionAdmission(0x3F01FFFFu); + physics.BeginCollisionAdmission(0x0A01FFFFu); PreparedLandblockCollisionGeneration warm = physics.PrepareCollisionGeneration(warmAdmission); physics.CancelCollisionGeneration(warmAdmission, warm); @@ -969,11 +1080,9 @@ public sealed class RuntimePhysicsStateTests physics.PrepareCollisionGeneration(admission); long admissionAllocation = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.InRange(admissionAllocation, 1L, 128L * 1024L); - Assert.Equal(0, prepared.Engine.LandblockCount); - int advances = 0; + int preparationAdvances = 0; RuntimeCollisionPreparationStep step; do { @@ -981,13 +1090,461 @@ public sealed class RuntimePhysicsStateTests admission, prepared); Assert.InRange(step.WorkUnits, 0, 1); - Assert.True(++advances < 10_000); + Assert.True(++preparationAdvances < 10_000); } while (!step.Completed); - Assert.True(advances > residentLandblocks); - Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount); - physics.CancelCollisionGeneration(admission, prepared); + // The draft holds ONLY the target after preparation completes — + // stronger than the deleted assertion, which expected the whole + // resident world to have been materialized into the draft. + Assert.Equal(0, prepared.Engine.LandblockCount); + + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 33f)); + AddSyntheticCell(prepared.DataCache, 0x09010100u); + prepared.DataCache.RegisterBuildingForTest( + 0x09010001u, + SyntheticBuilding(Matrix4x4.Identity)); + + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + + int sealWorkUnits = 0; + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal(admission, prepared); + Assert.False(seal.Restarted); + Assert.InRange(seal.WorkUnits, 0, 1); + sealWorkUnits += seal.WorkUnits; + } + while (!seal.Completed); + + Assert.True(CompleteSealedCommit( + physics, + admission, + prepared).Committed); + Assert.True(physics.Engine.IsLandblockTerrainResident(target)); + return (preparationAdvances, sealWorkUnits); + } + + [Fact] + public void CommitTimeRefloodMatchesPrecomputedReflood() + { + // O3 (2026-08-02): the equivalence proof that moving the retained- + // owner reflood from the deleted staged whole-world context to the + // commit call (retail CObjCell::init_objects 0x0052B420 → + // CPhysicsObj::recalc_cross_cells 0x00515A30) is a scheduling change, + // not a semantics change. The oracle is the pre-change staged world — + // neighbor content plus the NEW target content in one flat engine — + // with every owner flooded directly against it; the production + // commit-time reflood must produce identical per-cell rows. + + // Corner landblock 0x0000FFFF (map corner is a real landblock, + // C3c-F3) with a seam dynamic, a neighbor-rooted static flooding + // across, and one authored target static. + AssertCommitTimeRefloodMatchesOracle( + targetLandblock: 0x0000FFFFu, + targetOrigin: new Vector3(0f, 0f, 0f), + neighborLandblock: 0x0001FFFFu, + neighborOrigin: new Vector3(0f, 192f, 0f), + targetEnvCells: Array.Empty(), + owners: + [ + new RefloodOwnerSpec( + 800u, + new Vector3(10f, 193f, 0f), + Radius: 4f, + SeedCellId: 0x00010001u, + IsStatic: false, + Staged: false), + new RefloodOwnerSpec( + 801u, + new Vector3(20f, 195f, 0f), + Radius: 6f, + SeedCellId: 0x00010001u, + IsStatic: true, + Staged: false), + new RefloodOwnerSpec( + 802u, + new Vector3(30f, 20f, 0f), + Radius: 5f, + SeedCellId: 0x00000009u, + IsStatic: true, + Staged: true), + ]); + + // EnvCell-heavy target: authored indoor statics seeded in target + // EnvCells plus a retained seam dynamic. + AssertCommitTimeRefloodMatchesOracle( + targetLandblock: 0x0301FFFFu, + targetOrigin: new Vector3(576f, 192f, 0f), + neighborLandblock: 0x0302FFFFu, + neighborOrigin: new Vector3(576f, 384f, 0f), + targetEnvCells: [0x03010100u, 0x03010101u, 0x03010102u], + owners: + [ + new RefloodOwnerSpec( + 810u, + new Vector3(600f, 385f, 0f), + Radius: 3f, + SeedCellId: 0x03020001u, + IsStatic: false, + Staged: false), + new RefloodOwnerSpec( + 811u, + new Vector3(580f, 200f, 0f), + Radius: 1.5f, + SeedCellId: 0x03010100u, + IsStatic: true, + Staged: true), + new RefloodOwnerSpec( + 812u, + new Vector3(590f, 210f, 0f), + Radius: 1.5f, + SeedCellId: 0x03010101u, + IsStatic: true, + Staged: true), + ]); + + // Scenery-dense target: sixteen authored outdoor statics across the + // block plus retained seam owners. + var sceneryOwners = new List + { + new( + 820u, + new Vector3(970f, 1153f, 0f), + Radius: 4f, + SeedCellId: 0x05060001u, + IsStatic: false, + Staged: false), + new( + 821u, + new Vector3(1100f, 1150f, 0f), + Radius: 5f, + SeedCellId: 0x05060031u, + IsStatic: true, + Staged: false), + }; + for (int index = 0; index < 16; index++) + { + float localX = 12f + (index % 4) * 48f; + float localY = 12f + (index / 4) * 48f; + uint low = (uint)( + ((int)(localX / 24f) * 8) + (int)(localY / 24f) + 1); + sceneryOwners.Add(new RefloodOwnerSpec( + (uint)(830 + index), + new Vector3(960f + localX, 960f + localY, 0f), + Radius: 3f, + SeedCellId: 0x05050000u | low, + IsStatic: true, + Staged: true)); + } + AssertCommitTimeRefloodMatchesOracle( + targetLandblock: 0x0505FFFFu, + targetOrigin: new Vector3(960f, 960f, 0f), + neighborLandblock: 0x0506FFFFu, + neighborOrigin: new Vector3(960f, 1152f, 0f), + targetEnvCells: Array.Empty(), + owners: [.. sceneryOwners]); + } + + private sealed record RefloodOwnerSpec( + uint OwnerId, + Vector3 Position, + float Radius, + uint SeedCellId, + bool IsStatic, + bool Staged); + + private static void AssertCommitTimeRefloodMatchesOracle( + uint targetLandblock, + Vector3 targetOrigin, + uint neighborLandblock, + Vector3 neighborOrigin, + uint[] targetEnvCells, + RefloodOwnerSpec[] owners) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + + RuntimeCollisionAdmission neighborAdmission = + physics.BeginCollisionAdmission(neighborLandblock); + using (PreparedLandblockCollisionGeneration neighborPrepared = + physics.PrepareCollisionGeneration(neighborAdmission)) + { + physics.StageCollisionAssets( + neighborAdmission, + neighborPrepared, + CollisionAssetsAt(neighborLandblock, 2f, neighborOrigin)); + Assert.True(CommitPrepared( + physics, + neighborAdmission, + neighborPrepared).Committed); + } + foreach (RefloodOwnerSpec owner in owners) + { + if (owner.Staged) + continue; + physics.Engine.ShadowObjects.Register( + owner.OwnerId, + 0x01000001u, + owner.Position, + Quaternion.Identity, + owner.Radius, + 0f, + 0f, + neighborLandblock, + seedCellId: owner.SeedCellId, + isStatic: owner.IsStatic); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(targetLandblock); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssetsAt(targetLandblock, 1f, targetOrigin)); + foreach (uint envCellId in targetEnvCells) + AddSyntheticCell(prepared.DataCache, envCellId); + foreach (RefloodOwnerSpec owner in owners) + { + if (!owner.Staged) + continue; + prepared.Engine.ShadowObjects.Register( + owner.OwnerId, + 0x01000001u, + owner.Position, + Quaternion.Identity, + owner.Radius, + 0f, + 0f, + targetLandblock, + seedCellId: owner.SeedCellId, + isStatic: owner.IsStatic); + } + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + + // Oracle: the pre-change staged world — full context in one flat + // engine; every owner flooded directly against it. + var oracleCache = new PhysicsDataCache(); + var oracleEngine = new PhysicsEngine { DataCache = oracleCache }; + RuntimeLandblockCollisionAssets neighborAssets = + CollisionAssetsAt(neighborLandblock, 2f, neighborOrigin); + oracleEngine.AddLandblock( + neighborAssets.LandblockId, + neighborAssets.Terrain, + neighborAssets.CellSurfaces, + neighborAssets.PortalPlanes, + neighborAssets.WorldOffsetX, + neighborAssets.WorldOffsetY); + RuntimeLandblockCollisionAssets targetAssets = + CollisionAssetsAt(targetLandblock, 1f, targetOrigin); + oracleEngine.AddLandblock( + targetAssets.LandblockId, + targetAssets.Terrain, + targetAssets.CellSurfaces, + targetAssets.PortalPlanes, + targetAssets.WorldOffsetX, + targetAssets.WorldOffsetY); + foreach (uint envCellId in targetEnvCells) + AddSyntheticCell(oracleCache, envCellId); + foreach (RefloodOwnerSpec owner in owners) + { + oracleEngine.ShadowObjects.Register( + owner.OwnerId, + 0x01000001u, + owner.Position, + Quaternion.Identity, + owner.Radius, + 0f, + 0f, + owner.Staged ? targetLandblock : neighborLandblock, + seedCellId: owner.SeedCellId, + isStatic: owner.IsStatic); + } + + // The owner set and every owner's per-cell rows must be equal. + foreach (RefloodOwnerSpec owner in owners) + { + Assert.Equal( + oracleEngine.ShadowObjects.HasLogicalOwner(owner.OwnerId), + physics.Engine.ShadowObjects.HasLogicalOwner(owner.OwnerId)); + } + var candidateCells = new List(); + for (uint low = 1u; low <= 0x40u; low++) + { + candidateCells.Add((targetLandblock & 0xFFFF0000u) | low); + candidateCells.Add((neighborLandblock & 0xFFFF0000u) | low); + } + candidateCells.AddRange(targetEnvCells); + foreach (uint cellId in candidateCells) + { + ShadowEntry[] actual = physics.Engine.ShadowObjects + .GetObjectsInCell(cellId) + .OrderBy(entry => entry.EntityId) + .ThenBy(entry => entry.GfxObjId) + .ToArray(); + ShadowEntry[] expected = oracleEngine.ShadowObjects + .GetObjectsInCell(cellId) + .OrderBy(entry => entry.EntityId) + .ThenBy(entry => entry.GfxObjId) + .ToArray(); + Assert.Equal(expected, actual); + } + } + + private static RuntimeLandblockCollisionAssets CollisionAssetsAt( + uint landblockId, + float terrainHeight, + Vector3 origin) + { + var heights = new byte[81]; + var table = new float[256]; + table[0] = terrainHeight; + return new RuntimeLandblockCollisionAssets( + landblockId, + new TerrainSurface(heights, table), + Array.Empty(), + Array.Empty(), + origin.X, + origin.Y, + 0u); + } + + [Fact] + public void CommitAppliesOneLandblockDeltaInASingleCall() + { + // O2 (2026-08-02): the engine-mutating CommitCollisionGeneration call + // drains the landblock-replacement apply cursor to completion before + // it returns — the active world holds no target content before that + // call and the complete target content after it, with unrelated + // resident content untouched by identity. + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint neighbor = 0x0202FFFFu; + const uint neighborCellId = 0x02020100u; + const uint target = 0x0101FFFFu; + const uint targetCellId = 0x01010100u; + const uint targetBuildingId = 0x01010001u; + + RuntimeCollisionAdmission neighborAdmission = + physics.BeginCollisionAdmission(neighbor); + using (PreparedLandblockCollisionGeneration neighborPrepared = + physics.PrepareCollisionGeneration(neighborAdmission)) + { + physics.StageCollisionAssets( + neighborAdmission, + neighborPrepared, + CollisionAssets(neighbor, terrainHeight: 3f)); + AddSyntheticCell(neighborPrepared.DataCache, neighborCellId); + Assert.True(CommitPrepared( + physics, + neighborAdmission, + neighborPrepared).Committed); + } + physics.Engine.ShadowObjects.Register( + 700u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + neighbor, + seedCellId: 0x02020001u, + isStatic: false); + // An outgoing target-rooted static the replacement does not re-author: + // the delta commit must retire it. + physics.Engine.ShadowObjects.Register( + 699u, + 0x01000001u, + new Vector3(11f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + CellPhysics? neighborCell = + physics.DataCache.GetCellStruct(neighborCellId); + Assert.NotNull(neighborCell); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 9f)); + AddSyntheticCell(prepared.DataCache, targetCellId); + prepared.DataCache.RegisterBuildingForTest( + targetBuildingId, + SyntheticBuilding(Matrix4x4.Identity)); + prepared.Engine.ShadowObjects.Register( + 701u, + 0x01000001u, + new Vector3(12f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + _ = SealPrepared(physics, admission, prepared); + + RuntimeCollisionGenerationCommit commit = default; + for (int poll = 0; poll < 10_000 && !commit.EngineCommitted; poll++) + { + // No observable intermediate exists before the engine-mutating + // call: the target stays completely absent. + Assert.Null(physics.DataCache.GetCellStruct(targetCellId)); + Assert.Null(physics.DataCache.GetBuilding(targetBuildingId)); + Assert.False(physics.Engine.IsLandblockTerrainResident(target)); + commit = physics.CommitCollisionGeneration(admission, prepared); + if (!commit.EngineCommitted && !commit.Completed) + _ = SealPrepared(physics, admission, prepared); + } + + // The single engine-mutating call published the complete delta. + Assert.True(commit.EngineCommitted); + Assert.True(physics.Engine.IsLandblockTerrainResident(target)); + Assert.NotNull(physics.DataCache.GetCellStruct(targetCellId)); + Assert.NotNull(physics.DataCache.GetBuilding(targetBuildingId)); + Assert.True(physics.DataCache.CellGraph.Contains(targetCellId)); + uint[] owners = physics.Engine.ShadowObjects + .AllEntriesForDebug() + .Select(entry => entry.EntityId) + .Distinct() + .OrderBy(id => id) + .ToArray(); + Assert.Equal(new[] { 700u, 701u }, owners); + Assert.False(physics.Engine.ShadowObjects.HasLogicalOwner(699u)); + // Unrelated resident content is untouched by identity, not replaced. + Assert.Same(neighborCell, physics.DataCache.GetCellStruct(neighborCellId)); + // The consumed staging root is revoked exactly as the old transfer did. + Assert.Throws( + () => _ = prepared.Engine.LandblockCount); + + if (!commit.Completed) + { + Assert.True(CompleteSealedCommit( + physics, + admission, + prepared).Committed); + } } [Fact] @@ -1075,7 +1632,9 @@ public sealed class RuntimePhysicsStateTests GC.GetAllocatedBytesForCurrentThread() - before; Assert.True(commit.Committed); - Assert.Equal(0L, allocated); + // O2 (2026-08-02): delta-apply commit allocates O(target payload), + // never O(resident world). + Assert.InRange(allocated, 0L, 1L * 1024L * 1024L); Assert.Same(cacheFacade, physics.DataCache); Assert.Same(graphFacade, physics.DataCache.CellGraph); Assert.Same(shadowFacade, physics.Engine.ShadowObjects); @@ -1137,7 +1696,9 @@ public sealed class RuntimePhysicsStateTests first).Committed); long firstAllocated = GC.GetAllocatedBytesForCurrentThread() - firstBefore; - Assert.Equal(0L, firstAllocated); + // O2 (2026-08-02): delta-apply commit allocates O(target payload), + // never O(resident world). + Assert.InRange(firstAllocated, 0L, 1L * 1024L * 1024L); Assert.True(second.IsSealed); Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); Assert.False(physics.Engine.IsLandblockTerrainResident(secondLandblock)); @@ -1155,7 +1716,9 @@ public sealed class RuntimePhysicsStateTests second).Committed); long secondAllocated = GC.GetAllocatedBytesForCurrentThread() - secondBefore; - Assert.Equal(0L, secondAllocated); + // O2 (2026-08-02): delta-apply commit allocates O(target payload), + // never O(resident world). + Assert.InRange(secondAllocated, 0L, 1L * 1024L * 1024L); Assert.Equal(2, physics.Engine.LandblockCount); Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); Assert.True(physics.Engine.IsLandblockTerrainResident(secondLandblock)); @@ -1569,54 +2132,6 @@ public sealed class RuntimePhysicsStateTests destination)); } - [Fact] - public void UnrelatedStateMutationIsJournaledAfterAllRowsChange() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint target = 0x0101FFFFu; - const uint unrelated = 0x0202FFFFu; - foreach (uint landblock in new[] { target, unrelated }) - { - RuntimeCollisionAdmission seed = - physics.BeginCollisionAdmission(landblock); - using PreparedLandblockCollisionGeneration initial = - physics.PrepareCollisionGeneration(seed); - physics.StageCollisionAssets( - seed, - initial, - CollisionAssets(landblock)); - Assert.True(CommitPrepared(physics, seed, initial).Committed); - } - physics.Engine.ShadowObjects.Register( - 99u, - 0x01000001u, - new Vector3(10f, 10f, 0f), - Quaternion.Identity, - 0.5f, - 0f, - 0f, - unrelated, - state: 1u, - seedCellId: 0x02020001u, - isStatic: false); - - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(target); - using PreparedLandblockCollisionGeneration prepared = - physics.PrepareCollisionGeneration(admission); - physics.StageCollisionAssets( - admission, - prepared, - CollisionAssets(target, terrainHeight: 6f)); - physics.Engine.ShadowObjects.UpdatePhysicsState(99u, 0x1234u); - Assert.True(CommitPrepared(physics, admission, prepared).Committed); - - Assert.All( - physics.Engine.ShadowObjects.AllEntriesForDebug(), - entry => Assert.Equal(0x1234u, entry.State)); - } - [Fact] public void PrefixOwnerSlotsReuseTombstonesUnderGuidChurn() { @@ -1897,210 +2412,6 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(0x55u, authored.State); } - [Fact] - public void OwnerJournalCoalescesUnrelatedChurnAcrossManyDrafts() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint ownerId = 900u; - physics.Engine.ShadowObjects.Register( - ownerId, - 0x01000001u, - new Vector3(10f, 10f, 0f), - Quaternion.Identity, - 0.5f, - 0f, - 0f, - 0x0101FFFFu, - seedCellId: 0x01010001u, - isStatic: false); - - physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 1u); - _ = GC.GetAllocatedBytesForCurrentThread(); - long baselineBefore = GC.GetAllocatedBytesForCurrentThread(); - for (uint version = 2u; version <= 10_001u; version++) - physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, version); - long baselineAllocated = - GC.GetAllocatedBytesForCurrentThread() - baselineBefore; - - var admissions = new List(); - var preparations = new List(); - for (int index = 0; index < 32; index++) - { - uint landblock = ((uint)(0x20 + index) << 24) | 0x0001FFFFu; - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(landblock); - admissions.Add(admission); - preparations.Add(physics.PrepareCollisionGeneration(admission)); - } - - physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 10_002u); - _ = GC.GetAllocatedBytesForCurrentThread(); - long before = GC.GetAllocatedBytesForCurrentThread(); - for (uint version = 10_003u; version <= 20_002u; version++) - physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, version); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - - Assert.Equal(baselineAllocated, allocated); - Assert.Equal(1, physics.CollisionOwnerJournalEntryCountForDiagnostics); - for (int index = 0; index < admissions.Count; index++) - { - physics.CancelCollisionGeneration( - admissions[index], - preparations[index]); - } - Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); - } - - [Fact] - public void ManyUniqueOwnerMutationsReconcileOnePerSealAndCommitWithoutAllocation() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint target = 0x0101FFFFu; - const uint unrelated = 0x0202FFFFu; - const int ownerCount = 512; - - RuntimeCollisionAdmission unrelatedAdmission = - physics.BeginCollisionAdmission(unrelated); - using (PreparedLandblockCollisionGeneration unrelatedPrepared = - physics.PrepareCollisionGeneration(unrelatedAdmission)) - { - physics.StageCollisionAssets( - unrelatedAdmission, - unrelatedPrepared, - CollisionAssets(unrelated)); - Assert.True(CommitPrepared( - physics, - unrelatedAdmission, - unrelatedPrepared).Committed); - } - for (uint index = 0; index < ownerCount; index++) - { - physics.Engine.ShadowObjects.Register( - 30_000u + index, - 0x01000001u, - new Vector3(10f, 10f, 0f), - Quaternion.Identity, - 0.5f, - 0f, - 0f, - unrelated, - seedCellId: 0x02020001u, - isStatic: false); - } - - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(target); - using PreparedLandblockCollisionGeneration prepared = - physics.PrepareCollisionGeneration(admission); - physics.StageCollisionAssets( - admission, - prepared, - CollisionAssets(target)); - _ = SealPrepared(physics, admission, prepared); - - for (uint index = 0; index < ownerCount; index++) - { - physics.Engine.ShadowObjects.UpdatePhysicsState( - 30_000u + index, - index + 1u); - } - Assert.False(physics.CommitCollisionGeneration( - admission, - prepared).Committed); - - int worked = 0; - RuntimeCollisionSealStep seal; - do - { - seal = physics.AdvanceCollisionGenerationSeal( - admission, - prepared); - Assert.InRange(seal.WorkUnits, 0, 1); - worked += seal.WorkUnits; - } - while (!seal.Completed); - Assert.Equal(ownerCount, worked); - - Assert.False(physics.CommitCollisionGeneration( - admission, - prepared).Committed); - _ = SealPrepared(physics, admission, prepared); - - _ = GC.GetAllocatedBytesForCurrentThread(); - long before = GC.GetAllocatedBytesForCurrentThread(); - RuntimeCollisionGenerationCommit commit = - physics.CommitCollisionGeneration(admission, prepared); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.True(commit.Committed); - Assert.Equal(0L, allocated); - Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); - } - - [Fact] - public void CompactedJournalSupersessionPreservesTheMissedSuffix() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint oldestTarget = 0x0101FFFFu; - const uint survivingTarget = 0x0202FFFFu; - - RuntimeCollisionAdmission oldestAdmission = - physics.BeginCollisionAdmission(oldestTarget); - PreparedLandblockCollisionGeneration oldest = - physics.PrepareCollisionGeneration(oldestAdmission); - physics.Engine.ShadowObjects.Register( - 41_000u, - 0x01000001u, - new Vector3(10f, 10f, 0f), - Quaternion.Identity, - 0.5f, - 0f, - 0f, - 0x0303FFFFu, - seedCellId: 0x03030001u, - isStatic: false); - - RuntimeCollisionAdmission survivingAdmission = - physics.BeginCollisionAdmission(survivingTarget); - using PreparedLandblockCollisionGeneration surviving = - physics.PrepareCollisionGeneration(survivingAdmission); - physics.StageCollisionAssets( - survivingAdmission, - surviving, - CollisionAssets(survivingTarget)); - physics.CancelCollisionGeneration(oldestAdmission, oldest); - - _ = SealPrepared(physics, survivingAdmission, surviving); - RuntimeCollisionSealStep compacted = - physics.AdvanceCollisionGenerationSeal( - survivingAdmission, - surviving); - Assert.True(compacted.Completed); - Assert.Equal(1, compacted.WorkUnits); - Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); - - physics.Engine.ShadowObjects.Register( - 42_000u, - 0x01000001u, - new Vector3(12f, 10f, 0f), - Quaternion.Identity, - 0.5f, - 0f, - 0f, - survivingTarget, - seedCellId: 0x02020001u, - isStatic: false); - Assert.True(CompleteSealedCommit( - physics, - survivingAdmission, - surviving).Committed); - Assert.Contains( - physics.Engine.ShadowObjects.AllEntriesForDebug(), - entry => entry.EntityId == 42_000u); - } - [Fact] public void UnrelatedOwnerEnteringTargetAfterJournalScanWritesThroughExactly() { @@ -2201,130 +2512,6 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(0x55u, entry.State); } - [Fact] - public void JournalTailCompactionRetiresItsFreeSlotMetadataIncrementally() - { - var journal = new CollisionOwnerMutationJournal(); - const int ownerCount = 4_096; - for (uint ownerId = 1u; ownerId <= ownerCount; ownerId++) - _ = journal.Record(ownerId, ownerId, journal.NextSequence); - journal.RequestCompactionBefore(journal.NextSequence); - - int steps = 0; - while (journal.AdvanceCompaction()) - Assert.True(++steps <= ownerCount * 2); - Assert.Equal(0, journal.Count); - Assert.Equal(0, journal.ActiveCount); - - CollisionOwnerMutationJournal.MutationRecord next = - journal.Record( - 99_999u, - 1u, - journal.NextSequence); - Assert.Equal(0, next.SlotIndex); - } - - [Fact] - public void RetirementAfterSealBlocksCommitUntilItsCursorCompletes() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint retired = 0x0101FFFFu; - const uint replacement = 0x0202FFFFu; - const uint retiredCell = 0x01010100u; - - RuntimeCollisionAdmission retiredAdmission = - physics.BeginCollisionAdmission(retired); - using (PreparedLandblockCollisionGeneration retiredPrepared = - physics.PrepareCollisionGeneration(retiredAdmission)) - { - physics.StageCollisionAssets( - retiredAdmission, - retiredPrepared, - CollisionAssets(retired)); - AddSyntheticCell(retiredPrepared.DataCache, retiredCell); - Assert.True(CommitPrepared( - physics, - retiredAdmission, - retiredPrepared).Committed); - } - - RuntimeCollisionAdmission replacementAdmission = - physics.BeginCollisionAdmission(replacement); - using PreparedLandblockCollisionGeneration replacementPrepared = - physics.PrepareCollisionGeneration(replacementAdmission); - physics.StageCollisionAssets( - replacementAdmission, - replacementPrepared, - CollisionAssets(replacement)); - _ = SealPrepared( - physics, - replacementAdmission, - replacementPrepared); - - Assert.True(CompleteWithdrawal(physics, retired).WasResident); - Assert.False(physics.CommitCollisionGeneration( - replacementAdmission, - replacementPrepared).Committed); - - _ = SealPrepared( - physics, - replacementAdmission, - replacementPrepared); - Assert.True(CompleteSealedCommit( - physics, - replacementAdmission, - replacementPrepared).Committed); - Assert.False(physics.Engine.IsLandblockTerrainResident(retired)); - Assert.Null(physics.DataCache.GetCellStruct(retiredCell)); - } - - [Fact] - public void MoreThanFixedRingWorthOfRetirementsRemainMeteredAndLossless() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint target = 0x0101FFFFu; - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(target); - using PreparedLandblockCollisionGeneration prepared = - physics.PrepareCollisionGeneration(admission); - physics.StageCollisionAssets( - admission, - prepared, - CollisionAssets(target)); - _ = SealPrepared(physics, admission, prepared); - - for (uint index = 0; index < 300u; index++) - { - uint ordinal = index + 0x1000u; - uint x = ordinal & 0xFFu; - uint y = (ordinal >> 8) & 0xFFu; - _ = CompleteWithdrawal( - physics, - (x << 24) | (y << 16) | 0xFFFFu); - } - Assert.False(physics.CommitCollisionGeneration( - admission, - prepared).Committed); - - int steps = 0; - RuntimeCollisionSealStep seal; - do - { - seal = physics.AdvanceCollisionGenerationSeal( - admission, - prepared); - Assert.InRange(seal.WorkUnits, 0, 1); - Assert.True(++steps < 100_000); - } - while (!seal.Completed); - Assert.True(CompleteSealedCommit( - physics, - admission, - prepared).Committed); - } - [Fact] public void EmptyOwnerPrefixContainersAreReclaimedAcrossUniquePrefixes() { From f670db4327ff1ebc48176a7565f64e1cc47a60dc Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 20:08:34 +0200 Subject: [PATCH 66/73] docs: next-agent handoff prompt for the collision/placement regressions Self-contained task brief: P1 retirement-receipt exception loop, P2 feel-test placement failures, P3 soak residuals re-judgment, P4 door approach regression, P5 spell-particle deferral, P6 re-verifications; process, gates, and commit rules included. Co-Authored-By: Claude Fable 5 --- .../NEXT-AGENT-PROMPT.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md diff --git a/docs/research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md b/docs/research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md new file mode 100644 index 00000000..263e3ba9 --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md @@ -0,0 +1,155 @@ +# Task: diagnose and fix the acdream collision/placement regressions, then COMMIT the fixes + +Work EXCLUSIVELY in `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch +`codex/port-claude-agents`, HEAD `71604331`. Read `CLAUDE.md` at the repo +root FIRST — its rules bind you (grep-named-retail-first workflow, no +workarounds, divergence-register bookkeeping, connected gates, launch +instructions). The user's in-game observations quoted below are AXIOMS — +they override any green gate number. + +## Standing constraints + +- NEVER `git add -A`, `git add .`, `git reset --hard`, or + `git checkout -- `. Stage exact paths only. +- NEVER stage, revert, or normalize these user-local files: `AGENTS.md`, + `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs`, + `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs`, + `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`, + `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs`, + `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs`, + `tools/A8CellAudit/A8CellAudit.csproj`, the root + `implementer-progress.md`, and any `launch-*.log`. +- No workarounds without explicit user approval: root causes only — no + rate knobs, grace periods, suppression flags, or catch-and-swallow. +- Do not push. Commit locally to `codex/port-claude-agents` only. +- Connected gates need the user's ACE server at `127.0.0.1:9000` + (normally up). Follow CLAUDE.md's "Logout-before-reconnect" graceful- + close discipline between client launches. If ACE is unreachable, STOP + and report — never fake or skip a connected gate. + +## Where everything is + +- **This directory** (`docs/research/2026-08-02-collision-throughput-handoff/`): + `design-note.md` (the approved D2 delta-commit design), + `implementer-progress.md` (the complete campaign evidence trail — read + the `## Collision-clone O1/O2/O3` and `## C3c-F4` sections first), + `docs-drafts.md` (register/digest drafts NOT yet applied), + `user-observations-*.md` (the axioms). +- Campaign plan: `docs/plans/2026-08-02-placement-cutover.md`. C3c + closeout: `docs/research/2026-08-02-c3c-cutover-closeout.md`. Issues: + `docs/ISSUES.md` #276–#279. +- Recent commits: `529e0e9d` (C3c production placement cutover — REVIEWED + and accepted), `f4ef2b2a`/`c52ce14a` (docs), `71604331` (the O1–O3 + collision delta-commit — **WIP, ON HOLD, NO review passed it**; its + commit message summarizes state). +- Logs (untracked): `launch-feeltest-oclone.log` (the FAILING user feel + test on the O-tree), `logs/connected-r6-soak-20260802-194423.*` + (current soak: Passed=false, 4 failures), + `logs/connected-r6-soak-20260802-143157.*` (pre-O soak: 37 failures), + `logs/connected-r6-soak-20260727-004942.artifacts` (last fully-PASSING + baseline), `logs/connected-world-gate-20260802-193029` (lifecycle PASS + on the O-tree). + +## Problems to diagnose and fix (priority order) + +**P1 — the retirement-receipt exception loop (smoking gun).** +`launch-feeltest-oclone.log` contains **243** occurrences of +`streaming: origin-recenter preparation will resume: +System.InvalidOperationException: Landblock already has a full +retirement receipt.` — a continuous catch-retry loop during origin +recenter. Find the receipt-lifecycle break introduced (or exposed) by +the O1–O3 diff (`git show 71604331`): the retirement machinery is +`RuntimePhysicsState.AdvanceCollisionRetirementMutation` (~:1880-1990), +`StreamingController.cs` `AdvanceRetirements` call sites (:648/:670/:689/:736), +recenter adoption at `LandblockPresentationPipeline.cs:322-345`. The +catch-and-resume site that swallows this exception may itself violate +the no-silent-catch rule — evaluate it too. + +**P2 — user-visible placement failures on the O-tree (the axioms).** +Quote: "Monsters pop into existence as I run by them. ran to a different +place, spawned in the air long in front of me, stabs are placed +incorrectly. This is not how retail worked. I could see monsters way in +front of me." Suspects, in order: (a) P1's stalled/looping recenter +preparation starving placement; (b) the commit-time reflood diverging +from the deleted staged-clone world in live conditions +(`PhysicsEngine.CommitLandblockReplacement` + +`RefloodPrefixOwnersAfterReplacement`; the oracle test +`CommitTimeRefloodMatchesPrecomputedReflood` covers only 3 landblock +archetypes and may under-cover); (c) siblings of the staged-owner +admission gap the implementer already fixed once (ShadowObjectRegistry +phase 2); (d) ordering between the reflood and admission +commit/RetryDeferred for operations parked on the republished prefix. +Note: under C3c rules entities WAIT for collision before placing — so +"spawned mid-air" means placement completed against wrong/incomplete +collision, not merely late. + +**P3 — the soak's 4 remaining failures.** +`connected-r6-soak-20260802-194423.report.json`: +`pendingLandblockRetirements`=131/122 at sawato-baseline/plateau, +`waitCueShown=true` at holtburg, cpuUs p99 over-limit at the six +non-Caul stops. The WIP's "exposed pre-existing" classification +(reasoning in `implementer-progress.md ## Collision-clone`) was made +BEFORE P1 was discovered — re-judge it against the exception loop. + +**P4 — door/use approach regression (in COMMITTED C3c code, `529e0e9d`).** +Quote: "I dont approach doors when I use them." Using a door no longer +walks the character to it first. Prime suspect: +`src/AcDream.App/Input/PlayerModeController.cs`'s conditional MoveTo +bind (`if (controller.MoveTo is { } moveTo)`) — the legacy path CREATED +the MoveToManager at attach via factory; the flip only binds approach +callbacks IF Runtime's publication chain already ran `MakeMoveToManager` +(`RuntimeLocalPlayerPhysicsPublicationState`). Establish when (or +whether) Runtime creates it relative to player-mode attach; the +interaction flow is `SelectionInteractionController` → +approach-MoveTo → use (see `claude-memory/project_interaction_pipeline.md` +and register row AD-27). + +**P5 — intermittent spell particle loss (#279, C3c-era).** +One-shot VFX/scripts arriving while their entity is in C3c's +suppressed-until-receipt window are dropped instead of deferred; retail +queues scripts for not-yet-in-world objects (grep +`docs/research/named-retail/acclient_2013_pseudo_c.txt` for the +PlayScript/pending-script handling around `HandleCreateObject` +0x00454C80 / `set_hidden` 0x00514C60). Route the deferral through the +presentation-binding moment +(`TryApplyInitialCreateCompletionPresentation` in +`src/AcDream.App/World/LiveEntityRuntime.cs` and the equivalent headless +sink). + +**P6 — verify after P1–P3 land:** purple materialization haze re-firing +while standing still (#278a — believed the same root as the visibility +edges), the monster pop-in re-check, slope-glide vs open #269. + +## Required process per fix + +1. Root cause with file:line evidence BEFORE writing the fix; for any + AC-specific behavior, cite the named-retail decomp. +2. Tests that fail pre-fix and pass post-fix. +3. Any new retail deviation gets its register row + (`docs/architecture/retail-divergence-register.md`) in the SAME + commit; when the collision work lands for real, APPLY the prepared + `docs-drafts.md` entries (AD-6 successor note, AD-62, digest entries, + #280) rather than re-deriving them. +4. Gates before each commit: `dotnet build AcDream.slnx -c Release`; + `dotnet test AcDream.slnx -c Release --no-build --no-restore -m:1` + with `ACDREAM_PAK_PATH=C:\Users\erikn\Documents\Asheron's Call\acdream.pak` + (baseline: 10,812 passed / 0 failed / 4 skipped); + `tools/run-connected-world-lifecycle-gate.ps1` must PASS; then + `tools/run-connected-r6-soak.ps1` — the FINAL acceptance: + `report.json` `Passed=true`, zero failures, `waitCueShown=false` at + all nine stops, per-checkpoint cpuUs p99 within +10% of the + 20260727-004942 baseline. READ `report.json`, never the markers log. + A failure whose only signature is `activeTeleportCount=1` at a stable + checkpoint is user interference — one clean re-run allowed; any other + failure is real: fix it, don't re-run past it. +5. COMMIT each completed fix as its own bisectable commit on + `codex/port-claude-agents` with a message explaining the why. +6. Manual verification launch (for the user): the PowerShell launch in + CLAUDE.md "Running the client against the live server", plus + `ACDREAM_RETAIL_UI=1`. + +## Final deliverable + +Per-problem disposition (root cause → fix → evidence), the commit SHAs, +full gate numbers including the soak report values, and an explicit list +of anything left open. From c65559d8f80604ce7de8bbafff7e59e6357332de Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 20:10:21 +0200 Subject: [PATCH 67/73] docs: add deleted-machinery grep sweep to the collision handoff bundle Late-arriving adversarial-review artifact: deletions confirmed clean (no surviving consumers, no post-Revoke dereference), one dead orphan (CollisionWorldStateSlot.TransferTo), two stale test names, and the stale-docs catalog for whoever lands the collision work. Co-Authored-By: Claude Fable 5 --- .../grep-sweep-deleted-machinery.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/grep-sweep-deleted-machinery.md diff --git a/docs/research/2026-08-02-collision-throughput-handoff/grep-sweep-deleted-machinery.md b/docs/research/2026-08-02-collision-throughput-handoff/grep-sweep-deleted-machinery.md new file mode 100644 index 00000000..cc2c9753 --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/grep-sweep-deleted-machinery.md @@ -0,0 +1,68 @@ +# Grep sweep — deleted collision machinery (2026-08-02, against the WIP O1-O3 tree) + +Produced by an adversarial-review subagent (completed after the review +round was halted). Verdict summary: the deletions are CLEAN — no +surviving consumer of the journal/peer-rebase/draft-retirement/staging- +clone machinery, and no post-`Revoke()` dereference path found. Two +actionable leftovers and a stale-docs catalog for whoever lands the +collision work. + +## Actionable + +1. `CollisionWorldStateSlot.TransferTo` (`CollisionWorldState.cs:270-281`) + is fully DEAD — zero callers incl. tests. Delete it (comments at + `PhysicsEngine.cs:311/:379` reference it historically and are + accurate). +2. Two test names are stale terminology with live bodies: + `RuntimePhysicsStateTests.cs:1730` + (`PostCommitOwnerMutationWinsOverQueuedPeerRebase`) and `:2264` + (`PendingOrActivePeerRebaseCannotResurrectRetiredLandblock`) — rename + when touched. + +## Confirmed DEAD (zero refs in src/tests/tools/docs) + +- `LandblockRetirementCursor`/`Step`/`CreateLandblockRetirementCursor` +- `CollisionStagingBuilder.Advance/.WorkUnits/.Completed/.SuppressLandblock` +- `CopyOneOutsideTarget` +- Owner journal: `_collisionOwnerJournal`, `EnqueueCommittedRebase`, + write-through, draft retirement + +## Confirmed LIVE (name overlap, different concept — do not "clean up") + +- `InstallLandblockClone` (`PhysicsEngine.cs:576,673`) — the per-landblock + delta-apply installer, not the old clone loop. +- `MirrorOwnerFrom` (`ShadowObjectRegistry.cs:1961,2011,2079`) — + repurposed for the O3 commit-time reflood. +- `OwnerMutated`/`OwnerPrefixMembershipChanged` events — general-purpose, + unrelated to the deleted journal. +- `RetryDeferred` (`RuntimeSetPositionState.cs:4103` et al.) — the + deferred-SetPosition subsystem, unrelated. +- `LandblockReplacementBuilder` `.WorkUnits/.Advance/.Completed` — the + live seal builder, not the deleted staging builder. + +## Post-Revoke audit + +`Revoke()` has exactly one call site (`PhysicsEngine.cs:381`, end of +`CommitLandblockReplacement`). `MarkCommitted` (`RuntimePhysicsState.cs: +372-380`) + `LandblockPhysicsPublisher.cs:505/:1177-1183` guards mean no +production or test site dereferences a revoked slot. `prepared.Engine`/ +`DataCache` remain unguarded by design (ObjectDisposedException is the +intended revoked behavior). + +## Stale docs/comments that now describe the DELETED design (rewrite when +landing the collision work) + +- `docs/architecture/acdream-architecture.md:504-566` — full section on + journal/write-through/rebase/root-transfer: STALE, needs rewrite to + the per-landblock delta commit. +- `memory/project_collision_port.md:50-88` — same content class, STALE. +- `docs/research/2026-07-31-atomic-collision-generation.md` — whole file + documents the deleted mechanism with no supersession note (the + prepared banner is in docs-drafts.md). +- `src/AcDream.Core/Physics/PhysicsDataCache.cs:126-130` XML doc — + describes the deleted one-leaf-per-step materialization. +- `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:621-625` + comment — "zero-work root transfer" no longer true. +- Correctly archival (no action): `retail-divergence-register.md:113` + (~~AD-6~~ retired entry); the placement-cutover plan + C3c closeout + (they name the clone as the known problem being fixed). From 01f4791e958f474e79254d92ef48e0217ae8d308 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 20:53:11 +0200 Subject: [PATCH 68/73] fix(streaming): stop replaying committed recenter retirements Root cause: pending-only live projection buckets were misclassified as landblock presentation owners during origin recentering. That manufactured a second full cleanup receipt for a generation whose first receipt was still advancing; the duplicate guard threw and the broad retry path replayed the already-committed detach 243 times. Keep pending live projections through the spatial identity map without issuing another receipt, and fail fast when a receipt-ledger invariant occurs after detachment. Evidence: docs/research/2026-08-02-collision-throughput-handoff/p1-retirement-receipt-loop.md. Release suite, lifecycle gate, and nine-stop soak pass. --- .../retail-divergence-register.md | 8 ++ .../implementer-progress.md | 33 ++++++ .../p1-retirement-receipt-loop.md | 95 ++++++++++++++++ src/AcDream.App/Streaming/GpuWorldState.cs | 10 +- .../LandblockPresentationPipeline.cs | 21 +++- .../Streaming/StreamingController.cs | 8 ++ .../Streaming/GpuWorldStateVisibilityTests.cs | 9 +- .../LandblockPresentationPipelineTests.cs | 106 ++++++++++++++++++ .../LandblockRetirementCoordinatorTests.cs | 28 +++++ 9 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/p1-retirement-receipt-loop.md diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 2b8dcaf7..36e79f4e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -80,6 +80,14 @@ loader branch there. Slice 4B must preserve the flag while mapping successful deferred placement to exact-cell, generation-scoped asynchronous admission; the presence of the flag in the immutable request is not claimed as exactness. +AD-2 retirement-receipt refinement (2026-08-02): a pending-only live +projection bucket survives the atomic origin swap but is not a landblock +presentation generation and emits no second full cleanup receipt. A genuine +receipt-ledger invariant after spatial detachment is a committed terminal +failure, never resumable detach work. This preserves the existing adaptation: +one exact asynchronous cleanup owner for each synchronously destroyed retail +landblock, while logical live objects survive streaming residence changes. + AP-1/AD-1 checkpoint (placement Slice 4B2 checkpoint 1, 2026-07-31): Runtime now owns the exact accepted placement/lost-cell transaction, atomic body/contact/cell/ shadow/workset commit, adjusted retained frame, authored mover preparation, diff --git a/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md b/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md index 79d0499a..87061cd1 100644 --- a/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md +++ b/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md @@ -4500,6 +4500,39 @@ Register row AD-61 records the "overwritten, not deleted" truth. passing signature (142539/161138/164432 class). 174811 interference attribution confirmed. Round complete; nothing staged. +## P1 — origin-recenter retirement-receipt exception loop + +Root cause pinned in +`p1-retirement-receipt-loop.md`. An already-detached landblock can retain +live projections in `GpuWorldState._pendingByLandblock` while its one exact +full-cleanup ticket advances. The recenter swap incorrectly promoted that +pending-only spatial bucket into a second full presentation receipt. The +coordinator rejected the duplicate correctly, but the controller's broad +resume catch replayed the already-committed detach 243 times in the captured +feel-test session. + +Implemented: + +- pending-only live buckets are retained through `_projectionLocations` but + no longer manufacture a landblock retirement receipt; +- loaded/pending-render/pending-near/tier/bounds owners still receive exact + receipts; +- a genuine receipt-ledger invariant after the spatial commit is surfaced as + a committed `StreamingMutationException` and cannot enter retry work; +- the existing duplicate-receipt guard remains unchanged. + +TDD evidence: the new pending-only regression failed before the source edit +(`Assert.Empty`, one receipt returned) and passes afterward. The production +controller/recenter regression and committed-invariant fail-fast regression +also pass. Focused `OriginRecenter` group: 20/20. + +Final gates: Release build passed; the complete Release suite passed +10,815/10,815 with 4 skips; the connected lifecycle gate passed at +`logs/connected-world-gate-20260802-203751/report.json`; and the nine-stop +soak passed at `logs/connected-r6-soak-20260802-204309.report.json` with all +9 canonical checkpoints, zero failures, zero wait cues, zero pending +landblock retirements, and zero recurrence of the 243x exception signature. + ### F3 addendum (coordinator resolution accepted, implemented) Hand-calls KEPT as honest documented models: enriched comments at all six item-6/8 sites (LiveEntityHydrationControllerTests x5 sites incl. the diff --git a/docs/research/2026-08-02-collision-throughput-handoff/p1-retirement-receipt-loop.md b/docs/research/2026-08-02-collision-throughput-handoff/p1-retirement-receipt-loop.md new file mode 100644 index 00000000..4621716a --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/p1-retirement-receipt-loop.md @@ -0,0 +1,95 @@ +# P1 — origin-recenter retirement-receipt loop + +## Observed failure + +`launch-feeltest-oclone.log` contains 243 consecutive failures with this +shape: + +```text +streaming: origin-recenter preparation will resume: +InvalidOperationException: Landblock 0xC85AFFFF already has a full +retirement receipt. +``` + +The stack is `StreamingController.TryAdvanceOriginRecenterPreparation` → +`LandblockPresentationPipeline.DetachAllForOriginRecenter` → +`LandblockRetirementCoordinator.AdoptDetachedFull`. + +## Root cause + +An ordinary full retirement detaches every landblock-owned presentation +resource first, then parks surviving live entities in +`GpuWorldState._pendingByLandblock` while the exact cleanup ticket advances +asynchronously (`GpuWorldState.DetachLandblock`, around lines 1188–1314). + +The origin-recenter swap incorrectly treated every pending-only live bucket +as another landblock presentation generation (`GpuWorldState.cs`, former +lines 1352–1353). It therefore emitted a second full cleanup receipt for the +same already-retired generation. `LandblockRetirementCoordinator` correctly +rejected that duplicate at lines 416–425. Because spatial detachment had +already committed, the broad retry catch in +`StreamingController.TryAdvanceOriginRecenterPreparation` then repeated the +detach against the changed state every frame. + +The pre-fix regression test +`OriginRecenterAdoption_PendingOnlyLiveProjectionDoesNotCreateSecondFullReceipt` +failed because the recenter returned one receipt for the pending-only bucket. + +## Retail and reference boundary + +Retail destroys one concrete landblock owner synchronously: +`CLandBlock::destroy_static_objects` (`0x0052FA50`) leaves and deletes the +landblock's static objects; `CLandBlock::Destroy` (`0x0052FAA0`) releases its +buildings and landblock data; `CLandBlock::release_all` (`0x0052FCF0`) +releases the landblock's object and visibility ownership. A live object +parked outside a loaded landblock is not a second `CLandBlock` and therefore +cannot create a second landblock-destruction transaction. + +The extracted WorldBuilder reference follows the same ownership boundary: +`ObjectRenderManagerBase` removes an actual `_landblocks` entry before +`UnloadLandblockResources`, and `PortalRenderManager` only unloads a removed +`PortalLandblock`. Neither treats an independently parked object as a new +landblock resource owner. + +Acdream retains its approved asynchronous adaptation: the first exact +receipt owns cleanup, while the live projection survives spatial recentering. + +## Fix + +- `GpuWorldState.DetachAllForOriginRecenter` no longer creates retirement + receipts from `_pendingByLandblock` alone. Pending live identities are + still captured from `_projectionLocations`, cleared atomically, and + re-parked unchanged. +- A landblock that also owns loaded, pending-render, pending-near, tier, or + bounds state still receives its exact full receipt. +- A receipt-ledger invariant thrown after spatial detachment is now surfaced + as a committed `StreamingMutationException`; it is terminal rather than + falsely logged as resumable work. +- The genuine duplicate-receipt guard remains unchanged. + +## Deterministic evidence + +- The new pending-only regression failed before the source fix and passes + afterward. +- `OriginRecenter_PendingOnlyLiveProjectionKeepsItsExistingRetirementOwner` + drives the production recenter/controller sequence and proves the origin + commits while the first cleanup ticket remains pending. +- `OriginRecenter_CommittedReceiptInvariantFailsFastInsteadOfReplayingDetach` + proves a genuine post-detach ledger violation surfaces once rather than + entering a frame-by-frame retry loop. +- The complete `OriginRecenter` focused group passes 20/20. + +## Gate evidence + +- Release build: 0 errors (21 pre-existing warnings). +- Complete Release suite: 10,815 passed, 0 failed, 4 skipped. +- Connected lifecycle/reconnect gate: + `logs/connected-world-gate-20260802-203751/report.json` — `Passed=true`. +- Connected nine-stop soak: + `logs/connected-r6-soak-20260802-204309.report.json` — `Passed=true`, + `Failures=[]`, graceful exit, all 9 canonical checkpoints present, no wait + cue, no pending landblock retirement, no reveal invariant failure, and no + render-shadow mismatch. +- The soak artifacts contain zero occurrences of + `already has a full retirement receipt`; the captured failing session had + 243. diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 5b2a32cd..b19da86a 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -1349,8 +1349,14 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery if (id != 0u) AddId(id); } - foreach (uint id in _pendingByLandblock.Keys) - AddId(id); + // A pending-only bucket owns live spatial projections, not a loaded + // landblock presentation generation. Those projections are retained + // below through _projectionLocations, but there is no terrain, + // collision, static-script, or renderer owner to retire. In + // particular, DetachLandblock deliberately parks surviving live + // projections here while its existing exact retirement receipt is + // still advancing. Emitting another full receipt during a recenter + // would give the same retired generation two cleanup owners. foreach (uint id in _pendingRenderIdsByLandblock.Keys) AddId(id); foreach (uint id in _pendingNearTierLandblocks) diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 75802436..93c019ec 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -322,8 +322,25 @@ public sealed class LandblockPresentationPipeline { GpuWorldRecenterRetirement detached = _state.DetachAllForOriginRecenter(); - Exception? adoptionFailure = - _retirements.AdoptDetachedFull(detached.Landblocks); + Exception? adoptionFailure; + try + { + adoptionFailure = + _retirements.AdoptDetachedFull(detached.Landblocks); + } + catch (Exception error) + { + // Spatial detachment is already committed. A receipt-ledger + // invariant failure cannot be retried by detaching the same + // generation again; doing so was the 243-frame origin-recenter + // exception loop. Surface the committed edge so the controller + // fails fast instead of pretending the operation is resumable. + throw new StreamingMutationException( + "Origin-recenter retirement receipt adoption failed after " + + "the spatial generation detached.", + mutationCommitted: true, + error); + } Exception? failure = (detached.ObserverFailure, adoptionFailure) switch { (null, null) => null, diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index b10e88e5..48a1ab81 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -1433,6 +1433,14 @@ public sealed class StreamingController transaction.PreparationCommitted = true; return true; } + catch (StreamingMutationException error) when (error.MutationCommitted) + { + // The old spatial generation is already gone. Re-entering this + // transaction would replay the detach against a new state, so a + // committed receipt/adoption invariant is terminal and must be + // surfaced to the caller. + throw; + } catch (Exception error) { Console.WriteLine( diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs index fa87840f..e3d5bfd7 100644 --- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs @@ -293,7 +293,7 @@ public sealed class GpuWorldStateVisibilityTests Assert.Null(result.ObserverFailure); Assert.Equal( - [firstLandblock, secondLandblock, pendingLandblock], + [firstLandblock, secondLandblock], result.Landblocks.Select(retirement => retirement.LandblockId)); Assert.Contains( result.Landblocks.Single( @@ -311,10 +311,9 @@ public sealed class GpuWorldStateVisibilityTests result.Landblocks.Single( retirement => retirement.LandblockId == secondLandblock).Entities, entity => ReferenceEquals(entity, player)); - Assert.Same( - pending, - Assert.Single(result.Landblocks.Single( - retirement => retirement.LandblockId == pendingLandblock).Entities)); + Assert.DoesNotContain( + result.Landblocks, + retirement => retirement.LandblockId == pendingLandblock); Assert.Empty(state.LoadedLandblockIds); Assert.Empty(state.Entities); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index c7751b5f..fc9a3753 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -160,6 +160,112 @@ public sealed class LandblockPresentationPipelineTests Assert.Equal([0x2223FFFFu], enqueued); } + [Fact] + public void OriginRecenter_PendingOnlyLiveProjectionKeepsItsExistingRetirementOwner() + { + const uint oldLandblockId = 0x2424FFFFu; + var live = Entity(1u, serverGuid: 0x70000004u); + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + oldLandblockId, + new LandBlock(), + Array.Empty())); + state.PlaceLiveEntityProjection(oldLandblockId, live); + + bool holdCleanup = true; + var retirements = new LandblockRetirementCoordinator( + state, + ticket => ticket.RunOnce( + LandblockRetirementStage.EntityLighting, + () => + { + if (holdCleanup) + { + throw new InvalidOperationException( + "injected retained cleanup"); + } + }), + _ => LandblockRetirementStage.EntityLighting); + retirements.BeginFull(oldLandblockId); + Assert.Equal(1, retirements.PendingCount); + Assert.Equal(1, state.PendingLiveEntityCount); + + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x24, 0x24)); + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + retirementCoordinator: retirements, + workBudgetOptions: GenerousWorkBudget()); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x25, 0x25, isSealedDungeon: false)); + Assert.True(Converge(recenter, controller, 0x24, 0x24)); + + Assert.Equal((0x25, 0x25), (origin.CenterX, origin.CenterY)); + Assert.Equal(1, retirements.PendingCount); + Assert.Equal(1, state.PendingLiveEntityCount); + + holdCleanup = false; + controller.Tick(0x25, 0x25); + Assert.Equal(0, retirements.PendingCount); + } + + [Fact] + public void OriginRecenter_CommittedReceiptInvariantFailsFastInsteadOfReplayingDetach() + { + const uint landblockId = 0x2626FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty())); + var retirements = new LandblockRetirementCoordinator( + state, + ticket => ticket.RunOnce( + LandblockRetirementStage.EntityLighting, + static () => throw new InvalidOperationException( + "injected retained cleanup")), + _ => LandblockRetirementStage.EntityLighting); + retirements.BeginFull(landblockId); + Assert.Equal(1, retirements.PendingCount); + + // Directly violate the production publication fence so the recenter + // obtains a genuinely conflicting receipt after its spatial commit. + // The invariant must surface once; it must not become retry work. + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty())); + + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x26, 0x26)); + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + retirementCoordinator: retirements, + workBudgetOptions: GenerousWorkBudget()); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + Assert.False(recenter.Begin(0x27, 0x27, isSealedDungeon: false)); + + StreamingMutationException error = Assert.Throws( + () => controller.Tick(0x26, 0x26)); + + Assert.True(error.MutationCommitted); + Assert.IsType(error.InnerException); + Assert.False(state.IsLoaded(landblockId)); + } + [Fact] public void OriginRecenter_DetachesFullTwentyFiveByTwentyFiveWindowAtomically() { diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs index 3c8c84fe..0d006d85 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs @@ -595,6 +595,34 @@ public sealed class LandblockRetirementCoordinatorTests Assert.Equal(0, detachedCallbacks); } + [Fact] + public void OriginRecenterAdoption_PendingOnlyLiveProjectionDoesNotCreateSecondFullReceipt() + { + const uint landblockId = 0x4648FFFFu; + WorldEntity live = Entity(1, serverGuid: 0x70000003u); + GpuWorldState state = StateWith(landblockId, live); + LandblockRetirementCoordinator coordinator = + LandblockRetirementCoordinator.CreateBudgeted( + state, + ticket => AdvancePresentationStep(ticket), + ticket => CompletePresentation(ticket)); + + // The ordinary retirement has already detached every landblock-owned + // resource. The still-live entity is parked in the pending bucket + // while that exact cleanup receipt advances asynchronously. + coordinator.BeginFull(landblockId); + Assert.Equal(1, coordinator.PendingCount); + Assert.False(state.IsLoaded(landblockId)); + + GpuWorldRecenterRetirement recenter = + state.DetachAllForOriginRecenter(); + + Assert.Empty(recenter.Landblocks); + Assert.Null(coordinator.AdoptDetachedFull(recenter.Landblocks)); + Assert.Equal(1, coordinator.PendingCount); + Assert.True(coordinator.IsPending(landblockId)); + } + [Fact] public void OriginRecenterAdoption_ObserverFailureRetainsEveryCleanupReceipt() { From 670f307c84ba9a069638c0ae11a494ccbda0bf76 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 3 Aug 2026 08:59:31 +0200 Subject: [PATCH 69/73] fix(physics): keep remote placement and targeting in one world frame CreateObject positions are landblock-local, but Runtime first-entry previously submitted remotes with a zero world offset. Runtime now owns the accepted local-player world-frame center and converts remote placements before SetPosition. The local physics host also publishes body.Position rather than CellPosition's landblock-local origin, so TargetManager no longer directs monsters toward a phantom player position. User gate: monster/static placement, chase, and attacks accepted outside Tusker Barracks. --- .../Entities/RuntimeEntityObjectLifetime.cs | 28 +++++++++++ .../Entities/RuntimeRemoteFirstEntryState.cs | 3 +- ...ntimeLocalPlayerPhysicsPublicationState.cs | 16 +++++- .../Physics/RuntimePhysicsState.cs | 49 +++++++++++++++++++ .../RuntimeSetPositionMoverPreparation.cs | 3 +- .../Physics/RuntimeSetPositionState.cs | 40 +++++++++++++-- .../RuntimeFirstEntryDriveController.cs | 13 +++-- 7 files changed, 140 insertions(+), 12 deletions(-) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index f8a47c86..cd9e5a59 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -556,6 +556,18 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Func? retirePriorProjection) { EnsureNotDisposed(); + if (beginInitialResidence + && isLocalPlayer + && (incoming.Physics?.Position ?? incoming.Position) + is { LandblockId: not 0u } initialPlayerPosition) + { + // The accepted local Create establishes the shared world frame + // before any remote first-entry conductor converts its authored + // landblock-local coordinates. + Physics.ObserveLocalWorldFrame( + initialPlayerPosition.LandblockId, + teleportAdvanced: false); + } if (_sessionClearInProgress) { throw new InvalidOperationException( @@ -1508,6 +1520,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable if (!deferredKnown) return false; + if (isLocalPlayer + && disposition is not PositionTimestampDisposition.Rejected) + { + Physics.ObserveLocalWorldFrame( + update.Position.LandblockId, + timestamps.TeleportAdvanced); + } + if (disposition is PositionTimestampDisposition.Rejected && !timestampMutation) { @@ -1556,6 +1576,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return known; } + if (isLocalPlayer + && disposition is not PositionTimestampDisposition.Rejected) + { + Physics.ObserveLocalWorldFrame( + update.Position.LandblockId, + timestamps.TeleportAdvanced); + } + bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected; if (disposition is PositionTimestampDisposition.Apply) diff --git a/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs index 9563881d..90ad5c61 100644 --- a/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs @@ -346,7 +346,8 @@ internal sealed class RuntimeRemoteFirstEntryState lease.Route.SetPositionFlags, collisionSource, gameTime, - out RuntimeSetPositionCommand command); + out RuntimeSetPositionCommand command, + resolveWorldOffsetFromRuntimeFrame: true); if (moverStatus == RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable) { diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs index 4080bc11..b84b0d37 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -243,7 +243,16 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable motion, stopCompletely: () => _ = controller.StopCompletelyAtPhysicsObjectBoundary(), - getPosition: () => body.CellPosition, + // App/Runtime movement managers use one normalized world + // coordinate frame. PhysicsBody.CellPosition deliberately + // retains retail's landblock-local origin for cell transit; + // publishing it here made every remote TargetManager chase a + // different point whenever the player and world origin were + // not the same landblock. + getPosition: () => new Position( + body.CellPosition.ObjCellId, + body.Position, + body.Orientation), getHeading: () => MoveToMath.GetHeading(body.Orientation), setHeading: (heading, _) => body.Orientation = MoveToMath.SetHeading(body.Orientation, heading), @@ -268,7 +277,10 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable }; physicsHost = new EntityPhysicsHost( record.ServerGuid, - getPosition: () => body.CellPosition, + getPosition: () => new Position( + body.CellPosition.ObjCellId, + body.Position, + body.Orientation), getVelocity: () => body.Velocity, getRadius: () => preparedActivation.Radius, inContact: () => body.InContact, diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index b5ee2bf8..77554eee 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -448,6 +448,7 @@ public sealed class RuntimePhysicsState : IDisposable private int _collisionMutationThreadId; private long _nextCollisionPreparationSequence; private ulong _collisionWorldAuthority = 1UL; + private uint _worldFrameCenterLandblockId; private readonly List> _collisionGenerationCommittedObservers = new(); private bool _disposed; @@ -524,6 +525,52 @@ public sealed class RuntimePhysicsState : IDisposable internal double PlacementSimulationTime(double fallback) => _gameClock?.SimulationTimeSeconds ?? fallback; + /// + /// Establishes the landblock that maps to world-frame XY (0,0). The + /// local-player Create initializes it before remote first-entry work; + /// only an accepted teleport moves it afterward. Ordinary walking across + /// a landblock boundary does not rebase the streamed world. + /// + internal void ObserveLocalWorldFrame( + uint fullCellId, + bool teleportAdvanced) + { + EnsureNotDisposed(); + if (fullCellId == 0u) + return; + if (_worldFrameCenterLandblockId == 0u || teleportAdvanced) + { + _worldFrameCenterLandblockId = + (fullCellId & 0xFFFF0000u) | 0xFFFFu; + } + } + + /// + /// Converts a retail landblock-local network frame into the Runtime's + /// current world frame without consulting presentation or waiting for + /// the destination collision package. + /// + internal bool TryGetWorldFrameOffset( + uint fullCellId, + out float worldOffsetX, + out float worldOffsetY) + { + if (_worldFrameCenterLandblockId == 0u || fullCellId == 0u) + { + worldOffsetX = 0f; + worldOffsetY = 0f; + return false; + } + + int centerX = (int)((_worldFrameCenterLandblockId >> 24) & 0xFFu); + int centerY = (int)((_worldFrameCenterLandblockId >> 16) & 0xFFu); + int landblockX = (int)((fullCellId >> 24) & 0xFFu); + int landblockY = (int)((fullCellId >> 16) & 0xFFu); + worldOffsetX = (landblockX - centerX) * 192f; + worldOffsetY = (landblockY - centerY) * 192f; + return true; + } + public RuntimePhysicsOwnershipSnapshot CaptureOwnership() { RuntimeSetPositionOwnershipSnapshot setPosition = @@ -1315,6 +1362,7 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions.Clear(); SetPosition.ResetSession(); CollisionReports.ResetSession(); + _worldFrameCenterLandblockId = 0u; AdvanceCollisionWorldAuthority(); Volatile.Write(ref _collisionMutationThreadId, 0); } @@ -2007,6 +2055,7 @@ public sealed class RuntimePhysicsState : IDisposable _collisionPrefixMutations.Clear(); _collisionAdmissions.Clear(); _collisionGenerations.Clear(); + _worldFrameCenterLandblockId = 0u; CellCommitted = null; _collisionGenerationCommittedObservers.Clear(); _disposed = true; diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs index e2f97302..9c46f161 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs @@ -57,7 +57,8 @@ internal readonly record struct RuntimeSetPositionMoverPreparation( uint ScatterAttempts = 0u, float ShadowWorldOffsetX = 0f, float ShadowWorldOffsetY = 0f, - RuntimePortalPlacementAuthority Portal = default); + RuntimePortalPlacementAuthority Portal = default, + bool ResolveWorldOffsetFromRuntimeFrame = false); /// /// Pure preparation port of the mover inputs consumed by retail diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 39378bda..0f6dcee0 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -1522,6 +1522,32 @@ internal sealed class RuntimeSetPositionState : IDisposable .RetrySetupUnavailable; } + RuntimeSetPositionMoverPreparation effectivePreparation = preparation; + if (preparation.ResolveWorldOffsetFromRuntimeFrame) + { + // CreateObject/Position origins are local to their authored + // landblock. Resolve them through Runtime's accepted world frame, + // which is initialized by the local-player Create and advanced + // only by authoritative teleport transitions. This is available + // before streaming publishes the target collision generation, so + // remote admission cannot starve world loading while still using + // the exact same coordinate frame that generation will publish. + if (!_physics.TryGetWorldFrameOffset( + authority.AcceptedPosition.LandblockId, + out float worldOffsetX, + out float worldOffsetY)) + { + return RuntimeSetPositionMoverPreparationStatus + .RetrySetupUnavailable; + } + + effectivePreparation = preparation with + { + ShadowWorldOffsetX = worldOffsetX, + ShadowWorldOffsetY = worldOffsetY, + }; + } + if (!RuntimeSetPositionMoverPreparer.TryBuild( operation.Record, authority.AcceptedPosition, @@ -1529,7 +1555,7 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.Kind, operation.Portal, authority.VelocityAuthorityVersion, - preparation, + effectivePreparation, out command) || !IsStructurallyValid(command.Physics)) { @@ -1586,7 +1612,8 @@ internal sealed class RuntimeSetPositionState : IDisposable float scatterRadiusY = 0f, uint scatterAttempts = 0u, float shadowWorldOffsetX = 0f, - float shadowWorldOffsetY = 0f) + float shadowWorldOffsetY = 0f, + bool resolveWorldOffsetFromRuntimeFrame = false) { outcome = default; @@ -1606,7 +1633,8 @@ internal sealed class RuntimeSetPositionState : IDisposable scatterRadiusY, scatterAttempts, shadowWorldOffsetX, - shadowWorldOffsetY); + shadowWorldOffsetY, + resolveWorldOffsetFromRuntimeFrame); if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) return status; @@ -1644,7 +1672,8 @@ internal sealed class RuntimeSetPositionState : IDisposable float scatterRadiusY = 0f, uint scatterAttempts = 0u, float shadowWorldOffsetX = 0f, - float shadowWorldOffsetY = 0f) + float shadowWorldOffsetY = 0f, + bool resolveWorldOffsetFromRuntimeFrame = false) { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(record); @@ -1683,7 +1712,8 @@ internal sealed class RuntimeSetPositionState : IDisposable scatterAttempts, shadowWorldOffsetX, shadowWorldOffsetY, - portal); + portal, + resolveWorldOffsetFromRuntimeFrame); return PrepareMover(token, preparation, out command); } diff --git a/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs index 43acaf53..921e15ea 100644 --- a/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs @@ -250,7 +250,11 @@ internal sealed class RuntimeFirstEntryDriveController // Contention — nothing more this pump can do synchronously. return; } - if (!TryCompleteContinuationPlacement(key, pending.Record)) + if (!TryCompleteContinuationPlacement( + key, + pending.Record, + resolveWorldOffsetFromRuntimeFrame: + !pending.IsLocalPlayer)) return; // A continuation placement progressed — re-Advance so the // executor can consume the acknowledged completion and keep @@ -265,7 +269,8 @@ internal sealed class RuntimeFirstEntryDriveController /// private bool TryCompleteContinuationPlacement( RuntimeEntityKey key, - RuntimeEntityRecord record) + RuntimeEntityRecord record, + bool resolveWorldOffsetFromRuntimeFrame) { RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; @@ -311,7 +316,9 @@ internal sealed class RuntimeFirstEntryDriveController route.SetPositionFlags, _collisionSource, _clock.SimulationTimeSeconds, - out RuntimeSetPositionOutcome outcome); + out RuntimeSetPositionOutcome outcome, + resolveWorldOffsetFromRuntimeFrame: + resolveWorldOffsetFromRuntimeFrame); if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) { // RetrySetupUnavailable retries on a later pump; a rejected From 1fc529cdcb2f3e34e6d84ff374d90219d2b05c4b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 3 Aug 2026 09:36:53 +0200 Subject: [PATCH 70/73] fix(interaction): restore distant use after runtime cutover Runtime GetObjectA lookup became intentionally non-constructing, so static doors and corpses entered MoveToObject without a physics host and their target snapshot timed out at the origin. Ensure the canonical minimal host exists before routing the server move. Runtime first-entry also grounds the local player before graphical PartArray attachment. That could leave an unmatched startup CMotionInterp node ahead of all later use and cast motion. Drain matched PartArray entries first, then retire only the impossible pre-attach suffix at the presentation attach boundary. Add focused regressions for static-target host materialization and attach-order reconciliation. User verified near and distant object use in the connected client; focused App tests pass 3/3. --- src/AcDream.App/Input/PlayerModeController.cs | 11 ++ .../LiveEntityMotionRuntimeController.cs | 12 +- .../Input/C3cF2AutoEntryWiringTests.cs | 26 ++++ .../LiveEntityMotionRuntimeControllerTests.cs | 135 ++++++++++++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/AcDream.App.Tests/Physics/LiveEntityMotionRuntimeControllerTests.cs diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index 0080c8ba..ee8903d3 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -334,6 +334,17 @@ internal sealed class PlayerModeController : controller.Motion.DefaultSink = new MotionTableDispatchSink(sequencer); sequencer.Manager.HandleEnterWorld(); + // C3c constructs and grounds the Runtime-owned player before + // graphical PartArray presentation attaches. Ground entry can + // therefore enqueue CMotionInterp work while no animation sink + // exists; that work has no matching PartArray completion and + // would permanently sit ahead of later MoveTo/cast motions. + // First let the newly attached PartArray drain every matching + // animation above, then retire only the unmatched pre-attach + // interpreter suffix. In retail both owners exist together, + // so this suffix cannot arise; this is the split-lifetime + // reconciliation at their single attach boundary. + controller.Motion.HandleExitWorld(); } var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect }; diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs index db8a635b..5a7f463d 100644 --- a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs +++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs @@ -417,7 +417,17 @@ internal sealed class LiveEntityMotionRuntimeController if (update.MotionState.MovementType == 6 && path.TargetGuid is { } tgtGuid && _liveEntities is { } liveMoveEntities - && liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt)) + && liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt) + // Retail resolves the CPhysicsObj itself here. Even a static, + // animation-less object (door, chest, corpse, NPC prop) can + // therefore accept AddVoyeur and immediately publish its + // position. C3c moved the local player's GetObjectA seam into + // Runtime, where lookup is intentionally non-constructing; + // eagerly materialize the existing canonical minimal host at + // this App composition boundary before PerformMovement asks + // Runtime for it. Without this, MoveToObject arms but receives + // no target snapshot and times out at (0,0,0). + && ResolvePhysicsHost(tgtGuid) is not null) { ms.Type = AcDream.Core.Physics.MovementType.MoveToObject; ms.ObjectId = tgtGuid; diff --git a/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs b/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs index 056140cc..6c4659da 100644 --- a/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs +++ b/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs @@ -37,6 +37,32 @@ public sealed class C3cF2AutoEntryWiringTests StringComparison.Ordinal); } + [Fact] + public void PlayerPresentationAttach_DrainsMatchedAnimationsBeforeStartupMotionSuffix() + { + string source = ReadSource("Input", "PlayerModeController.cs"); + + int attach = source.IndexOf( + "controller.Motion.DefaultSink =", + StringComparison.Ordinal); + int animationDrain = source.IndexOf( + "sequencer.Manager.HandleEnterWorld();", + attach, + StringComparison.Ordinal); + int unmatchedMotionDrain = source.IndexOf( + "controller.Motion.HandleExitWorld();", + animationDrain, + StringComparison.Ordinal); + + Assert.True(attach >= 0, "The player animation sink was not attached."); + Assert.True( + animationDrain > attach, + "Matching PartArray animations must drain after attaching the sink."); + Assert.True( + unmatchedMotionDrain > animationDrain, + "Only the unmatched pre-attach interpreter suffix may drain last."); + } + private static string ReadSource(params string[] relativePath) { DirectoryInfo? directory = new(AppContext.BaseDirectory); diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityMotionRuntimeControllerTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityMotionRuntimeControllerTests.cs new file mode 100644 index 00000000..4efcf728 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveEntityMotionRuntimeControllerTests.cs @@ -0,0 +1,135 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.Selection; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Physics; + +public sealed class LiveEntityMotionRuntimeControllerTests +{ + [Fact] + public void MoveToObject_StaticTargetWithoutHost_MaterializesCanonicalMinimalHost() + { + const uint targetGuid = 0x70000091u; + const uint cellId = 0x01010001u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new LandBlock(), + Array.Empty())); + var runtime = LiveEntityRuntimeFixture.Create( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + runtime.RegisterAndMaterializeProjection(Spawn(targetGuid, cellId)); + Assert.False(runtime.TryGetPhysicsHost(targetGuid, out _)); + + var origin = new LiveWorldOriginState(); + origin.Recenter(1, 1); + var controller = new LiveEntityMotionRuntimeController( + runtime, + new PhysicsDataCache(), + static () => null, + new SelectionState(), + origin); + var movement = new MovementManager(new MotionInterpreter()); + var update = new WorldSession.EntityMotionUpdate( + Guid: 0x50000001u, + MotionState: new CreateObject.ServerMotionState( + Stance: 0x3D, + ForwardCommand: null, + MovementType: 6, + MoveToParameters: 0x203u, + MoveToSpeed: 1f, + MoveToRunRate: 1f, + MoveToPath: new CreateObject.MoveToPathData( + TargetGuid: targetGuid, + OriginCellId: cellId, + OriginX: 10f, + OriginY: 10f, + OriginZ: 5f, + DistanceToObject: 0.6f, + MinDistance: 0f, + FailDistance: 15f, + WalkRunThreshold: 15f, + DesiredHeading: 0f, + Bitfield: 0x203u)), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 3, + IsAutonomous: false); + + Assert.True(controller.RouteServerMoveTo(movement, cellId, update)); + Assert.True(runtime.TryGetPhysicsHost(targetGuid, out var targetHost)); + Assert.IsType(targetHost); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cellId) + { + var position = new CreateObject.ServerPosition( + cellId, + 10f, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Static, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + Guid: guid, + Position: position, + SetupTableId: 0x02000001u, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "static target", + ItemType: null, + MotionState: null, + MotionTableId: null, + PhysicsState: (uint)PhysicsStateFlags.Static, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} From f24532adf33427c774f997fa9e92074e4905c534 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 3 Aug 2026 12:10:21 +0200 Subject: [PATCH 71/73] fix(vfx): bind effects after canonical placement C3c created graphical effect, projectile, and static-animation sidecars before Runtime finished the entity's first SetPosition. One-shot F754/F755 packets could be discarded, projectiles could adopt a cell-less body, and animated statics could compete for body ownership. Keep effects behind an exact-incarnation presentation barrier, retry projectile/static binding on the committed visibility edge, and keep effect cells synchronized with canonical rebuckets. User verified spell, recall, arrow, projectile, portal, and static presentation; 90 focused App tests and the Release build pass. --- docs/ISSUES.md | 57 ++++++++++--- .../LivePresentationComposition.cs | 33 +++++++- .../Physics/ProjectileController.cs | 79 +++++++++++++------ .../DatLiveEntityProjectionMaterializer.cs | 33 ++++++-- .../RetailStaticAnimatingObjectScheduler.cs | 31 +++++++- .../Rendering/Vfx/EntityEffectController.cs | 57 +++++++++++++ src/AcDream.App/World/LiveEntityRuntime.cs | 12 +++ .../Physics/ProjectileControllerTests.cs | 60 +++++++++++++- ...tailStaticAnimatingObjectSchedulerTests.cs | 44 +++++++++++ .../Vfx/EntityEffectControllerTests.cs | 52 ++++++++++++ .../World/LiveEntityRuntimeTests.cs | 9 ++- 11 files changed, 417 insertions(+), 50 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index d098eb42..64357a9d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -72,17 +72,52 @@ What does NOT go here: longer hand-place ahead of publication); expected to resolve with the O(changed)-clone slice — re-verify all three in its acceptance session. -- **#279 — OPEN — one-shot spell/effect scripts arriving during the - suppressed-until-receipt window can be lost.** User-observed: spell - particle effects intermittently missing (2026-08-02 smoke test). C3c - suppresses presentation for a created entity until its placement - receipt binds it; a play-once VFX/script that fires while suppressed - has no presentation to land on and never replays at bind time — - "sometimes works" = the receipt won the race. Investigate retail's - pending-script handling for not-yet-in-world objects (HandleCreateObject - tail / PlayScript queuing) and defer one-shot scripts to the - presentation-binding moment. Route: presentation sink / - TryApplyInitialCreateCompletionPresentation. +- **#279 — DONE (2026-08-03, user-verified) — one-shot spell/effect + scripts arriving during the suppressed-until-receipt window were lost.** + `EntityEffectController` now retains the mixed F754/F755 FIFO behind an + exact-incarnation initial-presentation barrier and replays it only after + canonical placement has bound the mesh, pose owner, and particle visibility + resources. Live rebuckets also keep the effect cell synchronized with the + entity cell. Spell buffs, recalls, arrows, and combat spell projectiles were + verified in the connected client; focused effect, projectile, and + cell-transition tests cover the race. +- **#280 — OPEN — portal reveal can expose an incompletely streamed distant + landscape.** User-observed 2026-08-03: after some recalls, the nearby + destination is playable but terrain near the far end of the view continues + visibly building after portal space exits. The current outdoor reveal gate + is explicitly only `WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius = + 1` (a 3x3 landblock neighborhood), while the normal configured view extends + substantially farther; this permits the world viewport to open before its + visible static destination is complete. + + **Retail oracle:** `CellManager::PreFetchCells @ 0x00455820` sets + `blocking_for_cells` until `LScape::PreFetchCells @ 0x00505660` has walked + the configured `mid_radius` square and each required + `CLandBlock::PreFetchCells` / `CLandBlockInfo::PreFetchCells` building and + connected EnvCell dependency is available. While blocked, + `SmartBox::UseTime @ 0x00455410` checks prefetch status but does not advance + ordinary object maintenance, physics, landscape, game time, or ambient + audio; the portal viewport and UI remain live and may show retail's centered + "In Portal Space - Please Wait..." notice. Once the destination is ready, + retail resumes it behind the portal viewport during `TAS_TUNNEL_CONTINUE` + before the later tunnel-to-world reveal. + + **Fix shape:** replace the hard-coded radius-one reveal requirement with a + retail-derived, quality-configured destination prefetch window and keep one + generation-scoped reservation across terrain, statics/buildings, EnvCells, + render publication, composite textures, and collision until that complete + visible window is ready. Preserve bounded asynchronous preparation and the + existing wait cue; never reveal early merely to meet a timeout. Do not wait + for an unknowable "all dynamic server objects delivered" condition—ACE has + no such terminal marker and some object delivery follows LoginComplete. + + **Acceptance:** at every quality/view-distance setting, repeated login, + `/ls`, spell recall, and portal routes reveal no constructing terrain, + buildings, statics, interiors, missing composite textures, or nearby + collision; slow destinations remain in the authored portal presentation + with responsive UI until ready, then receive the existing hidden settling + interval before the world viewport appears. Dynamic monsters/items may + continue to arrive authoritatively after reveal. ## Current queue — 2026-07-27 diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 7a4be2fd..f9244fde 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -293,6 +293,25 @@ internal sealed class LivePresentationCompositionPhase d.WorldOrigin, d.EffectPoses); var staticResidency = new LiveStaticAnimationResidency(d.RuntimeSlot); + (LiveEntityAnimationState Animation, PhysicsBody Body)? + ResolveLiveStaticOwner(WorldEntity entity) + { + if (entity.ServerGuid == 0 + || liveEntities?.TryGetRecord( + entity.ServerGuid, + out LiveEntityRecord record) != true + || !ReferenceEquals(record.WorldEntity, entity) + || !record.IsSpatiallyProjected + || !record.IsSpatiallyVisible + || record.AnimationRuntime + is not LiveEntityAnimationState animation + || record.PhysicsBody is not { } body) + { + return null; + } + + return (animation, body); + } var staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler( content.AnimationLoader, @@ -300,7 +319,8 @@ internal sealed class LivePresentationCompositionPhase d.EffectPoses.Publish, staticResidency.IsResident, (entity, body) => _ = staticRootCommitter.Commit(entity, body), - staticResidency.ProjectionVersion); + staticResidency.ProjectionVersion, + ResolveLiveStaticOwner); ScriptActivationInfo? ResolveActivation(WorldEntity entity) { @@ -453,7 +473,7 @@ internal sealed class LivePresentationCompositionPhase particleVisibility, "particle projection visibility"); var placementVisibilitySinks = new List< - Action>(3) + Action>(4) { wbVisibility, }; @@ -463,6 +483,15 @@ internal sealed class LivePresentationCompositionPhase liveRenderProjections.OnProjectionVisibilityChanged); } placementVisibilitySinks.Add(particleVisibility); + // Retail enters the CPhysicsObj before ProcessObjectNetBlobs. + // C3c's initial Runtime placement is the equivalent world-entry + // edge, so open/replay the one-shot F754/F755 barrier only after + // mesh poses and particle presentation have both been published. + placementVisibilitySinks.Add((record, visible) => + { + if (visible) + entityEffects?.OnPresentationBound(record); + }); var placementProjection = new RuntimePlacementPresentationSink( liveEntities, worldTransit, diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs index b083e00d..1538fe2a 100644 --- a/src/AcDream.App/Physics/ProjectileController.cs +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -181,12 +181,14 @@ internal sealed class ProjectileController // animation workset, classification adopts that same body instead // of replacing it or replaying CreateObject vectors. body = sharedBody; - uint currentCellId = record.FullCellId; - Vector3 currentCellLocal = CellLocalFromWorld( - body.Position, - currentCellId, - liveCenterX, - liveCenterY); + // Retail has one CPhysicsObj, whose Position owns both objcell_id + // and the cell-local frame. The graphical record's FullCellId is + // only the later projection receipt and is legitimately still + // zero while a residence-managed Create awaits presentation. + // Validate and adopt the canonical body frame itself; successful + // classification projects that same cell into the sidecar below. + uint currentCellId = body.CellPosition.ObjCellId; + Vector3 currentCellLocal = body.CellPosition.Frame.Origin; if (!IsFinite(body.Position) || !IsFinite(body.Velocity) || !IsFinite(body.Omega) @@ -268,7 +270,17 @@ internal sealed class ProjectileController entity.SetPosition(body.Position); entity.Rotation = body.Orientation; entity.ParentCellId = canonicalCellId; - if (!_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId) + // Classification can run from the projection-visible callback after + // Runtime has already installed this exact cell. Re-entering Rebucket + // from that callback would supersede the outer projection transaction + // merely to write the same bucket again. Only perform a spatial move + // when classification is actually changing residence. + bool alreadyProjectedInCanonicalCell = record.IsSpatiallyProjected + && record.FullCellId == canonicalCellId; + if ((!alreadyProjectedInCanonicalCell + && !_liveEntities.RebucketLiveEntity( + record.ServerGuid, + canonicalCellId)) || !_liveEntities.TryGetRecord(record.ServerGuid, out var currentRecord) || !ReferenceEquals(currentRecord, record) || !ReferenceEquals(currentRecord.WorldEntity, entity) @@ -848,10 +860,43 @@ internal sealed class ProjectileController private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) { - if (record.ProjectileRuntime is not RuntimeProjectile runtime - || record.WorldEntity is not { } entity + if (record.WorldEntity is not { } entity || !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) - || !ReferenceEquals(current, record) + || !ReferenceEquals(current, record)) + { + return; + } + + if (visible + && record.ProjectileRuntime is null + && record.PhysicsBody is not null + && (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0) + { + // A residence-managed Create builds its one CPhysicsObj before + // presentation, but Runtime intentionally withholds the committed + // cell frame until enter_world completes. Materialization may + // therefore observe the body while it is still cell-less and its + // eager TryBind correctly refuses that incomplete frame. The + // projection-visible edge is the first point at which both the + // canonical body and its authoritative placement are guaranteed + // to be committed, so retry classification here instead of + // fabricating a CreateObject-frame fallback. + Setup? setup = _setupResolver?.Resolve( + entity.SourceGfxObjOrSetupId); + if (setup is not null) + { + int liveCenterX = _origin?.CenterX ?? 0; + int liveCenterY = _origin?.CenterY ?? 0; + _ = TryBind( + record, + setup, + _lastFiniteGameTime, + liveCenterX, + liveCenterY); + } + } + + if (record.ProjectileRuntime is not RuntimeProjectile runtime || !ReferenceEquals(current.ProjectileRuntime, runtime)) { return; @@ -933,20 +978,6 @@ internal sealed class ProjectileController && float.IsFinite(value.Y) && float.IsFinite(value.Z); - private static Vector3 CellLocalFromWorld( - Vector3 worldPosition, - uint cellId, - int liveCenterX, - int liveCenterY) - { - int landblockX = (int)((cellId >> 24) & 0xFFu); - int landblockY = (int)((cellId >> 16) & 0xFFu); - return worldPosition - new Vector3( - (landblockX - liveCenterX) * 192f, - (landblockY - liveCenterY) * 192f, - 0f); - } - private bool TryGetCurrent( uint serverGuid, out LiveEntityRecord record, diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index 440c2900..13760355 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -837,12 +837,24 @@ internal sealed class DatLiveEntityProjectionMaterializer expectedCreateIntegrationVersion) || !ReferenceEquals(expectedRecord.WorldEntity, entity)) return false; - _projectiles.TryBind( - expectedRecord, - setup, - _gameTime.CurrentScriptTime, - _origin.CenterX, - _origin.CenterY); + bool initialResidenceActive = + _runtime.HasActiveInitialCreateResidence(expectedCanonical); + // C3c first entry owns CPhysicsObj construction and SetPosition for a + // residence-managed Create. Eager projectile classification used to + // acquire that same body here, before the conductor ran; the + // conductor then correctly rejected the unexpected owner and the + // missile remained permanently cell-less. The committed projection + // visibility edge retries TryBind after Runtime has constructed and + // placed the one canonical body. + if (!initialResidenceActive) + { + _projectiles.TryBind( + expectedRecord, + setup, + _gameTime.CurrentScriptTime, + _origin.CenterX, + _origin.CenterY); + } if (!_runtime.IsCurrentCreateIntegration( expectedRecord, @@ -1033,6 +1045,15 @@ internal sealed class DatLiveEntityProjectionMaterializer return; } + if (_runtime.HasActiveInitialCreateResidence(expectedRecord.Canonical)) + { + // RuntimeRemoteFirstEntryState owns CPhysicsObj construction and + // SetPosition until the Create residence completes. The static + // scheduler retains this pending animation owner and binds the + // canonical body after projection becomes spatially visible. + return; + } + PhysicsBody body = _runtime.GetOrCreatePhysicsBody( spawn.Guid, incarnation => diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 9874090c..ca3f1b8a 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -53,6 +53,9 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram private readonly Func _isResident; private readonly Action _commitLiveRoot; private readonly Func _residencyVersion; + private readonly Func? + _resolveLiveOwner; private readonly Dictionary _owners = new(); private readonly List _snapshot = new(); private readonly List _hookSnapshot = new(); @@ -63,7 +66,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram Action, IReadOnlyList> publishPartPoses, Func? isResident = null, Action? commitLiveRoot = null, - Func? residencyVersion = null) + Func? residencyVersion = null, + Func? + resolveLiveOwner = null) { _animationLoader = animationLoader ?? throw new ArgumentNullException(nameof(animationLoader)); @@ -74,6 +80,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram _isResident = isResident ?? (_ => true); _commitLiveRoot = commitLiveRoot ?? ((_, _) => { }); _residencyVersion = residencyVersion ?? (_ => 0UL); + _resolveLiveOwner = resolveLiveOwner; } internal int Count => _owners.Count; @@ -363,12 +370,30 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram foreach (Owner owner in _snapshot) { if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current) - || !ReferenceEquals(current, owner) - || owner.Sequencer is not { } sequencer) + || !ReferenceEquals(current, owner)) { continue; } + // C3c: live Static objects are registered while their visual + // sidecar is hydrated, before Runtime has completed the initial + // CreateObject SetPosition transaction. The first-entry + // conductor owns construction of the canonical CPhysicsObj. + // Resolve and bind that already-placed owner lazily; constructing + // a second/eager body here makes the conductor reject authority + // and leaves portals, doors, and other animated statics cell-less. + if (owner.Sequencer is null + && owner.Entity.ServerGuid != 0 + && _resolveLiveOwner?.Invoke(owner.Entity) is { } binding) + { + _ = BindLiveOwner( + owner.Entity, + binding.Animation, + binding.Body); + } + if (owner.Sequencer is not { } sequencer) + continue; + owner.ElapsedSinceUpdate += elapsedSeconds; if (!_isResident(owner.Entity)) { diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs index 1d42cb5d..5a769558 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs @@ -36,6 +36,14 @@ public sealed class EntityEffectController : IAnimationHookSink, private readonly Action _ownerSoundTableChanged; private readonly Dictionary _liveProfiles = []; private readonly HashSet _readyLiveOwners = []; + // C3c constructs the App effect owner before Runtime's initial placement + // receipt binds its world presentation. During that one-time split-lifetime + // window, retail still considers the CPhysicsObj absent: SmartBox queues + // F754/F755 at 0x00452020/0x00452070, enters the object, then drains them + // through ProcessObjectNetBlobs in HandleCreateObject 0x00454C80. Keep the + // exact incarnation behind an equivalent barrier until the graphical + // placement publishes its pose and resource visibility. + private readonly HashSet _initialPresentationBarriers = []; private readonly Dictionary> _pendingByServerGuid = new(); private readonly Dictionary _staticOwners = new(); private readonly Dictionary _staticProfiles = new(); @@ -90,6 +98,8 @@ public sealed class EntityEffectController : IAnimationHookSink, RefreshLiveAnchor(message.Guid, localId); if (CanStartOwner(localId)) PlayDirect(localId, message.ScriptDid); + else if (IsWaitingForInitialPresentation(message.Guid)) + Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid)); return; } Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid)); @@ -104,6 +114,12 @@ public sealed class EntityEffectController : IAnimationHookSink, RefreshLiveAnchor(message.Guid, localId); if (CanStartOwner(localId)) PlayTyped(localId, message.RawScriptType, message.Intensity); + else if (IsWaitingForInitialPresentation(message.Guid)) + { + Enqueue( + message.Guid, + PendingEffect.Typed(message.RawScriptType, message.Intensity)); + } return; } Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity)); @@ -140,11 +156,45 @@ public sealed class EntityEffectController : IAnimationHookSink, RuntimeEntityKey key = RequireProjectionKey(record); _readyLiveOwners.Add(key); _liveProfiles[key] = profile; + if (record.MaterializationResidence is + LiveEntityMaterializationResidence.AwaitRuntimePlacement + && !record.IsSpatiallyProjected) + { + _initialPresentationBarriers.Add(key); + } + else + { + _initialPresentationBarriers.Remove(key); + } _runner.SetOwnerAnchor(entity.Id, entity.Position); _ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid); return true; } + /// + /// Opens the C3c-only network-script barrier after the initial placement + /// has published the entity pose and presentation resources, then replays + /// the retained mixed F754/F755 FIFO synchronously in arrival order. + /// + public bool OnPresentationBound(LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + if (record.ProjectionKey is not { } key + || !_liveEntities.TryGetRecord(key, out LiveEntityRecord current) + || !ReferenceEquals(current, record)) + { + return false; + } + + _initialPresentationBarriers.Remove(key); + if (!TryGetReadyLocalId(record.ServerGuid, out uint localId)) + return true; + + RefreshLiveAnchor(record.ServerGuid, localId); + TryReplayPending(record.ServerGuid, localId); + return true; + } + /// Replays the mixed F754/F755 FIFO after full object construction. public bool ReplayPendingForLiveEntity(uint serverGuid) { @@ -219,6 +269,7 @@ public sealed class EntityEffectController : IAnimationHookSink, { _readyLiveOwners.Remove(key); _liveProfiles.Remove(key); + _initialPresentationBarriers.Remove(key); } if (record.LocalEntityId is not { } localId) return; @@ -241,6 +292,7 @@ public sealed class EntityEffectController : IAnimationHookSink, } _readyLiveOwners.Clear(); _liveProfiles.Clear(); + _initialPresentationBarriers.Clear(); _pendingByServerGuid.Clear(); _dirtyLiveOwners.Clear(); _dirtyLiveOwnerOrder.Clear(); @@ -455,6 +507,11 @@ public sealed class EntityEffectController : IAnimationHookSink, return false; } + private bool IsWaitingForInitialPresentation(uint serverGuid) => + _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) + && record.ProjectionKey is { } key + && _initialPresentationBarriers.Contains(key); + private void OnEffectPoseChanged(uint localId) { if (_posePublishLocalId == localId) diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index c708c8d2..e0d88d4d 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -842,6 +842,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource // between two loaded buckets. Suppress those implementation details // and publish only the final logical visibility edge. record.IsSpatiallyProjected = true; + bool hasExactDestinationCell = spatialCellOrLandblockId != 0u + && (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu; + if (hasExactDestinationCell) + { + // Runtime's physics cell commit and the graphical sidecar are one + // SetPosition result. Retail CPhysicsObj::set_cell changes the + // CObjCell read by ShouldDrawParticles at the same edge; retaining + // the prior sidecar cell makes newly-created spell particles fail + // IsInView as soon as the player crosses an outdoor landcell. + entity.ParentCellId = spatialCellOrLandblockId; + entity.EffectCellId = spatialCellOrLandblockId; + } Exception? spatialNotificationFailure = null; uint priorRebucketingGuid = _rebucketingGuid; _rebucketingGuid = serverGuid; diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index 9d3041e9..e26fb000 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -107,7 +107,7 @@ public sealed class ProjectileControllerTests Assert.Null(record.AnimationRuntime); var remote = fixture.Live.GetOrCreateRemoteMotionRuntime(Guid); - remote.Body.Position = entity.Position; + remote.Body.SnapToCell(CellA, entity.Position, entity.Position); remote.Body.Orientation = entity.Rotation; Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1)); Assert.Same(remote.Body, record.ProjectileRuntime!.Body); @@ -1006,6 +1006,8 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; var remote = fixture.Live.GetOrCreateRemoteMotionRuntime(Guid); + WorldEntity entity = record.WorldEntity!; + remote.Body.SnapToCell(CellA, entity.Position, entity.Position); record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( @@ -1081,7 +1083,10 @@ public sealed class ProjectileControllerTests Assert.Equal(new Vector3(0f, 0f, 2f), remote.Body.Omega); remote.Body.set_velocity(new Vector3(8f, 0f, 0f)); remote.Body.Omega = new Vector3(0f, 0f, 3f); - remote.Body.Position = entity.Position; + remote.Body.SnapToCell( + startCell, + entity.Position, + new Vector3(191f, 10f, 50f)); remote.Body.State = record.FinalPhysicsState; PhysicsBody body = remote.Body; @@ -1142,7 +1147,7 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; var remote = fixture.Live.GetOrCreateRemoteMotionRuntime(Guid); - remote.Body.Position = entity.Position; + remote.Body.SnapToCell(CellA, entity.Position, entity.Position); remote.Body.Orientation = entity.Rotation; Assert.True(float.IsNaN(record.Snapshot.Physics!.Value.Velocity!.Value.X)); @@ -1181,6 +1186,55 @@ public sealed class ProjectileControllerTests Assert.Equal(MissileState, remote.Body.State); } + [Fact] + public void ResidencePlacementVisibility_RetriesProjectileBindingAfterBodyFrameCommits() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + WorldEntity entity = record.WorldEntity!; + var remote = fixture.Live.GetOrCreateRemoteMotionRuntime(Guid); + + Assert.Null(record.ProjectileRuntime); + Assert.True(fixture.Live.WithdrawLiveEntityProjection(Guid)); + + remote.Body.Orientation = Quaternion.Identity; + remote.Body.set_velocity(new Vector3(10f, 0f, 0f)); + remote.Body.SnapToCell(CellA, entity.Position, entity.Position); + + Assert.True(fixture.Live.RebucketLiveEntity(Guid, CellA)); + + Assert.NotNull(record.ProjectileRuntime); + Assert.Same(remote.Body, record.ProjectileRuntime!.Body); + Assert.True(record.ProjectileRuntime.Body.InWorld); + } + + [Fact] + public void SharedBodyCell_IsCanonicalBeforePresentationCellIsProjected() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + WorldEntity entity = record.WorldEntity!; + var remote = fixture.Live.GetOrCreateRemoteMotionRuntime(Guid); + + Assert.True(fixture.Live.WithdrawLiveEntityProjection(Guid)); + record.CanonicalLandblockId = 0u; + record.FullCellId = 0u; + Assert.Equal(0u, record.FullCellId); + remote.Body.Orientation = Quaternion.Identity; + remote.Body.set_velocity(new Vector3(10f, 0f, 0f)); + remote.Body.SnapToCell(CellA, entity.Position, entity.Position); + + Assert.True(fixture.Controller.TryBind( + record, + ProjectileSetup(), + currentTime: 1.0, + liveCenterX: 1, + liveCenterY: 1)); + + Assert.Equal(CellA, record.FullCellId); + Assert.Same(remote.Body, record.ProjectileRuntime!.Body); + } + [Fact] public void SharedRemoteMotionCell_DelegatesToCanonicalLiveRecord() { diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index 0ed92521..7cfc97c7 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -239,6 +239,50 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Assert.Equal(referenceFrames[0], frames[0]); } + [Fact] + public void PendingLiveStaticOwner_BindsCanonicalRuntimeOwnerOnFirstTick() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + Setup setup = MakeSetup(); + WorldEntity entity = MakeEntity(serverGuid: 0x70000001u); + var sequencer = new AnimationSequencer( + setup, + new MotionTable(), + loader); + var body = new PhysicsBody + { + Orientation = entity.Rotation, + }; + body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero); + LiveEntityAnimationState animation = + LiveState(entity, setup, sequencer); + int resolutions = 0; + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => { }, + (_, _, _) => { }, + resolveLiveOwner: candidate => + { + Assert.Same(entity, candidate); + resolutions++; + return (animation, body); + }); + Assert.True(scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true))); + + scheduler.Tick(0.02f); + scheduler.Tick(0.02f); + + Assert.Equal(1, resolutions); + Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _)); + } + [Fact] public void LivePhysicsStaticOwner_DiscardsLongNonResidentIntervalOnReentry() { diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs index 32ed7f8b..1ad1ec21 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs @@ -9,6 +9,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Vfx; using AcDream.Core.World; +using AcDream.Runtime.Entities; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -129,6 +130,31 @@ public sealed class EntityEffectControllerTests return entity; } + public LiveEntityRecord AwaitInitialPresentation( + uint guid = Guid, + ushort generation = 1, + EntityEffectProfile? profile = null) + { + WorldSession.EntitySpawn spawn = Spawn(guid, generation); + RuntimeEntityRecord canonical = Assert.IsType( + Runtime.RegisterLiveEntity(spawn).Canonical); + WorldEntity entity = Assert.IsType( + Runtime.MaterializeLiveEntity( + canonical, + spawn.Position!.Value.LandblockId, + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: exact => + exact.EffectProfile = profile ?? LiveProfile(), + out LiveEntityRecord? record, + LiveEntityMaterializationResidence.AwaitRuntimePlacement)); + Assert.Same(entity, record!.WorldEntity); + Assert.False(record.IsSpatiallyProjected); + Assert.False(record.IsSpatiallyVisible); + Assert.True(Controller.PrepareLiveEntityOwner(guid)); + return record; + } + public static EntityEffectProfile LiveProfile( uint tableDid = TableDid, uint rawDefaultType = RawType, @@ -187,6 +213,32 @@ public sealed class EntityEffectControllerTests Assert.Equal(1, fixture.Runner.ActiveScriptCount); } + [Fact] + public void RuntimeInitialPlacementWindow_DefersPacketsUntilPresentationBinds() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.AwaitInitialPresentation(); + + fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid)); + fixture.Controller.HandleTyped( + new PlayPhysicsScriptType(Guid, RawType, 0.5f)); + + Assert.Equal(2, fixture.Controller.PendingPacketCount); + Assert.Equal(0, fixture.Runner.ActiveScriptCount); + + // Runtime has committed the first frame and the graphical placement + // receipt is now publishing its pose/resources. + record.FullCellId = 0x01010001u; + record.IsSpatiallyProjected = true; + record.IsSpatiallyVisible = true; + Assert.True(fixture.Controller.OnPresentationBound(record)); + Assert.Equal(0, fixture.Controller.PendingPacketCount); + Assert.Equal(2, fixture.Runner.ActiveScriptCount); + + fixture.Runner.Tick(0.0); + Assert.Equal([1u, 2u], EmitterIds(fixture.Sink)); + } + [Fact] public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately() { diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index 7baa11ef..4aa33ce9 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -2190,16 +2190,23 @@ public sealed class LiveEntityRuntimeTests spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); var runtime = LiveEntityRuntimeFixture.Create(spatial, new RecordingResources()); runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010022u)); - runtime.MaterializeLiveEntity(guid, 0x01010022u, id => Entity(id, guid)); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + 0x01010022u, + id => Entity(id, guid))!; runtime.RebucketLiveEntity(guid, 0x0102FFFFu); Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); Assert.Equal(0x01010022u, record.FullCellId); Assert.Equal(0x0102FFFFu, record.CanonicalLandblockId); + Assert.Equal(0x01010022u, entity.ParentCellId); + Assert.Equal(0x01010022u, entity.EffectCellId); runtime.RebucketLiveEntity(guid, 0x01020033u); Assert.Equal(0x01020033u, record.FullCellId); + Assert.Equal(0x01020033u, entity.ParentCellId); + Assert.Equal(0x01020033u, entity.EffectCellId); } [Fact] From 175ad6b0d0c074432bbbf14e47f81fb73b201286 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 3 Aug 2026 12:10:42 +0200 Subject: [PATCH 72/73] fix(session): acknowledge login after first placement ACE intentionally creates the local player Hidden and releases that materialization state on LoginComplete. Sending LoginComplete from raw F746 receipt raced canonical placement and left the login haze visible. Route one one-shot completion callback from Runtime's local first-entry terminal edge to graphical and prepared headless hosts; retain a guarded accepted-Create edge only for content-less headless sessions. Focused Runtime login tests, all 79 Headless tests, the connected user gate, and the Release build pass. --- .../retail-divergence-register.md | 2 +- .../Net/GraphicalSessionEventRoute.cs | 14 +++-- .../Net/LiveSessionRuntimeFactory.cs | 4 +- .../Messages/GameActionLoginComplete.cs | 5 +- src/AcDream.Core.Net/WorldSession.cs | 40 ++------------ .../Hosting/HeadlessSessionEventRoute.cs | 8 ++- .../Hosting/HeadlessSessionHost.cs | 4 +- .../RuntimeFirstEntryDriveController.cs | 12 ++++- .../RuntimeLiveEntitySessionController.cs | 12 +++++ ...RuntimeLiveEntitySessionControllerTests.cs | 54 +++++++++++++++++++ 10 files changed, 107 insertions(+), 48 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 36e79f4e..82eb6ae9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -297,7 +297,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16** — `CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons ∪ portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` | | TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | | TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` | -| TS-28 | **NARROWED 2026-07-15** — F751 teleports now resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login still sends LoginComplete directly from the PlayerCreate (0xF746) handler and does not enter the portal-space presentation. | `src/AcDream.Core.Net/WorldSession.cs` (PlayerCreate branch); `src/AcDream.App/Rendering/GameWindow.cs` (F751 `FireLoginComplete`) | The live-session bootstrap currently needs the acknowledgement to unlock the initial authoritative object/property stream; moving initial login behind the App presentation requires an explicit session→presentation readiness contract rather than withholding it inside Core.Net | Initial login can expose server updates earlier than retail and skips the wormhole presentation; recalls/portals now have retail ordering | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` | +| TS-28 | **NARROWED 2026-08-03** — F751 teleports resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login no longer acknowledges raw PlayerCreate receipt: graphical and prepared headless hosts send exactly once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. Residual: initial login still does not enter the full portal-space presentation. | `src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs`; `src/AcDream.App/Net/GraphicalSessionEventRoute.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs`; `src/AcDream.Core.Net/WorldSession.cs` | Initial placement is now the shared readiness contract that releases ACE's intentional Hidden/pink-bubble state without racing presentation. The content-less direct host uses its only truthful admission edge. | The persistent login materialization haze is fixed and server updates no longer unlock before canonical placement. The remaining difference is presentation-only: initial login skips retail's wormhole sequence. | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` | | TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) | | TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | diff --git a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs index 518a2ae8..6bc25897 100644 --- a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs +++ b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs @@ -1,4 +1,5 @@ using AcDream.Runtime; +using AcDream.Runtime.Entities; using AcDream.Runtime.Physics; using AcDream.Runtime.Session; @@ -16,6 +17,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting private readonly Func _generation; private readonly RuntimePlacementProjectionRetrySlot _retries; private readonly RuntimeFirstEntryDriveController? _firstEntry; + private readonly Action? _localPlayerCompleted; private RuntimePlacementProjectionSubscription? _subscription; private IDisposable? _retryLease; private bool _attachStarted; @@ -27,7 +29,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting GameRuntime runtime, IRuntimePlacementProjectionSink placements, RuntimePlacementProjectionRetrySlot retries, - RuntimeFirstEntryDriveController? firstEntry = null) + RuntimeFirstEntryDriveController? firstEntry = null, + Action? localPlayerCompleted = null) : this( events, () => new RuntimePlacementProjectionSubscription( @@ -36,7 +39,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting retryPendingOnSubscribe: false), () => runtime.Generation, retries, - firstEntry) + firstEntry, + localPlayerCompleted) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(placements); @@ -47,7 +51,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting Func createSubscription, Func generation, RuntimePlacementProjectionRetrySlot retries, - RuntimeFirstEntryDriveController? firstEntry = null) + RuntimeFirstEntryDriveController? firstEntry = null, + Action? localPlayerCompleted = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _createSubscription = createSubscription @@ -56,6 +61,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting ?? throw new ArgumentNullException(nameof(generation)); _retries = retries ?? throw new ArgumentNullException(nameof(retries)); _firstEntry = firstEntry; + _localPlayerCompleted = localPlayerCompleted; } public void Attach() @@ -68,7 +74,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting // C3c-R1 review F6: assert (not assume) that the prior route // detached — session reset precedes a new route — before this route // takes ownership of the shared drive controller's tracked entries. - _firstEntry?.AttachRoute(this); + _firstEntry?.AttachRoute(this, _localPlayerCompleted); _events.Attach(); RuntimePlacementProjectionSubscription? subscription = null; diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index b9103078..c6d4dfda 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -18,6 +18,7 @@ using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; +using AcDream.Core.Net.Messages; using AcDream.Core.Player; using AcDream.Core.Social; using AcDream.Core.Spells; @@ -262,7 +263,8 @@ internal sealed class LiveSessionRuntimeFactory _domain.Runtime, _world.PlacementProjection, _world.PlacementRetries, - _world.FirstEntryDrive); + _world.FirstEntryDrive, + _ => session.SendGameAction(GameActionLoginComplete.Build())); } private LiveInventorySessionBindings CreateInventoryBindings() => new( diff --git a/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs b/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs index 84dc5313..cd087aeb 100644 --- a/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs +++ b/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs @@ -29,8 +29,9 @@ namespace AcDream.Core.Net.Messages; /// Retail clients send it once the portal-space transition animation finishes. /// acdream's F751 teleport path now does the same through /// TeleportAnimSequencer.FireLoginComplete. Initial session bootstrap -/// still sends from the PlayerCreate handler; that remaining ordering gap is -/// tracked as TS-28 in the divergence register. +/// sends from the canonical local-player first-placement completion edge, not +/// from raw packet receipt, so ACE's intentional Hidden/pink-bubble state is +/// released only after the client can actually present the player. /// /// public static class GameActionLoginComplete diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 9a3af53c..a56ce33d 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -555,16 +555,6 @@ public sealed class WorldSession : IDisposable /// public double LastServerTimeTicks { get; private set; } - /// - /// Allow re-sending LoginComplete after a portal teleport. The normal - /// _loginCompleteSent latch prevents duplicate sends on the initial spawn - /// path; this method resets it so the teleport completion path can send - /// another LoginComplete to tell the server the client has finished loading - /// the destination cell. Pattern from holtburger's PlayerTeleport handler - /// (client/messages.rs line 434-440: call send_login_complete on teleport). - /// - public void ResetLoginComplete() => _loginCompleteSent = false; - /// Raised every time the state machine transitions. public event Action? StateChanged; @@ -771,14 +761,6 @@ public sealed class WorldSession : IDisposable Buffer.AsMemory(0, Length); } - /// - /// Phase 4.10 latch — true after we've sent the LoginComplete game - /// action in response to PlayerCreate. Prevents re-sending if the - /// server emits multiple PlayerCreate messages (rare but possible - /// across recall / portal teleports). - /// - private bool _loginCompleteSent; - /// L.2g slice 1: one-shot guard so the [setstate-hex] probe /// emits the first SetState's body bytes only, not 5–10/sec. private bool _setStateHexDumped; @@ -1061,13 +1043,10 @@ public sealed class WorldSession : IDisposable // login form. ACE validates this canonical account value. SendGameMessage(selection.EnterWorldBody); - // NOTE: LoginComplete used to be sent here unconditionally. That was - // wrong — per holtburger's flow (see references/holtburger/.../client/ - // messages.rs lines 391-422), LoginComplete is sent in response to the - // server's PlayerCreate (0xF746) game message, NOT immediately after - // EnterWorld. Sending it too early means the player object isn't - // ready and the server ignores it. The actual trigger lives in - // ProcessDatagram. + // LoginComplete is emitted by the host only after the accepted local + // Create has completed its canonical first placement. Sending it at + // EnterWorld or merely on PlayerCreate races the server's intentional + // Hidden/pink-bubble login state. Transition(State.InWorld); // Phase A.3: start the background receive thread now that the @@ -1705,17 +1684,6 @@ public sealed class WorldSession : IDisposable // references/holtburger/.../client/messages.rs::DddInterrogation SendGameMessage(DddInterrogationResponse.Build()); } - else if (op == 0xF746u && !_loginCompleteSent) // PlayerCreate — server creates our player object - { - // Phase 4.10: PlayerCreate for our character is the cue to - // send LoginComplete. Sending it earlier (right after the - // outbound CharacterEnterWorld) was wrong because the server - // hadn't finished spawning the player yet. Holtburger's - // client/messages.rs (PlayerCreate handler) confirms this is - // the correct trigger. Send once per session. - _loginCompleteSent = true; - SendGameMessage(GameActionLoginComplete.Build()); - } else if (op == CreateObject.Opcode) { var parsed = CreateObject.TryParse(body); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs index afb0c400..87ca6e4c 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs @@ -1,4 +1,5 @@ using AcDream.Runtime; +using AcDream.Runtime.Entities; using AcDream.Runtime.Physics; using AcDream.Runtime.Session; @@ -16,6 +17,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting private readonly GameRuntime _runtime; private readonly IRuntimePlacementProjectionSink _placements; private readonly RuntimeFirstEntryDriveController? _firstEntry; + private readonly Action? _localPlayerCompleted; private RuntimePlacementProjectionSubscription? _subscription; private bool _attachStarted; private bool _eventsDisposed; @@ -25,13 +27,15 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting ILiveSessionEventRouting events, GameRuntime runtime, IRuntimePlacementProjectionSink placements, - RuntimeFirstEntryDriveController? firstEntry = null) + RuntimeFirstEntryDriveController? firstEntry = null, + Action? localPlayerCompleted = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _placements = placements ?? throw new ArgumentNullException(nameof(placements)); _firstEntry = firstEntry; + _localPlayerCompleted = localPlayerCompleted; } public void Attach() @@ -47,7 +51,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting // C3c-R1 review F6: assert (not assume) that the prior route // detached — session reset precedes a new route — before this route // takes ownership of the shared drive controller's tracked entries. - _firstEntry?.AttachRoute(this); + _firstEntry?.AttachRoute(this, _localPlayerCompleted); _events.Attach(); _subscription = new RuntimePlacementProjectionSubscription( _runtime, diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 8d5ca3b0..16d14d80 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -2,6 +2,7 @@ using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Policies; +using AcDream.Core.Net.Messages; using AcDream.Runtime; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; @@ -618,7 +619,8 @@ internal sealed class HeadlessSessionHost : IDisposable route, Runtime, new HeadlessRuntimePlacementProjectionSink(Runtime), - _firstEntryDrive); + _firstEntryDrive, + _ => session.SendGameAction(GameActionLoginComplete.Build())); } private static LiveSessionCharacterSelector MapCharacterSelector( diff --git a/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs index 921e15ea..8dd3aff9 100644 --- a/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs @@ -61,6 +61,7 @@ internal sealed class RuntimeFirstEntryDriveController private bool _driving; /// C3c-R1 review F6: see . private object? _routeOwner; + private Action? _localPlayerCompleted; internal RuntimeFirstEntryDriveController( RuntimeEntityObjectLifetime entityObjects, @@ -155,7 +156,9 @@ internal sealed class RuntimeFirstEntryDriveController /// before the prior route detached would otherwise let the OLD route's /// dispose wipe the NEW route's tracked entries. /// - internal void AttachRoute(object route) + internal void AttachRoute( + object route, + Action? localPlayerCompleted = null) { ArgumentNullException.ThrowIfNull(route); if (_routeOwner is not null && !ReferenceEquals(_routeOwner, route)) @@ -166,6 +169,7 @@ internal sealed class RuntimeFirstEntryDriveController + "precedes a new route) before a replacement attaches."); } _routeOwner = route; + _localPlayerCompleted = localPlayerCompleted; } /// @@ -181,6 +185,7 @@ internal sealed class RuntimeFirstEntryDriveController if (!ReferenceEquals(_routeOwner, route)) return; _routeOwner = null; + _localPlayerCompleted = null; _pending.Clear(); } @@ -198,6 +203,7 @@ internal sealed class RuntimeFirstEntryDriveController bool terminal; bool awaitingContinuationPlacement; + bool localPlayerCompleted = false; if (pending.IsLocalPlayer) { RuntimeLocalPlayerFirstEntryStatus status = @@ -214,6 +220,8 @@ internal sealed class RuntimeFirstEntryDriveController is RuntimeLocalPlayerFirstEntryStatus.Completed or RuntimeLocalPlayerFirstEntryStatus.RejectedToken or RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + localPlayerCompleted = status + is RuntimeLocalPlayerFirstEntryStatus.Completed; awaitingContinuationPlacement = status is RuntimeLocalPlayerFirstEntryStatus .AwaitingContinuationPlacement; @@ -241,6 +249,8 @@ internal sealed class RuntimeFirstEntryDriveController if (terminal) { _pending.Remove(key); + if (localPlayerCompleted) + _localPlayerCompleted?.Invoke(pending.Record); return; } if (!awaitingContinuationPlacement) diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index 55140db2..bf7123d2 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -40,6 +40,7 @@ public sealed class RuntimeLiveEntitySessionController private readonly IRuntimeDirectWorldProjection? _worldProjection; private readonly LocalPlayerOutboundController _localPlayerOutbound = new((_, _, _, _, _, _) => { }); + private bool _initialLoginCompleteSent; public RuntimeLiveEntitySessionController( GameRuntime runtime, @@ -111,6 +112,17 @@ public sealed class RuntimeLiveEntitySessionController canonical, canonical.ServerGuid == _runtime.PlayerIdentity.ServerGuid); + if (_worldProjection is null + && canonical.ServerGuid + == _runtime.PlayerIdentity.ServerGuid + && !_initialLoginCompleteSent) + { + // A content-less direct host has no first-entry placement + // conductor. Its accepted local Create is therefore its + // truthful terminal admission edge. + _initialLoginCompleteSent = true; + _session.SendGameAction(GameActionLoginComplete.Build()); + } } } diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 647f1ee8..123e68ac 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -165,6 +165,12 @@ public sealed class RuntimeLiveEntitySessionControllerTests Spawn(playerGuid, incarnation: 1); sink.Spawned(spawn); + Assert.Single(gameActions); + Assert.Equal(GameActionLoginComplete.Build(), gameActions[0]); + sink.Spawned(spawn); + Assert.Single(gameActions); + gameActions.Clear(); + sink.TeleportStarted(1u); sink.PositionUpdated(new WorldSession.EntityPositionUpdate( playerGuid, @@ -182,6 +188,7 @@ public sealed class RuntimeLiveEntitySessionControllerTests ForcePositionSequence: 0)); Assert.Single(gameActions); + Assert.Equal(GameActionLoginComplete.Build(), gameActions[0]); Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle); Assert.True(runtime.Portal.Snapshot.Completed); Assert.Equal(0x01020001u, runtime.Portal.Snapshot.DestinationCell); @@ -278,6 +285,41 @@ public sealed class RuntimeLiveEntitySessionControllerTests drive.DetachRoute(routeB); } + [Fact] + public void FirstEntryDriveSignalsLocalCompletionOnceAfterCanonicalPlacement() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + const uint playerGuid = 0x50000003u; + runtime.PlayerIdentity.ServerGuid = playerGuid; + CommitLandblockCollision(runtime, 0x01010000u); + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); + var route = new object(); + int completed = 0; + drive.AttachRoute(route, record => + { + Assert.Equal(playerGuid, record.ServerGuid); + completed++; + }); + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session, + worldProjection: new FixtureWorldProjection()); + LiveEntitySessionSink sink = controller.CreateSink(); + + sink.Spawned(Spawn(playerGuid, incarnation: 1)); + Assert.Equal(0, completed); + + DrainFirstEntry(runtime, drive); + + Assert.Equal(1, completed); + Assert.Equal(0, drive.PendingCount); + drive.DetachRoute(route); + } + /// /// C3c: initial-residence admission requires a live session generation /// (RuntimeInitialCreateResidenceState.CanAcceptCreate), so these direct @@ -372,6 +414,18 @@ public sealed class RuntimeLiveEntitySessionControllerTests Height: 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + private static void DrainFirstEntry( + GameRuntime runtime, + RuntimeFirstEntryDriveController drive) + { + for (int attempt = 0; attempt < 8 && drive.PendingCount != 0; attempt++) + { + drive.DriveAll(); + DrainPlacementFifo(runtime); + } + Assert.Equal(0, drive.PendingCount); + } + /// /// Drains/acknowledges every still-pending placement receipt (the /// ExecutorCompleted correlation is reaped by its acknowledgement) the From 205f3fea6f70adef710b4d2c1b22597849ae974b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 3 Aug 2026 12:54:47 +0200 Subject: [PATCH 73/73] docs: hand off placement campaign finish --- docs/ISSUES.md | 33 +- docs/plans/2026-04-11-roadmap.md | 21 +- docs/plans/2026-05-12-milestones.md | 18 +- docs/plans/2026-08-02-placement-cutover.md | 61 ++- .../NEXT-AGENT-PROMPT.md | 406 ++++++++++++------ .../docs-drafts.md | 9 + .../implementer-progress.md | 38 ++ memory/project_collision_port.md | 31 ++ 8 files changed, 441 insertions(+), 176 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 64357a9d..a41d2a48 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -52,26 +52,17 @@ What does NOT go here: review). Related narrowing: a remote whose landblock leaves the headless service window between ProjectSpawn and placement commit still parks (route 8, rarer than the pre-F7 leak). -- **#278 — OPEN — post-C3c user-session triage bundle (2026-08-02 - observations).** (a) purple materialization haze re-fires while - standing still — traced to the Hidden/UnHide script re-firing on a - visibility edge; most plausible driver is the pre-existing `6b28ff99` - streaming-convergence regression (its dedicated slice precedes C5) — - re-observe after that slice; (b) no lateral glide when walking against - impassable slopes — verify against open #269 (Campaign P slope-slide - residual) in the session before treating as new; (c) ~~`/ls` command - reported non-working~~ — RESOLVED 2026-08-02: user confirmed `/ls` - works in-client; the earlier report was environmental noise. - **Smoke-test additions (2026-08-02 evening, retail-UI session):** - (d) monsters pop into existence late — appear on radar BEHIND the - running player; (e) recall spends far longer in portal space than - retail, occasionally sticks there; (f) on portal exit the character - pops in instead of being visible at reveal. (d)/(e)/(f) are the - `6b28ff99` publication-throughput regression made user-visible by - C3c's retail-correct wait-for-collision placement order (spawns no - longer hand-place ahead of publication); expected to resolve with the - O(changed)-clone slice — re-verify all three in its acceptance - session. +- **#278 — NARROWED 2026-08-03 — post-C3c user-session triage bundle.** + Resolved and user-verified: (a) the persistent login purple haze no longer + races raw PlayerCreate receipt (`175ad6b0`); (c) `/ls` works; (d) remote + monsters no longer pop in behind the player or place/attack in a different + coordinate frame (`670f307c`); (e) the retirement-receipt replay loop that + stalled streaming and portal convergence is gone (`01f4791e`); and (f) + materialization/effect presentation is bound after canonical placement + (`f24532ad`, `175ad6b0`). The remaining item is (b): explicitly compare + lateral glide against impassable slopes with open #269 before closing this + bundle. Far terrain that can visibly continue building after portal reveal + is tracked separately as #280. - **#279 — DONE (2026-08-03, user-verified) — one-shot spell/effect scripts arriving during the suppressed-until-receipt window were lost.** `EntityEffectController` now retains the mixed F754/F755 FIFO behind an @@ -80,7 +71,7 @@ What does NOT go here: resources. Live rebuckets also keep the effect cell synchronized with the entity cell. Spell buffs, recalls, arrows, and combat spell projectiles were verified in the connected client; focused effect, projectile, and - cell-transition tests cover the race. + cell-transition tests cover the race. Landed at `f24532ad`. - **#280 — OPEN — portal reveal can expose an incompletely streamed distant landscape.** User-observed 2026-08-03: after some recalls, the nearby destination is playable but terrain near the far end of the view continues diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 5afef9d6..d74ce2fa 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-27. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-08-03. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. **Slice L checkpoint:** L0 closed at `66f114b2` with one typed graphical @@ -62,7 +62,7 @@ crowd, two-client remote/door/portal, and shallow-water behavior. The user waived the general sweep and explicitly deferred the barred-house gate as #274. The later exact-location #273 tight-gap gate is now fixed and accepted. -**Remaining physics-divergence closeout (ACTIVE 2026-07-31):** the user then +**Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then authorized retirement of the remaining proven collision/placement gaps before vendor work resumes. Nested retry, edge/StepDown/Path-6 ordering, exact cell availability, atomic collision generations, canonical Core SetPosition, @@ -84,12 +84,17 @@ execution-time retail Position routing, shared apply bodies keeping one snapshot store in lockstep, and converged ownership ledgers on every abandonment path — dual independent reviews PASS; register rows AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 filed in the same commit. -Production Create registration is not yet cut over (the executor has no -production caller). Next is the all-host/all-route cutover which can retire -AP-1/AD-1 behind its connected/visual gates. AP-22 authored object shapes and -AD-10 remote contact-plane projection follow, then the final matrix and -ledger closeout. Current handoff: -[`2026-08-02-runtime-continuation-executor-handoff.md`](../research/2026-08-02-runtime-continuation-executor-handoff.md). +Production initial Create registration is now cut over by C3c (`529e0e9d`). +The O(changed) collision-publication checkpoint and five stabilization fixes +through `175ad6b0` restore recenter convergence, remote world-frame placement +and targeting, distant Use, one-shot spell/projectile/static effects, and +login materialization; the corresponding connected user gates passed. The +campaign remains open for six fixture reconciliations, C4 routes 2–7, portal +destination prefetch #280, the final-binary C5 suite/soak/visual matrix, +AP-22 authored object shapes, and AD-10 remote contact-plane projection. +Current plan and copy-ready handoff: +[`2026-08-02-placement-cutover.md`](2026-08-02-placement-cutover.md) and +[`NEXT-AGENT-PROMPT.md`](../research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md). --- diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 42beaa8a..97cc4612 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -109,12 +109,18 @@ through the canonical SetPosition lifecycle. Independent retail-conformance and architecture/adversarial reviews both PASS after five implementation rounds; register rows AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 document the slice's deviations; Runtime tests 903/903, complete Release solution -10,696/4 skips. Production Create registration is still NOT cut over — the -executor has no production caller. The remaining order is the all-host/ -all-route cutover (which can retire AP-1/AD-1 behind its connected/visual -gates), AP-22 shape fidelity, AD-10 remote contact-plane projection, and the -final matrix/ledger closeout. Resume Slice 5 vendor browsing only after that -closeout or a new explicit user direction. +10,696/4 skips. Production initial Create registration is now cut over by C3c +(`529e0e9d`). The O(changed) collision-publication checkpoint and +stabilization fixes `01f4791e`, `670f307c`, `1fc529cd`, `f24532ad`, and +`175ad6b0` are connected user-accepted for recenter convergence, remote +monster/static placement and targeting, distant Use, spell/projectile/static +VFX, and login materialization. The remaining order is six selected-fixture +reconciliations, C4 routes 2–7, portal destination prefetch #280, C5's +final-binary complete suite/soak/two-client matrix, AP-22 shape fidelity, +AD-10 remote contact-plane projection, and final ledger closeout. Resume Slice +5 vendor browsing only after that closeout or a new explicit user direction. +Canonical checkpoint: +[`2026-08-02-placement-cutover.md`](2026-08-02-placement-cutover.md). The separately authorized modern-runtime performance program has completed Slices A–D: corrected measurement, prepared-package bake/dedup, package-only diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 1884bb81..1e92af05 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -6,6 +6,60 @@ residence + continuation-executor owner (`38fd4b8d` / `30012361` / `5db3de3c`), delete the legacy duplicate authorities, and retire AP-1/AD-1 behind connected + user-visual gates. +## Handoff checkpoint — 2026-08-03 + +**Status: stabilization checkpoint accepted; campaign closeout is not yet +complete.** The C3c production cutover and the O(changed) collision +publication checkpoint are now playable after five separately committed +root-cause fixes: + +- `01f4791e` stops origin recenter from manufacturing and replaying a second + retirement receipt for a pending-only live-projection bucket. Its exact + binary passed the complete Release suite, lifecycle route, and canonical + nine-stop soak (`connected-r6-soak-20260802-204309`, nine stops, zero + failures/wait cues/pending retirements). +- `670f307c` keeps remote Create placement, the local-player physics host, + targeting, chasing, and attacks in the same world-coordinate frame. The + user accepted monster placement/chase/hit behavior and static placement + after portals. +- `1fc529cd` materializes the canonical minimal static physics host before a + distant Use/MoveTo route and reconciles the pre-PartArray startup motion + suffix. The user accepted near and distant object use. +- `f24532ad` defers one-shot F754/F755 effects until canonical placement has + bound presentation, retries projectile/static-animation sidecars on the + committed visibility edge, and keeps effect cells synchronized. The user + accepted buffs, recalls, arrows, combat spell projectiles, portals, and + static animation. +- `175ad6b0` sends LoginComplete from the local first-placement terminal edge + instead of raw PlayerCreate receipt, so ACE's intentional login Hidden/ + materialization state cannot race placement. The user accepted the login + haze behavior. + +Focused verification after the final fix passed 90 App effect/projectile/ +static-scheduler tests, two Runtime login tests, the exact live-entity cell +tracking regression, all 79 Headless tests, and the Release solution build +with zero errors. The long connected soak and complete solution suite have +**not** been rerun on the final `175ad6b0` binary. A broader selected fixture +run also exposed five `LiveEntityRuntimeTests` failures tied to the still-open +placement cutover plus one old remote first-entry fixture that supplies an +empty collision source; classify and fix those before claiming C5 closure. + +Remaining campaign work, in order: + +1. Reproduce and repair the six fixture failures without weakening their + assertions or adding compatibility bypasses. +2. Finish C4's routes 2–7 and remove their legacy placement writers; fold in + #276 and #277 where their route becomes authoritative. +3. Resolve #280 with retail's configured destination-prefetch window so the + portal viewport never reveals visibly constructing far terrain. +4. Run C5's complete Release suite, lifecycle/reconnect route, latest-binary + nine-stop soak, two-client observation, and the remaining #269 slope-glide + visual check. A pass from `01f4791e` is evidence for that fix, not a + substitute for the final-binary soak. +5. Delete the superseded paths, retire AP-1/AD-1/AP-131 and AD-60's legacy + half only when the code proves they are gone, then complete AP-22 and + AD-10 and close the campaign ledger. + **Inputs (read in order):** 1. [`2026-08-02-runtime-continuation-executor-handoff.md`](../research/2026-08-02-runtime-continuation-executor-handoff.md) — the completed dormant mechanism and its cutover notes. @@ -189,7 +243,7 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. 175401`). Closeout: [`2026-08-02-c3c-cutover-closeout.md`](../research/2026-08-02-c3c-cutover-closeout.md). **Carried to C4/C5:** route-1 far-Create service-window conversion - if either streaming/broadcast radius changes (#276); the + if either streaming/broadcast radius changes (#277); the window-departure park narrowing; `NotifyRetirement`-on-active-entry subscriber invariant; the reachable equip-mid-conductor fail-fast; settle-CellId discard (#276-adjacent, see ISSUES). @@ -198,9 +252,10 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. 4 (remote Create/Position; delete `RemoteTeleportController`/`Placement` and the inline MoveOrTeleport duplicate), 5 (projectile authoritative), 6 (drops + split-recovery marking), 7 (residual pickup/parent/delete - polish).** May land as more than one commit if a route proves large; + polish). — OPEN at the 2026-08-03 handoff.** May land as more than one + commit if a route proves large; each sub-landing keeps the full review discipline. -- **C5 — legacy deletion + closeout gates.** Delete every superseded legacy +- **C5 — legacy deletion + closeout gates — OPEN.** Delete every superseded legacy path; parity tests; exact lifecycle/reconnect + canonical nine-stop connected routes; two-client observation; **user visual matrix** (the campaign's stopping point for user acceptance). Retire AP-1, AD-1, diff --git a/docs/research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md b/docs/research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md index 263e3ba9..b312896b 100644 --- a/docs/research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md +++ b/docs/research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md @@ -1,155 +1,285 @@ -# Task: diagnose and fix the acdream collision/placement regressions, then COMMIT the fixes +# Next-agent prompt — finish the retail placement/collision campaign -Work EXCLUSIVELY in `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch -`codex/port-claude-agents`, HEAD `71604331`. Read `CLAUDE.md` at the repo -root FIRST — its rules bind you (grep-named-retail-first workflow, no -workarounds, divergence-register bookkeeping, connected gates, launch -instructions). The user's in-game observations quoted below are AXIOMS — -they override any green gate number. +Continue acdream from the 2026-08-03 stabilization checkpoint. The code is +modern; behavior must remain retail-faithful. The campaign is playable again, +but it is **not closed**. -## Standing constraints +## Start here -- NEVER `git add -A`, `git add .`, `git reset --hard`, or +Use the merged local `main` worktree: + +```text +C:\Users\erikn\source\repos\acdream +``` + +The completed fixes originated on `codex/port-claude-agents` and are merged +into local `main`. Confirm the exact starting commit with `git rev-parse HEAD` +and read the operator's handoff message for the merge SHA. Do not reset, +clean, or overwrite the main worktree's untracked research/reference files. +The original feature worktree remains at: + +```text +C:\Users\erikn\.codex\worktrees\af5e\acdream +``` + +That feature worktree contains protected user-local modifications and is not +the preferred continuation workspace. + +Read these files in order before editing: + +1. `CLAUDE.md` and `AGENTS.md`. +2. `docs/plans/2026-08-02-placement-cutover.md` — canonical placement-cutover + plan and the 2026-08-03 checkpoint. +3. This prompt. +4. `docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md` + — especially `## P1` and the final stabilization checkpoint. +5. `docs/research/2026-08-02-collision-throughput-handoff/p1-retirement-receipt-loop.md`. +6. `docs/research/2026-08-02-c3c-cutover-closeout.md`. +7. `docs/ISSUES.md` #269 and #276–#280. +8. `docs/architecture/retail-divergence-register.md` rows AP-1, AP-22, + AP-131, AD-1, AD-10, AD-60, and TS-28. + +`docs-drafts.md` is now explicitly historical. Do **not** apply it wholesale: +it predates the final fixes and incorrectly tries to reuse issue number #280. + +## Binding rules + +- Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by named + `class::method` before fresh decompilation. Retail behavior is the oracle. +- Preserve the modern Runtime-owned, presentation-independent architecture. + Graphical and headless hosts must use the same canonical gameplay owners. +- Root causes only. Do not add timeouts, grace periods, suppression flags, + catch-and-swallow paths, duplicated placement writers, or compatibility + bypasses to make a test green. +- The user's connected observations are acceptance facts. A green automated + test cannot overrule a live regression. +- Never use `git add -A`, `git add .`, `git reset --hard`, or `git checkout -- `. Stage exact paths only. -- NEVER stage, revert, or normalize these user-local files: `AGENTS.md`, - `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs`, - `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs`, - `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`, - `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs`, - `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs`, - `tools/A8CellAudit/A8CellAudit.csproj`, the root - `implementer-progress.md`, and any `launch-*.log`. -- No workarounds without explicit user approval: root causes only — no - rate knobs, grace periods, suppression flags, or catch-and-swallow. -- Do not push. Commit locally to `codex/port-claude-agents` only. -- Connected gates need the user's ACE server at `127.0.0.1:9000` - (normally up). Follow CLAUDE.md's "Logout-before-reconnect" graceful- - close discipline between client launches. If ACE is unreachable, STOP - and report — never fake or skip a connected gate. +- Do not delete or normalize unrelated/untracked worktree content. +- Each independent fix must be a bisectable commit whose message records the + root cause and evidence. +- Update the divergence register and issues in the same commit that changes + their truth. Retire a row only when its exact legacy mechanism is gone. +- Do not push unless the user explicitly asks. +- Connected tests require the user's ACE server at `127.0.0.1:9000`. Close + the client gracefully before reconnecting so ACE releases the session. -## Where everything is +## What has been completed -- **This directory** (`docs/research/2026-08-02-collision-throughput-handoff/`): - `design-note.md` (the approved D2 delta-commit design), - `implementer-progress.md` (the complete campaign evidence trail — read - the `## Collision-clone O1/O2/O3` and `## C3c-F4` sections first), - `docs-drafts.md` (register/digest drafts NOT yet applied), - `user-observations-*.md` (the axioms). -- Campaign plan: `docs/plans/2026-08-02-placement-cutover.md`. C3c - closeout: `docs/research/2026-08-02-c3c-cutover-closeout.md`. Issues: - `docs/ISSUES.md` #276–#279. -- Recent commits: `529e0e9d` (C3c production placement cutover — REVIEWED - and accepted), `f4ef2b2a`/`c52ce14a` (docs), `71604331` (the O1–O3 - collision delta-commit — **WIP, ON HOLD, NO review passed it**; its - commit message summarizes state). -- Logs (untracked): `launch-feeltest-oclone.log` (the FAILING user feel - test on the O-tree), `logs/connected-r6-soak-20260802-194423.*` - (current soak: Passed=false, 4 failures), - `logs/connected-r6-soak-20260802-143157.*` (pre-O soak: 37 failures), - `logs/connected-r6-soak-20260727-004942.artifacts` (last fully-PASSING - baseline), `logs/connected-world-gate-20260802-193029` (lifecycle PASS - on the O-tree). +### C3c and collision-publication checkpoint -## Problems to diagnose and fix (priority order) +- `529e0e9d` — C3c production first-entry cutover for graphical and headless + hosts. +- `71604331` — O(changed) per-landblock collision publication checkpoint. + Its original commit was deliberately marked WIP after the first feel test; + do not treat that old label as the current product status. The following + fixes addressed the observed failures. -**P1 — the retirement-receipt exception loop (smoking gun).** -`launch-feeltest-oclone.log` contains **243** occurrences of -`streaming: origin-recenter preparation will resume: -System.InvalidOperationException: Landblock already has a full -retirement receipt.` — a continuous catch-retry loop during origin -recenter. Find the receipt-lifecycle break introduced (or exposed) by -the O1–O3 diff (`git show 71604331`): the retirement machinery is -`RuntimePhysicsState.AdvanceCollisionRetirementMutation` (~:1880-1990), -`StreamingController.cs` `AdvanceRetirements` call sites (:648/:670/:689/:736), -recenter adoption at `LandblockPresentationPipeline.cs:322-345`. The -catch-and-resume site that swallows this exception may itself violate -the no-silent-catch rule — evaluate it too. +### Stabilization fixes, all user-verified where visual behavior applies -**P2 — user-visible placement failures on the O-tree (the axioms).** -Quote: "Monsters pop into existence as I run by them. ran to a different -place, spawned in the air long in front of me, stabs are placed -incorrectly. This is not how retail worked. I could see monsters way in -front of me." Suspects, in order: (a) P1's stalled/looping recenter -preparation starving placement; (b) the commit-time reflood diverging -from the deleted staged-clone world in live conditions -(`PhysicsEngine.CommitLandblockReplacement` + -`RefloodPrefixOwnersAfterReplacement`; the oracle test -`CommitTimeRefloodMatchesPrecomputedReflood` covers only 3 landblock -archetypes and may under-cover); (c) siblings of the staged-owner -admission gap the implementer already fixed once (ShadowObjectRegistry -phase 2); (d) ordering between the reflood and admission -commit/RetryDeferred for operations parked on the republished prefix. -Note: under C3c rules entities WAIT for collision before placing — so -"spawned mid-air" means placement completed against wrong/incomplete -collision, not merely late. +1. `01f4791e` — **retirement-receipt replay loop fixed.** + - Root cause: a pending-only live-projection bucket was promoted to a + second full landblock cleanup receipt after the first detach had already + committed. The duplicate guard threw; a broad resumable path replayed + the detach 243 times. + - Fix: retain pending identities without manufacturing another receipt; + post-commit receipt invariants are terminal, not resumable. + - Evidence: focused recenter tests; complete Release suite 10,815 passed / + 4 skipped; lifecycle report + `logs/connected-world-gate-20260802-203751/report.json`; nine-stop report + `logs/connected-r6-soak-20260802-204309.report.json` with `Passed=true`, + nine checkpoints, zero failures, zero wait cues, zero pending + retirements, and no recurrence of the 243x exception. -**P3 — the soak's 4 remaining failures.** -`connected-r6-soak-20260802-194423.report.json`: -`pendingLandblockRetirements`=131/122 at sawato-baseline/plateau, -`waitCueShown=true` at holtburg, cpuUs p99 over-limit at the six -non-Caul stops. The WIP's "exposed pre-existing" classification -(reasoning in `implementer-progress.md ## Collision-clone`) was made -BEFORE P1 was discovered — re-judge it against the exception loop. +2. `670f307c` — **remote placement and targeting share one world frame.** + - Root cause: CreateObject positions are landblock-local, but Runtime + submitted remote first-entry placement with zero world offset; the local + physics host also published a landblock-local origin to targeting. + - Fix: Runtime owns the accepted local-player world center, converts remote + Create placement before SetPosition, and publishes the local body's world + position. + - User gate: monsters and statics place correctly; monsters chase and hit + the visible player instead of attacking another coordinate. -**P4 — door/use approach regression (in COMMITTED C3c code, `529e0e9d`).** -Quote: "I dont approach doors when I use them." Using a door no longer -walks the character to it first. Prime suspect: -`src/AcDream.App/Input/PlayerModeController.cs`'s conditional MoveTo -bind (`if (controller.MoveTo is { } moveTo)`) — the legacy path CREATED -the MoveToManager at attach via factory; the flip only binds approach -callbacks IF Runtime's publication chain already ran `MakeMoveToManager` -(`RuntimeLocalPlayerPhysicsPublicationState`). Establish when (or -whether) Runtime creates it relative to player-mode attach; the -interaction flow is `SelectionInteractionController` → -approach-MoveTo → use (see `claude-memory/project_interaction_pipeline.md` -and register row AD-27). +3. `1fc529cd` — **distant Use/approach restored.** + - Root cause: Runtime object lookup is intentionally non-constructing, so a + static door/corpse could enter MoveTo without a physics host and its + target snapshot expired at the origin. Startup placement could also + leave an impossible pre-PartArray motion suffix ahead of later actions. + - Fix: ensure the canonical minimal static host before routing MoveTo and + reconcile the startup suffix exactly at presentation attach. + - User gate: near and distant object use works, including approach, turn, + and use after arrival. -**P5 — intermittent spell particle loss (#279, C3c-era).** -One-shot VFX/scripts arriving while their entity is in C3c's -suppressed-until-receipt window are dropped instead of deferred; retail -queues scripts for not-yet-in-world objects (grep -`docs/research/named-retail/acclient_2013_pseudo_c.txt` for the -PlayScript/pending-script handling around `HandleCreateObject` -0x00454C80 / `set_hidden` 0x00514C60). Route the deferral through the -presentation-binding moment -(`TryApplyInitialCreateCompletionPresentation` in -`src/AcDream.App/World/LiveEntityRuntime.cs` and the equivalent headless -sink). +4. `f24532ad` — **spell, recall, projectile, and static VFX binding fixed.** + - Root cause: C3c could create effect/projectile/static-animation sidecars + before first SetPosition had bound the entity's mesh, pose, cell, and + visibility. One-shot F754/F755 packets were lost and projectiles could + inherit a cell-less body. + - Fix: exact-incarnation presentation barrier and FIFO replay; retry + projectile/static binding on the committed visibility edge; synchronize + effect cells on rebucket. + - User gate: buffs/protections, recall effects, arrows, combat spell + projectiles, portals, and static animation all work. -**P6 — verify after P1–P3 land:** purple materialization haze re-firing -while standing still (#278a — believed the same root as the visibility -edges), the monster pop-in re-check, slope-glide vs open #269. +5. `175ad6b0` — **login materialization acknowledgement fixed.** + - Root cause: ACE creates the local player Hidden and releases that state on + LoginComplete. Sending LoginComplete from raw F746 receipt raced first + canonical placement and left the purple haze over the character. + - Fix: one completion callback from Runtime's local first-entry terminal + edge; content-less headless retains its only truthful accepted-Create + edge. + - User gate: ordinary login no longer leaves the purple haze; recall still + has the intended materialization presentation. -## Required process per fix +### Latest focused verification -1. Root cause with file:line evidence BEFORE writing the fix; for any - AC-specific behavior, cite the named-retail decomp. -2. Tests that fail pre-fix and pass post-fix. -3. Any new retail deviation gets its register row - (`docs/architecture/retail-divergence-register.md`) in the SAME - commit; when the collision work lands for real, APPLY the prepared - `docs-drafts.md` entries (AD-6 successor note, AD-62, digest entries, - #280) rather than re-deriving them. -4. Gates before each commit: `dotnet build AcDream.slnx -c Release`; - `dotnet test AcDream.slnx -c Release --no-build --no-restore -m:1` - with `ACDREAM_PAK_PATH=C:\Users\erikn\Documents\Asheron's Call\acdream.pak` - (baseline: 10,812 passed / 0 failed / 4 skipped); - `tools/run-connected-world-lifecycle-gate.ps1` must PASS; then - `tools/run-connected-r6-soak.ps1` — the FINAL acceptance: - `report.json` `Passed=true`, zero failures, `waitCueShown=false` at - all nine stops, per-checkpoint cpuUs p99 within +10% of the - 20260727-004942 baseline. READ `report.json`, never the markers log. - A failure whose only signature is `activeTeleportCount=1` at a stable - checkpoint is user interference — one clean re-run allowed; any other - failure is real: fix it, don't re-run past it. -5. COMMIT each completed fix as its own bisectable commit on - `codex/port-claude-agents` with a message explaining the why. -6. Manual verification launch (for the user): the PowerShell launch in - CLAUDE.md "Running the client against the live server", plus - `ACDREAM_RETAIL_UI=1`. +After the final fix, these passed: -## Final deliverable +- 90 focused App effect/projectile/static-animation scheduler tests. +- Two focused Runtime login-completion tests. +- The exact live-entity cell-tracking regression. +- All 79 Headless tests. +- `dotnet build AcDream.slnx -c Release --no-restore` with zero errors + (existing warnings remain). -Per-problem disposition (root cause → fix → evidence), the commit SHAs, -full gate numbers including the soak report values, and an explicit list -of anything left open. +The long complete suite and connected nine-stop soak were **not rerun after +the final four stabilization commits**. The P1 soak proves P1's binary, not +the final campaign binary. + +## What remains — execute in this order + +### 1. Reconcile the six selected-fixture failures + +A broad selected run after cleanup exposed: + +- five failures in `LiveEntityRuntimeTests`, associated with the still-open + placement/cell cutover; +- one old `RuntimeLiveEntitySessionControllerTests` remote-first-entry fixture + that provides an empty collision source while the production contract now + requires truthful collision admission. + +Re-run these two classes first and record the exact test names and assertions. +Classify each as either a real product failure or a stale fixture. If stale, +update the fixture to provide the same valid prepared collision neighborhood +as production; never weaken the product contract or merely change expected +values. If real, fix the owning production mechanism and add a smaller +regression test. + +Suggested first commands: + +```powershell +$env:ACDREAM_PAK_PATH='C:\Users\erikn\Documents\Asheron''s Call\acdream.pak' +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~LiveEntityRuntimeTests -m:1 +dotnet test tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~RuntimeLiveEntitySessionControllerTests -m:1 +``` + +### 2. Finish C4: remaining authoritative placement routes + +The plan still marks routes 2–7 open: + +- route 2: ForcePosition; +- route 3: portal placement through `RuntimeWorldTransitState` and + `RuntimePortalPlacementAuthority`; +- route 4: remote Create/Position, deleting the remaining + `RemoteTeleportController`/inline MoveOrTeleport duplicate; +- route 5: authoritative projectile correction; +- route 6: drops and split-recovery marking; +- route 7: pickup, parent-detach, and delete/recreate residue. + +Inventory every current writer before editing. For each route, prove: + +- one Runtime SetPosition transaction owns accepted frame, exact cell, + collision result, shadow/workset membership, and deferred-cell lifetime; +- App only projects the committed result; +- graphical and headless hosts use the same command and state path; +- stale sequences, delete/GUID reuse, missing cells, portal generations, and + replacement collision generations cannot commit old state; +- no route reconstructs from a stale spawn or uses the legacy outdoor demote/ + terrain-Z lift. + +Resolve #276 when the spawn settler's resolved `CellId` becomes authoritative. +Resolve #277 with a real service-window/celless lifecycle instead of relying +on ACE's current broadcast radius. + +### 3. Fix #280: destination prefetch before portal reveal + +Current behavior waits only a hard-coded radius-one (3x3) outdoor +neighborhood, while the visible configured world extends farther. The user +can see distant terrain continue building after portal exit. + +Port the retail mechanism, not a larger magic number: + +- `CellManager::PreFetchCells @ 0x00455820`; +- `LScape::PreFetchCells @ 0x00505660`; +- `CLandBlock::PreFetchCells` and `CLandBlockInfo::PreFetchCells`; +- `SmartBox::UseTime @ 0x00455410` while `blocking_for_cells`; +- the `TAS_TUNNEL_CONTINUE` resume/reveal order. + +Use the quality/view-distance configured destination window. Hold one +generation-scoped reservation across terrain, buildings/statics, EnvCells, +render publication, composite textures, and collision. Keep portal UI and +wait cue responsive. Never reveal early because of a timeout, and do not wait +for an impossible terminal marker for all dynamic ACE objects. + +Acceptance: repeated login, `/ls`, spell recall, and portals at every quality +setting reveal no constructing terrain, missing nearby statics/buildings, +unready interiors, missing composites, or absent nearby collision. Dynamic +monsters/items may still arrive later from ACE. + +### 4. C5 closeout and live gates + +After steps 1–3: + +1. Delete every superseded placement writer and compatibility projection. +2. Run focused Runtime/Core/App tests for every route. +3. Run the complete Release solution suite with the installed pak. +4. Run the exact lifecycle/reconnect route. +5. Run the canonical nine-stop soak on the **final binary**. Read + `report.json`, not marker output. Required: `Passed=true`, zero failures, + every canonical checkpoint, `waitCueShown=false`, zero pending + publication/retirement/reveal debt, graceful exit, and no render-shadow + mismatch. Diagnose any real failure; do not rerun past it. +6. Perform two-client observation for remote creation, chase/attack, doors, + drops/pickups, portal departure/arrival, arrows, and spells. +7. Ask the user for the remaining #269/#278 slope-glide comparison at the + known impassable slope. + +Only then retire AP-1, AD-1, AP-131, and the legacy half of AD-60 and close +the corresponding placement issues. + +### 5. Finish the original physics-divergence campaign + +After placement C5 is green: + +- **AP-22:** make `ShadowShapeBuilder` the sole authority for authored Setup + collision shapes. Preserve cylinder order; use authored spheres when there + are no cylinders; cylinder-first for mixed data; truly shapeless means no + shadow. Remove invented `Setup.Radius` cylinders, `Radius * 2` heights, and + sphere-to-cylinder coercion across graphical, headless, static, and live + paths. +- **AD-10:** prove remote motion uses the full transition sweep, remove + terrain-normal preprojection, and let `CTransition::adjust_offset` project + against the actual retained contact plane. Preserve interpolation, + correction replacement, Hidden state, and network cadence. +- Run the final movement/collision matrix and update the divergence ledger, + architecture, roadmap, milestones, memory, `CLAUDE.md`, and `AGENTS.md`. + Resume vendor Slice 5 only after this campaign is genuinely closed. + +## Required deliverable + +For every remaining item report: + +- observed failure and deterministic reproduction; +- retail/reference evidence with named functions and addresses; +- root cause in plain language plus file/line evidence; +- exact fix and why it preserves Runtime ownership; +- tests added or corrected; +- commit SHA; +- complete build/test/connected-gate numbers; +- user visual result where required; +- divergence/issue rows retired, narrowed, or left open. + +Finish with an explicit list of anything still open. Do not describe the +campaign as complete while any C4 route, #280, final-binary soak, AP-22, +AD-10, or required user visual gate remains. diff --git a/docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md b/docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md index cc722d76..08f60936 100644 --- a/docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md +++ b/docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md @@ -1,5 +1,14 @@ # Docs-commit drafts — collision publication-throughput fix (O1/O2/O3) +> **HISTORICAL DRAFT — DO NOT APPLY WHOLESALE (2026-08-03).** The O1/O2/O3 +> implementation landed in `71604331` and the user-visible stabilization +> fixes continued through `175ad6b0`. This draft predates that work, assigns +> issue number #280 to the collision clone even though #280 now canonically +> tracks incomplete portal-destination prefetch, and names ledger edits that +> must be re-audited against the final production tree. It remains only as +> research evidence. Use `NEXT-AGENT-PROMPT.md`, the campaign plan, and the +> live divergence register for current work. + Drafted per contract; NOT applied to the repo. Apply in the docs commit after code review. Register judgment executed as pinned: AD-6 stays retired with a successor note; the residual timing/order compression gets a NEW row (AD-62). diff --git a/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md b/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md index 87061cd1..397b3e61 100644 --- a/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md +++ b/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md @@ -4547,3 +4547,41 @@ _entities.AdvanceCreateAuthority in the executor, inside ApplyWeenieDescriptionAction; registration advance still residence-gated). Focused files 82/82; Runtime 1,003/1,003; App 4,039/3 skips of 4,042; git diff --check exit 0. No staging/commits. + +## 2026-08-03 stabilization checkpoint and handoff + +Five separately bisectable root-cause fixes followed the O1/O2/O3 checkpoint: + +1. `01f4791e` — pending-only live projection buckets no longer manufacture a + second origin-recenter retirement receipt. Complete Release, lifecycle, + and canonical nine-stop soak passed on this binary; the soak had nine + checkpoints, zero failures, zero wait cues, and zero pending retirements. +2. `670f307c` — Runtime converts remote Create frames through the accepted + local-player world center and publishes the local physics body's world + position. The user accepted monster/static placement, chase, and attacks. +3. `1fc529cd` — distant Use materializes the canonical minimal static physics + host before MoveTo, and presentation attach reconciles the pre-PartArray + startup motion suffix. The user accepted near and distant object use. +4. `f24532ad` — exact-incarnation effect packets wait for canonical + presentation binding; projectile and static-animation sidecars retry on the + committed visibility edge; effect cells follow rebuckets. The user accepted + buffs, recalls, arrows, combat spell projectiles, portals, and statics. +5. `175ad6b0` — LoginComplete is emitted from the local first-placement + terminal edge instead of raw PlayerCreate receipt. The user accepted login + materialization haze behavior. + +Post-fix focused evidence: 90 App effect/projectile/static-scheduler tests, +two Runtime login tests, the exact live-entity cell tracking test, all 79 +Headless tests, and a Release solution build with zero errors passed. The +complete suite and connected nine-stop soak were not rerun after the final +four stabilization commits. A broader selected fixture run exposed five +still-open `LiveEntityRuntimeTests` failures associated with the placement +cutover and one old remote-first-entry fixture that supplies an empty +collision source. These are campaign work, not evidence to weaken the new +production contracts. + +Open campaign finish: classify/fix those six fixtures; finish placement routes +2–7 and delete legacy writers; resolve #276/#277 and portal-prefetch #280; run +the final-binary complete suite/lifecycle/nine-stop/two-client gates; perform +the #269 slope-glide visual check; retire AP-1/AD-1/AP-131/AD-60 only when the +legacy paths are gone; then land AP-22 and AD-10 and close the ledger. diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md index bf488e54..ab119c48 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -1,7 +1,38 @@ # Collision System Port - Status and Plan +## 2026-08-03 production cutover stabilization checkpoint + +Production initial Create placement is cut over through the Runtime residence +and continuation-executor path. The O(changed) collision-publication +checkpoint is live, followed by five root-cause fixes: + +- `01f4791e`: pending-only live projections no longer create/replay a second + origin-recenter retirement receipt; +- `670f307c`: remote Create placement, local-player hosting, targeting, chase, + and attack share the same world-coordinate frame; +- `1fc529cd`: distant Use materializes the canonical minimal static host before + MoveTo; +- `f24532ad`: effects, projectiles, and static animation bind after canonical + placement and follow the committed cell; +- `175ad6b0`: LoginComplete waits for the local first-placement terminal edge. + +The user accepted those connected behaviors. The campaign remains open for +six fixture reconciliations, C4 placement routes 2–7, #276/#277, destination +prefetch #280, C5's final-binary suite/soak/two-client matrix, AP-22, and +AD-10. Canonical continuation prompt: +`docs/research/2026-08-02-collision-throughput-handoff/NEXT-AGENT-PROMPT.md`. + +**Do not use the 2026-04-29 whole-world staging-root description below as a +production blueprint.** It is retained as historical context. O1/O2/O3 at +`71604331` replaced that clone/journal/rebase design with the per-landblock +O(changed) publication mechanism; `01f4791e` then repaired its recenter +receipt lifetime. + ## 2026-08-01 initial Create residence checkpoint +> Historical checkpoint; superseded by the 2026-08-03 production cutover +> status above. + The dormant Runtime SetPosition path is now complete through activation, graphical/no-window placement receipts, collision-prefix quiescence, exact authoritative route classification, pre-placement App staging, and initial