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() {